diff --git a/Makefile b/Makefile
index ac8d75adcf..a6dbb2e11c 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ GOBIND=env PATH="$(GOBIN):$(PATH)" "$(GOMOBILE)" bind
IMPORT_HOST=github.com
IMPORT_PATH=$(IMPORT_HOST)/Jigsaw-Code/outline-client
-.PHONY: android apple linux windows
+.PHONY: android apple linux windows browser
all: android apple linux windows
@@ -73,3 +73,6 @@ $(XGO): go.mod
go.mod: tools.go
go mod tidy
touch go.mod
+
+browser:
+ echo 'browser environment: nothing to do'
diff --git a/commitlint.config.js b/commitlint.config.js
index d53855c5e3..a123704e62 100644
--- a/commitlint.config.js
+++ b/commitlint.config.js
@@ -22,5 +22,10 @@ module.exports = {
'service/windows',
],
],
+ 'type-enum': [
+ 2,
+ 'always',
+ ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'proposal', 'refactor', 'revert', 'style', 'test'],
+ ],
},
};
diff --git a/resources/original_messages.json b/resources/original_messages.json
index e87f6bc88a..17001f33de 100644
--- a/resources/original_messages.json
+++ b/resources/original_messages.json
@@ -537,6 +537,14 @@
"description": "The success message after renaming a server in the application.",
"message": "Server renamed"
},
+ "server_share": {
+ "description": "The text of an option displayed in a server card's options menu to tell the application to share the server's access key with another application.",
+ "message": "Share"
+ },
+ "server_share_text": {
+ "description": "The text of a message that appears when the user clicks the Share option in a server card's options menu.",
+ "message": "This is an access key for an Outline Server. To use it, download the Outline app from the App Store or Google Play."
+ },
"servers_menu_item": {
"description": "The menu item text to navigate to the list of servers.",
"message": "Servers"
diff --git a/src/electron/go_vpn_tunnel.ts b/src/electron/go_vpn_tunnel.ts
index 33d11bb2f2..dff3714546 100755
--- a/src/electron/go_vpn_tunnel.ts
+++ b/src/electron/go_vpn_tunnel.ts
@@ -16,7 +16,7 @@ import {powerMonitor} from 'electron';
import {platform} from 'os';
import {pathToEmbeddedBinary} from '../infrastructure/electron/app_paths';
-import {ShadowsocksSessionConfig} from '../www/app/tunnel';
+import {ShadowsocksSessionConfig} from '../www/model/shadowsocks_session_config';
import {TunnelStatus} from '../www/app/tunnel';
import {ErrorCode, fromErrorCode, UnexpectedPluginError} from '../www/model/errors';
diff --git a/src/electron/index.ts b/src/electron/index.ts
index 60820e502a..e69857daf4 100644
--- a/src/electron/index.ts
+++ b/src/electron/index.ts
@@ -25,7 +25,7 @@ import autoLaunch = require('auto-launch'); // tslint:disable-line
import * as errors from '../www/model/errors';
-import {ShadowsocksSessionConfig} from '../www/app/tunnel';
+import {ShadowsocksSessionConfig} from '../www/model/shadowsocks_session_config';
import {TunnelStatus} from '../www/app/tunnel';
import {GoVpnTunnel} from './go_vpn_tunnel';
import {installRoutingServices, RoutingDaemon} from './routing_service';
diff --git a/src/electron/tunnel_store.ts b/src/electron/tunnel_store.ts
index b920f765d1..e78b775087 100755
--- a/src/electron/tunnel_store.ts
+++ b/src/electron/tunnel_store.ts
@@ -15,7 +15,7 @@
import * as fs from 'fs';
import * as path from 'path';
-import {ShadowsocksSessionConfig} from '../www/app/tunnel';
+import {ShadowsocksSessionConfig} from '../www/model/shadowsocks_session_config';
// Format to store a tunnel configuration.
export interface SerializableTunnel {
diff --git a/src/www/app/app.ts b/src/www/app/app.ts
index a22c3cd10d..3c272108fb 100644
--- a/src/www/app/app.ts
+++ b/src/www/app/app.ts
@@ -130,6 +130,8 @@ export class App {
this.rootEl.addEventListener('DisconnectPressed', this.disconnectServer.bind(this));
this.rootEl.addEventListener('ForgetPressed', this.forgetServer.bind(this));
this.rootEl.addEventListener('RenameRequested', this.renameServer.bind(this));
+ this.rootEl.addEventListener('ForgetPressed', this.forgetServer.bind(this));
+ this.rootEl.addEventListener('ShareServer', this.shareServer.bind(this));
this.rootEl.addEventListener('QuitPressed', this.quitApplication.bind(this));
this.rootEl.addEventListener('AutoConnectDialogDismissed', this.autoConnectDialogDismissed.bind(this));
this.rootEl.addEventListener('ShowServerRename', this.rootEl.showServerRename.bind(this.rootEl));
@@ -378,6 +380,23 @@ export class App {
this.serverRepo.rename(serverId, newName);
}
+ private async shareServer(event: CustomEvent) {
+ const {serverId} = event.detail;
+ const server = this.getServerByServerId(serverId);
+
+ // TODO: fallback to copying to clipboard if share is not available
+ if (!navigator.share) {
+ console.warn('Web Share API not available');
+ return;
+ }
+
+ await navigator.share({
+ title: server.name || 'Outline Server',
+ text: this.localize('share-server-text'),
+ url: server.accessKey,
+ });
+ }
+
private async connectServer(event: CustomEvent) {
event.stopImmediatePropagation();
@@ -589,7 +608,7 @@ export class App {
// Helpers:
private makeServerListItem(server: Server): ServerListItem {
- return {
+ const serverListItem: ServerListItem = {
disabled: false,
errorMessageId: server.errorMessageId,
isOutlineServer: server.isOutlineServer,
@@ -598,6 +617,29 @@ export class App {
id: server.id,
connectionState: ServerConnectionState.DISCONNECTED,
};
+
+ if (server.sessionConfig?.extra) {
+ const extraParams = server.sessionConfig.extra;
+
+ if (['error', 'warning', 'info'].includes(extraParams.messageType) && extraParams.messageContent) {
+ serverListItem.message = {
+ type: extraParams.messageType as 'error' | 'warning' | 'info',
+ content: extraParams.messageContent,
+ };
+ }
+
+ if (extraParams.contactEmail) {
+ serverListItem.contact = {
+ email: extraParams.email,
+ };
+ }
+
+ if (extraParams.share) {
+ serverListItem.canShare = true;
+ }
+ }
+
+ return serverListItem;
}
private throttleServerConnectionChange(serverId: string, time: number) {
diff --git a/src/www/app/cordova_main.ts b/src/www/app/cordova_main.ts
index 75c3372a31..ee21ca5fbd 100644
--- a/src/www/app/cordova_main.ts
+++ b/src/www/app/cordova_main.ts
@@ -34,7 +34,7 @@ import {Tunnel, TunnelStatus} from './tunnel';
import {AbstractUpdater} from './updater';
import * as interceptors from './url_interceptor';
import {FakeOutlineTunnel} from './fake_tunnel';
-import {ShadowsocksSessionConfig} from './tunnel';
+import {ShadowsocksSessionConfig} from '../model/shadowsocks_session_config';
import {NoOpVpnInstaller, VpnInstaller} from './vpn_installer';
const OUTLINE_PLUGIN_NAME = 'OutlinePlugin';
diff --git a/src/www/app/electron_outline_tunnel.ts b/src/www/app/electron_outline_tunnel.ts
index 55d7d8c076..e2c4d0b1fb 100644
--- a/src/www/app/electron_outline_tunnel.ts
+++ b/src/www/app/electron_outline_tunnel.ts
@@ -14,7 +14,8 @@
import * as errors from '../model/errors';
-import {Tunnel, TunnelStatus, ShadowsocksSessionConfig} from './tunnel';
+import {Tunnel, TunnelStatus} from './tunnel';
+import {ShadowsocksSessionConfig} from '../model/shadowsocks_session_config';
export class ElectronOutlineTunnel implements Tunnel {
private statusChangeListener: ((status: TunnelStatus) => void) | null = null;
diff --git a/src/www/app/fake_tunnel.ts b/src/www/app/fake_tunnel.ts
index 54274f6068..3550267c40 100644
--- a/src/www/app/fake_tunnel.ts
+++ b/src/www/app/fake_tunnel.ts
@@ -14,7 +14,8 @@
import * as errors from '../model/errors';
-import {Tunnel, TunnelStatus, ShadowsocksSessionConfig} from './tunnel';
+import {Tunnel, TunnelStatus} from './tunnel';
+import {ShadowsocksSessionConfig} from '../model/shadowsocks_session_config';
// Fake Tunnel implementation for demoing and testing.
// Note that because this implementation does not emit disconnection events, "switching" between
diff --git a/src/www/app/outline_server_repository/access_key_serialization.ts b/src/www/app/outline_server_repository/access_key_serialization.ts
index 17dcc560ce..0d3ba4fd80 100644
--- a/src/www/app/outline_server_repository/access_key_serialization.ts
+++ b/src/www/app/outline_server_repository/access_key_serialization.ts
@@ -16,7 +16,7 @@ import {SHADOWSOCKS_URI} from 'ShadowsocksConfig';
import * as errors from '../../model/errors';
-import {ShadowsocksSessionConfig} from '../tunnel';
+import {ShadowsocksSessionConfig} from '../../model/shadowsocks_session_config';
// DON'T use these methods outside of this folder!
@@ -30,6 +30,7 @@ export function staticKeyToShadowsocksSessionConfig(staticKey: string): Shadowso
method: config.method.data,
password: config.password.data,
prefix: config.extra?.['prefix'],
+ extra: config.extra,
};
} catch (cause) {
throw new errors.ServerAccessKeyInvalid('Invalid static access key.', {cause});
@@ -37,7 +38,7 @@ export function staticKeyToShadowsocksSessionConfig(staticKey: string): Shadowso
}
function parseShadowsocksSessionConfigJson(maybeJsonText: string): ShadowsocksSessionConfig | null {
- const {method, password, server, server_port, prefix} = JSON.parse(maybeJsonText);
+ const {method, password, server, server_port, prefix, extra} = JSON.parse(maybeJsonText);
// These are the mandatory keys.
const missingKeys = [];
@@ -58,6 +59,7 @@ function parseShadowsocksSessionConfigJson(maybeJsonText: string): ShadowsocksSe
host: server,
port: server_port,
prefix,
+ extra,
};
}
diff --git a/src/www/app/outline_server_repository/server.ts b/src/www/app/outline_server_repository/server.ts
index ed94aa24ee..238978c912 100644
--- a/src/www/app/outline_server_repository/server.ts
+++ b/src/www/app/outline_server_repository/server.ts
@@ -15,8 +15,9 @@
import * as errors from '../../model/errors';
import * as events from '../../model/events';
import {Server, ServerType} from '../../model/server';
+import {ShadowsocksSessionConfig} from '../../model/shadowsocks_session_config';
-import {Tunnel, TunnelStatus, ShadowsocksSessionConfig} from '../tunnel';
+import {Tunnel, TunnelStatus} from '../tunnel';
import {fetchShadowsocksSessionConfig, staticKeyToShadowsocksSessionConfig} from './access_key_serialization';
@@ -28,7 +29,7 @@ export class OutlineServer implements Server {
private static readonly SUPPORTED_CIPHERS = ['chacha20-ietf-poly1305', 'aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm'];
errorMessageId?: string;
- private sessionConfig?: ShadowsocksSessionConfig;
+ sessionConfig?: ShadowsocksSessionConfig;
constructor(
public readonly id: string,
@@ -44,6 +45,7 @@ export class OutlineServer implements Server {
break;
case ServerType.STATIC_CONNECTION:
default:
+ this.accessKey = accessKey;
this.sessionConfig = staticKeyToShadowsocksSessionConfig(accessKey);
break;
}
diff --git a/src/www/app/tunnel.ts b/src/www/app/tunnel.ts
index fc475e2a21..8564d69176 100644
--- a/src/www/app/tunnel.ts
+++ b/src/www/app/tunnel.ts
@@ -12,13 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-export interface ShadowsocksSessionConfig {
- host?: string;
- port?: number;
- password?: string;
- method?: string;
- prefix?: string;
-}
+import {ShadowsocksSessionConfig} from '../model/shadowsocks_session_config';
export const enum TunnelStatus {
CONNECTED,
diff --git a/src/www/messages/en.json b/src/www/messages/en.json
index db0ffcdecd..40acc4e0e0 100644
--- a/src/www/messages/en.json
+++ b/src/www/messages/en.json
@@ -103,6 +103,8 @@
"server-forgotten-undo": "Server “{serverName}” has been restored.",
"server-rename": "Rename",
"server-rename-complete": "Server renamed",
+ "server-share": "Share",
+ "server-share-text": "This is an access key for an Outline Server. To use it, download the Outline app from the App Store or Google Play.",
"servers-menu-item": "Servers",
"servers-page-title": "Outline",
"submit": "Submit",
diff --git a/src/www/model/server.ts b/src/www/model/server.ts
index f789f78331..bf9ef0a405 100644
--- a/src/www/model/server.ts
+++ b/src/www/model/server.ts
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import {ShadowsocksSessionConfig} from './shadowsocks_session_config';
+
// TODO: add guidelines for this file
export enum ServerType {
@@ -31,9 +33,15 @@ export interface Server {
// A type specifying the manner in which the Server connects.
readonly type: ServerType;
+ // The access key used to connect to the server.
+ accessKey: string;
+
// The name of this server, as given by the user.
name: string;
+ // The configuration used to connect to the server.
+ sessionConfig?: ShadowsocksSessionConfig;
+
// The location to pull the session config from on each connection.
sessionConfigLocation?: URL;
diff --git a/src/www/model/shadowsocks_session_config.ts b/src/www/model/shadowsocks_session_config.ts
new file mode 100644
index 0000000000..542449c53b
--- /dev/null
+++ b/src/www/model/shadowsocks_session_config.ts
@@ -0,0 +1,22 @@
+// Copyright 2024 The Outline Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export interface ShadowsocksSessionConfig {
+ host?: string;
+ port?: number;
+ password?: string;
+ method?: string;
+ prefix?: string;
+ extra?: {[key: string]: string};
+}
diff --git a/src/www/style.css b/src/www/style.css
index 01eb8c76c6..48e537fcb0 100644
--- a/src/www/style.css
+++ b/src/www/style.css
@@ -27,6 +27,7 @@
--min-supported-device-width: 320px;
--outline-primary: hsl(170, 60%, 46%);
+ --outline-warning: hsl(48, 52%, 53%);
--outline-error: hsl(4, 90%, 58%);
--outline-black: hsl(0, 0%, 0%);
diff --git a/src/www/views/servers_view/server_list/index.ts b/src/www/views/servers_view/server_list/index.ts
index 0accdc8e66..763fcde7e2 100644
--- a/src/www/views/servers_view/server_list/index.ts
+++ b/src/www/views/servers_view/server_list/index.ts
@@ -34,11 +34,11 @@ export class ServerList extends PolymerElement {
server-row-card {
margin: 0 auto 8px auto;
- height: 130px;
+ height: 200px;
}
server-hero-card {
- height: 400px;
+ height: 500px;
}
diff --git a/src/www/views/servers_view/server_list_item/index.ts b/src/www/views/servers_view/server_list_item/index.ts
index be1086e1ca..1c6689ca02 100644
--- a/src/www/views/servers_view/server_list_item/index.ts
+++ b/src/www/views/servers_view/server_list_item/index.ts
@@ -21,6 +21,7 @@ export enum ServerListItemEvent {
DISCONNECT = 'DisconnectPressed',
FORGET = 'ForgetPressed',
RENAME = 'ShowServerRename',
+ SHARE = 'ShareServer',
}
/**
@@ -34,6 +35,14 @@ export interface ServerListItem {
id: string;
name: string;
connectionState: ServerConnectionState;
+ message?: {
+ type: 'error' | 'warning' | 'info';
+ content: string;
+ };
+ contact?: {
+ email: string;
+ };
+ canShare?: boolean;
}
/**
diff --git a/src/www/views/servers_view/server_list_item/server_card/index.ts b/src/www/views/servers_view/server_list_item/server_card/index.ts
index 52a218a52f..e9dfb8c79c 100644
--- a/src/www/views/servers_view/server_list_item/server_card/index.ts
+++ b/src/www/views/servers_view/server_list_item/server_card/index.ts
@@ -16,7 +16,7 @@ import '@material/mwc-icon-button';
import '@material/mwc-menu';
import '../../server_connection_indicator';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {createRef, Ref, ref} from 'lit/directives/ref.js';
@@ -123,11 +123,46 @@ const sharedCSS = css`
grid-area: footer;
padding: var(--outline-mini-gutter) var(--outline-gutter);
text-align: end;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .card-footer-button {
+ align-self: end;
}
- .card-error {
+ .card-error,
+ .card-provider-message {
+ font-family: var(--outline-font-family);
+ }
+
+ .card-error,
+ .card-provider-message-error {
color: var(--outline-error);
- margin: 0 var(--outline-slim-gutter);
+ }
+
+ .card-provider-message-warning {
+ color: var(--outline-warning);
+ }
+
+ .card-provider-message-warning::before {
+ content: '⚠️ ';
+ }
+
+ .card-provider-message-info {
+ color: var(--outline-medium-gray);
+ font-style: italic;
+ }
+
+ .card-provider-message-info::before {
+ content: 'ℹ ';
+ }
+
+ .card-provider-message-contact {
+ cursor: pointer;
+ color: var(--outline-primary);
+ text-decoration: underline;
}
`;
@@ -140,13 +175,18 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => {
ServerConnectionState.RECONNECTING,
].includes(server.connectionState);
const hasErrorMessage = Boolean(server.errorMessageId);
-
- const messages = {
+ const messages: {[key: string]: string} = {
serverName: server.name,
error: hasErrorMessage ? localize(server.errorMessageId) : '',
connectButton: localize(isConnectedState ? 'disconnect-button-label' : 'connect-button-label'),
};
+ if (Boolean(server.message && server.contact) && !hasErrorMessage) {
+ messages.providerMessageType = server.message.type;
+ messages.providerMessage = server.message.content;
+ messages.providerEmail = server.contact?.email;
+ }
+
const dispatchers = {
beginRename: () =>
element.dispatchEvent(
@@ -160,6 +200,10 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => {
element.dispatchEvent(
new CustomEvent(ServerListItemEvent.FORGET, {detail: {serverId: server.id}, bubbles: true, composed: true})
),
+ share: () =>
+ element.dispatchEvent(
+ new CustomEvent(ServerListItemEvent.SHARE, {detail: {serverId: server.id}, bubbles: true, composed: true})
+ ),
connectToggle: () =>
element.dispatchEvent(
new CustomEvent(isConnectedState ? ServerListItemEvent.DISCONNECT : ServerListItemEvent.CONNECT, {
@@ -197,6 +241,9 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => {
`,
menu: html`
+ ${server.canShare
+ ? html`${localize('server-share')}`
+ : nothing}
${localize('server-rename')}
${localize('server-forget')}
@@ -211,8 +258,17 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => {
`,
footer: html`