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`