diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index dd949e618f2..5b51df074b5 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -306,6 +306,10 @@ "name": "vs/workbench/contrib/offline", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/remoteTunnel", + "project": "vscode-workbench" + }, { "name": "vs/workbench/services/actions", "project": "vscode-workbench" diff --git a/package.json b/package.json index a4676dc092f..6a5bbde783b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.73.0", - "distro": "33ffc394a05e9cb3e9ac1fe2a72ab75610cf0d7e", + "distro": "cac14b226f101a8186316eb3dc74f1de73c0a5be", "author": { "name": "Microsoft Corporation" }, diff --git a/product.json b/product.json index 6e124afcf90..c76943372f1 100644 --- a/product.json +++ b/product.json @@ -11,6 +11,7 @@ "serverLicensePrompt": "", "serverApplicationName": "code-server-oss", "serverDataFolderName": ".vscode-server-oss", + "tunnelApplicationName": "code-tunnel-oss", "win32DirName": "Microsoft Code OSS", "win32NameVersion": "Microsoft Code OSS", "win32RegValueName": "CodeOSS", diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index e07695c9363..c7c8587b9da 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -130,6 +130,9 @@ export interface IProductConfiguration { readonly serverApplicationName: string; readonly serverDataFolderName?: string; + readonly tunnelApplicationName?: string; + readonly tunnelApplicationConfig?: { authenticationProviders: IStringDictionary<{ scopes: string[] }> }; + readonly npsSurveyUrl?: string; readonly cesSurveyUrl?: string; readonly surveys?: readonly ISurveyData[]; @@ -155,7 +158,6 @@ export interface IProductConfiguration { readonly 'configurationSync.store'?: ConfigurationSyncStore; readonly 'editSessions.store'?: Omit; - readonly darwinUniversalAssetId?: string; // experimental diff --git a/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts index 973fa4beb0c..091c36d4f28 100644 --- a/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts @@ -108,6 +108,9 @@ import { UserDataProfilesNativeService } from 'vs/platform/userDataProfile/elect import { SharedProcessRequestService } from 'vs/platform/request/electron-browser/sharedProcessRequestService'; import { OneDataSystemAppender } from 'vs/platform/telemetry/node/1dsAppender'; import { UserDataProfilesCleaner } from 'vs/code/electron-browser/sharedProcess/contrib/userDataProfilesCleaner'; +import { RemoteTunnelService } from 'vs/platform/remoteTunnel/electron-browser/remoteTunnelService'; +import { IRemoteTunnelService } from 'vs/platform/remoteTunnel/common/remoteTunnel'; +import { ISharedProcessLifecycleService, SharedProcessLifecycleService } from 'vs/platform/lifecycle/electron-browser/sharedProcessLifecycleService'; class SharedProcessMain extends Disposable { @@ -115,6 +118,8 @@ class SharedProcessMain extends Disposable { private sharedProcessWorkerService: ISharedProcessWorkerService | undefined = undefined; + private lifecycleService: SharedProcessLifecycleService | undefined = undefined;; + constructor(private configuration: ISharedProcessConfiguration) { super(); @@ -124,7 +129,14 @@ class SharedProcessMain extends Disposable { private registerListeners(): void { // Shared process lifecycle - const onExit = () => this.dispose(); + const onExit = async () => { + if (this.lifecycleService) { + await this.lifecycleService.fireOnWillShutdown(); + this.lifecycleService.dispose(); + this.lifecycleService = undefined; + } + this.dispose(); + }; process.once('exit', onExit); ipcRenderer.once('vscode:electron-main->shared-process=exit', onExit); @@ -180,6 +192,8 @@ class SharedProcessMain extends Disposable { private async initServices(): Promise { const services = new ServiceCollection(); + // Lifecycle + // Product const productService = { _serviceBrand: undefined, ...product }; services.set(IProductService, productService); @@ -211,6 +225,10 @@ class SharedProcessMain extends Disposable { const logService = this._register(new FollowerLogService(logLevelClient, multiplexLogger)); services.set(ILogService, logService); + // Lifecycle + this.lifecycleService = new SharedProcessLifecycleService(logService); + services.set(ISharedProcessLifecycleService, this.lifecycleService); + // Worker this.sharedProcessWorkerService = new SharedProcessWorkerService(logService); services.set(ISharedProcessWorkerService, this.sharedProcessWorkerService); @@ -342,6 +360,8 @@ class SharedProcessMain extends Disposable { services.set(IUserDataSyncService, new SyncDescriptor(UserDataSyncService, undefined, false /* Initializes the Sync State */)); services.set(IUserDataSyncProfilesStorageService, new SyncDescriptor(UserDataSyncProfilesStorageService, undefined, true)); + // Terminal + const ptyHostService = new PtyHostService({ graceTime: LocalReconnectConstants.GraceTime, shortGraceTime: LocalReconnectConstants.ShortGraceTime, @@ -353,7 +373,6 @@ class SharedProcessMain extends Disposable { ); ptyHostService.initialize(); - // Terminal services.set(ILocalPtyService, this._register(ptyHostService)); // Signing @@ -363,6 +382,9 @@ class SharedProcessMain extends Disposable { services.set(ISharedTunnelsService, new SyncDescriptor(SharedTunnelsService)); services.set(ISharedProcessTunnelService, new SyncDescriptor(SharedProcessTunnelService)); + // Remote Tunnel + services.set(IRemoteTunnelService, new SyncDescriptor(RemoteTunnelService)); + return new InstantiationService(services); } @@ -430,6 +452,10 @@ class SharedProcessMain extends Disposable { const sharedProcessWorkerChannel = ProxyChannel.fromService(accessor.get(ISharedProcessWorkerService)); this.server.registerChannel(ipcSharedProcessWorkerChannelName, sharedProcessWorkerChannel); + // Remote Tunnel + const remoteTunnelChannel = ProxyChannel.fromService(accessor.get(IRemoteTunnelService)); + this.server.registerChannel('remoteTunnel', remoteTunnelChannel); + } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index e427bd7c6fb..297b2cf012e 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -8,7 +8,7 @@ import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync import { homedir, release, tmpdir } from 'os'; import type { ProfilingSession, Target } from 'v8-inspect-profiler'; import { Event } from 'vs/base/common/event'; -import { isAbsolute, resolve, join } from 'vs/base/common/path'; +import { isAbsolute, resolve, join, dirname } from 'vs/base/common/path'; import { IProcessEnvironment, isMacintosh, isWindows } from 'vs/base/common/platform'; import { randomPort } from 'vs/base/common/ports'; import { isString } from 'vs/base/common/types'; @@ -24,7 +24,6 @@ import product from 'vs/platform/product/common/product'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { randomPath } from 'vs/base/common/extpath'; import { Utils } from 'vs/platform/profiling/common/profiling'; -import { dirname } from 'vs/base/common/resources'; import { FileAccess } from 'vs/base/common/network'; function shouldSpawnCliProcess(argv: NativeParsedArgs): boolean { @@ -50,6 +49,31 @@ export async function main(argv: string[]): Promise { return; } + if (args.tunnel) { + if (!product.tunnelApplicationName) { + console.error(`'tunnel' command not supported in ${product.applicationName}`); + return; + } + return new Promise((resolve, reject) => { + let tunnelProcess; + if (process.env['VSCODE_DEV']) { + tunnelProcess = spawn('cargo', ['run', '--bin', 'code-tunnel', ...argv.slice(5)], { cwd: join(getAppRoot(), 'cli') }); + } else { + const tunnelCommand = join(dirname(process.execPath), 'bin', `${product.tunnelApplicationName}${isWindows ? '.exe' : ''}`); + const tunnelArgs = argv.slice(3); + tunnelProcess = spawn(tunnelCommand, tunnelArgs); + } + tunnelProcess.stdout.on('data', data => { + console.log(data.toString()); + }); + tunnelProcess.stderr.on('data', data => { + console.error(data.toString()); + }); + tunnelProcess.on('exit', resolve); + tunnelProcess.on('error', reject); + }); + } + // Help if (args.help) { const executable = `${product.applicationName}${isWindows ? '.exe' : ''}`; @@ -75,7 +99,7 @@ export async function main(argv: string[]): Promise { case 'fish': file = 'shellIntegration.fish'; break; default: throw new Error('Error using --locate-shell-integration-path: Invalid shell type'); } - console.log(join(dirname(FileAccess.asFileUri('', require)).fsPath, 'out', 'vs', 'workbench', 'contrib', 'terminal', 'browser', 'media', file)); + console.log(join(getAppRoot(), 'out', 'vs', 'workbench', 'contrib', 'terminal', 'browser', 'media', file)); } // Extensions Management @@ -467,6 +491,10 @@ export async function main(argv: string[]): Promise { } } +function getAppRoot() { + return dirname(FileAccess.asFileUri('', require).fsPath); +} + function eventuallyExit(code: number): void { setTimeout(() => process.exit(code), 0); } diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index b72a87576f6..9d6f717b64e 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -7,6 +7,18 @@ * A list of command line arguments we support natively. */ export interface NativeParsedArgs { + // subcommands + tunnel?: { + 'cli-data-dir'?: string; + 'disable-telemetry'?: boolean; + 'telemetry-level'?: string; + user: { + login: { + 'access-token'?: string; + 'provider'?: string; + }; + }; + }; _: string[]; 'folder-uri'?: string[]; // undefined or array of 1 or more 'file-uri'?: string[]; // undefined or array of 1 or more diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 15be1bcf6f6..0e5502fb67a 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -26,20 +26,47 @@ export interface Option { deprecationMessage?: string; allowEmptyValue?: boolean; cat?: keyof typeof helpCategories; + global?: boolean; +} + +export interface Subcommand { + type: 'subcommand'; + description?: string; + deprecationMessage?: string; + options: OptionDescriptions>; } export type OptionDescriptions = { - [P in keyof T]: Option>; + [P in keyof T]: + T[P] extends boolean ? Option<'boolean'> : + T[P] extends string ? Option<'string'> : + T[P] extends string[] ? Option<'string[]'> : + Subcommand }; -type OptionTypeName = - T extends boolean ? 'boolean' : - T extends string ? 'string' : - T extends string[] ? 'string[]' : - T extends undefined ? 'undefined' : - 'unknown'; - export const OPTIONS: OptionDescriptions> = { + 'tunnel': { + type: 'subcommand', + description: 'Make the current machine accessible from vscode.dev or other machines through a secure tunnel', + options: { + 'cli-data-dir': { type: 'string', args: 'dir', description: localize('cliDataDir', "Directory where CLI metadata should be stored.") }, + 'disable-telemetry': { type: 'boolean' }, + 'telemetry-level': { type: 'string' }, + user: { + type: 'subcommand', + options: { + login: { + type: 'subcommand', + options: { + provider: { type: 'string' }, + 'access-token': { type: 'string' } + } + } + } + } + } + }, + 'diff': { type: 'boolean', cat: 'o', alias: 'd', args: ['file', 'file'], description: localize('diff', "Compare two files with each other.") }, 'merge': { type: 'boolean', cat: 'o', alias: 'm', args: ['path1', 'path2', 'base', 'result'], description: localize('merge', "Perform a three-way merge by providing paths for two modified versions of a file, the common origin of both modified versions and the output file to save merge results.") }, 'add': { type: 'boolean', cat: 'o', alias: 'a', args: 'folder', description: localize('add', "Add folder(s) to the last active window.") }, @@ -65,8 +92,8 @@ export const OPTIONS: OptionDescriptions> = { 'enable-proposed-api': { type: 'string[]', allowEmptyValue: true, cat: 'e', args: 'ext-id', description: localize('experimentalApis', "Enables proposed API features for extensions. Can receive one or more extension IDs to enable individually.") }, 'version': { type: 'boolean', cat: 't', alias: 'v', description: localize('version', "Print version.") }, - 'verbose': { type: 'boolean', cat: 't', description: localize('verbose', "Print verbose output (implies --wait).") }, - 'log': { type: 'string', cat: 't', args: 'level', description: localize('log', "Log level to use. Default is 'info'. Allowed values are 'critical', 'error', 'warn', 'info', 'debug', 'trace', 'off'.") }, + 'verbose': { type: 'boolean', cat: 't', global: true, description: localize('verbose', "Print verbose output (implies --wait).") }, + 'log': { type: 'string', cat: 't', args: 'level', global: true, description: localize('log', "Log level to use. Default is 'info'. Allowed values are 'critical', 'error', 'warn', 'info', 'debug', 'trace', 'off'.") }, 'status': { type: 'boolean', alias: 's', cat: 't', description: localize('status', "Print process usage and diagnostics information.") }, 'prof-startup': { type: 'boolean', cat: 't', description: localize('prof-startup', "Run CPU profiler during startup.") }, 'prof-append-timers': { type: 'string' }, @@ -80,7 +107,7 @@ export const OPTIONS: OptionDescriptions> = { 'inspect-extensions': { type: 'string', allowEmptyValue: true, deprecates: ['debugPluginHost'], args: 'port', cat: 't', description: localize('inspect-extensions', "Allow debugging and profiling of extensions. Check the developer tools for the connection URI.") }, 'inspect-brk-extensions': { type: 'string', allowEmptyValue: true, deprecates: ['debugBrkPluginHost'], args: 'port', cat: 't', description: localize('inspect-brk-extensions', "Allow debugging and profiling of extensions with the extension host being paused after start. Check the developer tools for the connection URI.") }, 'disable-gpu': { type: 'boolean', cat: 't', description: localize('disableGPU', "Disable GPU hardware acceleration.") }, - 'ms-enable-electron-run-as-node': { type: 'boolean' }, + 'ms-enable-electron-run-as-node': { type: 'boolean', global: true }, 'max-memory': { type: 'string', cat: 't', description: localize('maxMemory', "Max memory size for a window (in Mbytes)."), args: 'memory' }, 'telemetry': { type: 'boolean', cat: 't', description: localize('telemetry', "Shows all telemetry events which VS code collects.") }, @@ -167,9 +194,11 @@ export interface ErrorReporter { onMultipleValues(id: string, usedValue: string): void; onEmptyValue(id: string): void; onDeprecatedOption(deprecatedId: string, message: string): void; + + getSubcommandReporter?(commmand: string): ErrorReporter; } -const ignoringReporter: ErrorReporter = { +const ignoringReporter = { onUnknownOption: () => { }, onMultipleValues: () => { }, onEmptyValue: () => { }, @@ -177,27 +206,54 @@ const ignoringReporter: ErrorReporter = { }; export function parseArgs(args: string[], options: OptionDescriptions, errorReporter: ErrorReporter = ignoringReporter): T { + const firstArg = args.find(a => a.length > 0 && a[0] !== '-'); + const alias: { [key: string]: string } = {}; const string: string[] = ['_']; const boolean: string[] = []; + const globalOptions: OptionDescriptions = {}; + let command: Subcommand | undefined = undefined; for (const optionId in options) { const o = options[optionId]; - if (o.alias) { - alias[optionId] = o.alias; - } - - if (o.type === 'string' || o.type === 'string[]') { - string.push(optionId); - if (o.deprecates) { - string.push(...o.deprecates); + if (o.type === 'subcommand') { + if (optionId === firstArg) { + command = o; } - } else if (o.type === 'boolean') { - boolean.push(optionId); - if (o.deprecates) { - boolean.push(...o.deprecates); + } else { + if (o.alias) { + alias[optionId] = o.alias; + } + + if (o.type === 'string' || o.type === 'string[]') { + string.push(optionId); + if (o.deprecates) { + string.push(...o.deprecates); + } + } else if (o.type === 'boolean') { + boolean.push(optionId); + if (o.deprecates) { + boolean.push(...o.deprecates); + } + } + if (o.global) { + globalOptions[optionId] = o; } } } + if (command && firstArg) { + const options = globalOptions; + for (const optionId in command.options) { + options[optionId] = command.options[optionId]; + } + const newArgs = args.filter(a => a !== firstArg); + const reporter = errorReporter.getSubcommandReporter ? errorReporter.getSubcommandReporter(firstArg) : undefined; + const subcommandOptions = parseArgs(newArgs, options, reporter); + return { + [firstArg]: subcommandOptions + }; + } + + // remove aliases to avoid confusion const parsedArgs = minimist(args, { string, boolean, alias }); @@ -211,6 +267,9 @@ export function parseArgs(args: string[], options: OptionDescriptions, err for (const optionId in options) { const o = options[optionId]; + if (o.type === 'subcommand') { + continue; + } if (o.alias) { delete remainingArgs[o.alias]; } @@ -284,14 +343,17 @@ function formatUsage(optionId: string, option: Option) { // exported only for testing export function formatOptions(options: OptionDescriptions, columns: number): string[] { - let maxLength = 0; const usageTexts: [string, string][] = []; for (const optionId in options) { const o = options[optionId]; const usageText = formatUsage(optionId, o); - maxLength = Math.max(maxLength, usageText.length); usageTexts.push([usageText, o.description!]); } + return formatUsageTexts(usageTexts, columns); +} + +function formatUsageTexts(usageTexts: [string, string][], columns: number) { + const maxLength = usageTexts.reduce((previous, e) => Math.max(previous, e[0].length), 12); const argLength = maxLength + 2/*left padding*/ + 1/*right padding*/; if (columns - argLength < 25) { // Use a condensed version on narrow terminals @@ -343,9 +405,14 @@ export function buildHelpMessage(productName: string, executableName: string, ve help.push(''); } const optionsByCategory: { [P in keyof typeof helpCategories]?: OptionDescriptions } = {}; + const subcommands: { command: string; description: string }[] = []; for (const optionId in options) { const o = options[optionId]; - if (o.description && o.cat) { + if (o.type === 'subcommand') { + if (o.description) { + subcommands.push({ command: optionId, description: o.description }); + } + } else if (o.description && o.cat) { let optionsByCat = optionsByCategory[o.cat]; if (!optionsByCat) { optionsByCategory[o.cat] = optionsByCat = {}; @@ -364,6 +431,13 @@ export function buildHelpMessage(productName: string, executableName: string, ve help.push(''); } } + + if (subcommands.length) { + help.push(localize('subcommands', "Subcommands")); + help.push(...formatUsageTexts(subcommands.map(s => [s.command, s.description]), columns)); + help.push(''); + } + return help.join('\n'); } diff --git a/src/vs/platform/environment/node/argvHelper.ts b/src/vs/platform/environment/node/argvHelper.ts index fcf94639485..610ba3d774a 100644 --- a/src/vs/platform/environment/node/argvHelper.ts +++ b/src/vs/platform/environment/node/argvHelper.ts @@ -12,19 +12,34 @@ import { ErrorReporter, OPTIONS, parseArgs } from 'vs/platform/environment/node/ const MIN_MAX_MEMORY_SIZE_MB = 2048; function parseAndValidate(cmdLineArgs: string[], reportWarnings: boolean): NativeParsedArgs { + const onMultipleValues = (id: string, val: string) => { + console.warn(localize('multipleValues', "Option '{0}' is defined more than once. Using value '{1}'.", id, val)); + }; + const onEmptyValue = (id: string) => { + console.warn(localize('emptyValue', "Option '{0}' requires a non empty value. Ignoring the option.", id)); + }; + const onDeprecatedOption = (deprecatedOption: string, message: string) => { + console.warn(localize('deprecatedArgument', "Option '{0}' is deprecated: {1}", deprecatedOption, message)); + }; + const getSubcommandReporter = (command: string) => ({ + onUnknownOption: (id: string) => { + if (command !== 'tunnel') { + console.warn(localize('unknownSubCommandOption', "Warning: '{0}' is not in the list of known options for subcommand '{1}'", id, command)); + } + }, + onMultipleValues, + onEmptyValue, + onDeprecatedOption, + getSubcommandReporter: command !== 'tunnel' ? getSubcommandReporter : undefined + }); const errorReporter: ErrorReporter = { onUnknownOption: (id) => { console.warn(localize('unknownOption', "Warning: '{0}' is not in the list of known options, but still passed to Electron/Chromium.", id)); }, - onMultipleValues: (id, val) => { - console.warn(localize('multipleValues', "Option '{0}' is defined more than once. Using value '{1}'.", id, val)); - }, - onEmptyValue: (id) => { - console.warn(localize('emptyValue', "Option '{0}' requires a non empty value. Ignoring the option.", id)); - }, - onDeprecatedOption: (deprecatedOption: string, message: string) => { - console.warn(localize('deprecatedArgument', "Option '{0}' is deprecated: {1}", deprecatedOption, message)); - } + onMultipleValues, + onEmptyValue, + onDeprecatedOption, + getSubcommandReporter }; const args = parseArgs(cmdLineArgs, OPTIONS, reportWarnings ? errorReporter : undefined); @@ -68,7 +83,12 @@ export function parseMainProcessArgv(processArgv: string[]): NativeParsedArgs { * Use this to parse raw code CLI process.argv such as: `Electron cli.js . --verbose --wait` */ export function parseCLIProcessArgv(processArgv: string[]): NativeParsedArgs { - const [, , ...args] = processArgv; // remove the first non-option argument: it's always the app location + let [, , ...args] = processArgv; // remove the first non-option argument: it's always the app location + + // If dev, remove the first non-option argument: it's the app location + if (process.env['VSCODE_DEV']) { + args = stripAppPath(args) || []; + } return parseAndValidate(args, true); } diff --git a/src/vs/platform/environment/test/node/argv.test.ts b/src/vs/platform/environment/test/node/argv.test.ts index 7d1439125d2..fc182805893 100644 --- a/src/vs/platform/environment/test/node/argv.test.ts +++ b/src/vs/platform/environment/test/node/argv.test.ts @@ -4,23 +4,28 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { formatOptions, Option } from 'vs/platform/environment/node/argv'; +import { formatOptions, Option, OptionDescriptions, Subcommand, parseArgs, ErrorReporter } from 'vs/platform/environment/node/argv'; import { addArg } from 'vs/platform/environment/node/argvHelper'; -suite('formatOptions', () => { +function o(description: string, type: 'boolean' | 'string' | 'string[]' = 'string'): Option { + return { + description, type + }; +} +function c(description: string, options: OptionDescriptions): Subcommand { + return { + description, type: 'subcommand', options + }; +} - function o(description: string): Option { - return { - description, type: 'string' - }; - } +suite('formatOptions', () => { test('Text should display small columns correctly', () => { assert.deepStrictEqual( formatOptions({ 'add': o('bar') }, 80), - [' --add bar'] + [' --add bar'] ); assert.deepStrictEqual( formatOptions({ @@ -29,9 +34,9 @@ suite('formatOptions', () => { 'trace': o('b') }, 80), [ - ' --add bar', - ' --wait ba', - ' --trace b' + ' --add bar', + ' --wait ba', + ' --trace b' ]); }); @@ -41,8 +46,8 @@ suite('formatOptions', () => { 'add': o(('bar ').repeat(9)) }, 40), [ - ' --add bar bar bar bar bar bar bar bar', - ' bar' + ' --add bar bar bar bar bar bar', + ' bar bar bar' ]); }); @@ -65,4 +70,67 @@ suite('formatOptions', () => { assert.deepStrictEqual(addArg(['--wait', '--', '--foo'], 'bar'), ['--wait', 'bar', '--', '--foo']); assert.deepStrictEqual(addArg(['--', '--foo'], 'bar'), ['bar', '--', '--foo']); }); + + test('subcommands', () => { + assert.deepStrictEqual( + formatOptions({ + 'testcmd': c('A test command', { add: o('A test command option') }) + }, 30), + [ + ' --testcmd', + ' A test command' + ]); + }); +}); + +suite('parseArgs', () => { + function newErrorReporter(result: string[] = [], command = ''): ErrorReporter & { result: string[] } { + const commandPrefix = command ? command + '-' : ''; + return { + onDeprecatedOption: (deprecatedId) => result.push(`${commandPrefix}onDeprecatedOption ${deprecatedId}`), + onUnknownOption: (id) => result.push(`${commandPrefix}onUnknownOption ${id}`), + onEmptyValue: (id) => result.push(`${commandPrefix}onEmptyValue ${id}`), + onMultipleValues: (id, usedValue) => result.push(`${commandPrefix}onMultipleValues ${id} ${usedValue}`), + getSubcommandReporter: (c) => newErrorReporter(result, commandPrefix + c), + result + }; + } + + function assertParse(options: OptionDescriptions, input: string[], expected: T, expectedErrors: string[]) { + const errorReporter = newErrorReporter(); + assert.deepStrictEqual(parseArgs(input, options, errorReporter), expected); + assert.deepStrictEqual(errorReporter.result, expectedErrors); + } + + test('subcommands', () => { + const options1 = { + 'testcmd': c('A test command', { + testArg: o('A test command option') + }) + }; + assertParse( + options1, + ['testcmd', '--testArg=foo'], + { testcmd: { testArg: 'foo', '_': [] } }, + [] + ); + assertParse( + options1, + ['testcmd', '--testArg=foo', '--testX'], + { testcmd: { testArg: 'foo', '_': [] } }, + ['testcmd-onUnknownOption testX'] + ); + const options2 = { + 'testcmd': c('A test command', { + testArg: o('A test command option') + }), + testX: { global: true } + }; + assertParse( + options2, + ['testcmd', '--testArg=foo', '--testX'], + { testcmd: { testArg: 'foo', testX: true, '_': [] } }, + [] + ); + }); }); diff --git a/src/vs/platform/lifecycle/electron-browser/sharedProcessLifecycleService.ts b/src/vs/platform/lifecycle/electron-browser/sharedProcessLifecycleService.ts new file mode 100644 index 00000000000..ebcc62825c1 --- /dev/null +++ b/src/vs/platform/lifecycle/electron-browser/sharedProcessLifecycleService.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Promises } from 'vs/base/common/async'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { ILogService } from 'vs/platform/log/common/log'; + +export const ISharedProcessLifecycleService = createDecorator('lifecycleSharedProcessService'); + +export interface ISharedProcessLifecycleService { + readonly _serviceBrand: undefined; + + /** + * An event that fires after after no window has vetoed the shutdown sequence. At + * this point listeners are ensured that the application will quit without veto. + */ + readonly onWillShutdown: Event; +} + +export interface ShutdownEvent { + + /** + * Allows to join the shutdown. The promise can be a long running operation but it + * will block the application from closing. + */ + join(promise: Promise): void; +} + +export class SharedProcessLifecycleService extends Disposable implements ISharedProcessLifecycleService { + + declare readonly _serviceBrand: undefined; + + private pendingWillShutdownPromise: Promise | undefined = undefined; + + private readonly _onWillShutdown = this._register(new Emitter()); + readonly onWillShutdown = this._onWillShutdown.event; + + constructor( + @ILogService private readonly logService: ILogService + ) { + super(); + } + + public fireOnWillShutdown(): Promise { + if (this.pendingWillShutdownPromise) { + return this.pendingWillShutdownPromise; // shutdown is already running + } + + this.logService.trace('Lifecycle#onWillShutdown.fire()'); + + const joiners: Promise[] = []; + + this._onWillShutdown.fire({ + join(promise) { + joiners.push(promise); + } + }); + + this.pendingWillShutdownPromise = (async () => { + + // Settle all shutdown event joiners + try { + await Promises.settled(joiners); + } catch (error) { + this.logService.error(error); + } + })(); + + return this.pendingWillShutdownPromise; + } + +} diff --git a/src/vs/platform/remoteTunnel/common/remoteTunnel.ts b/src/vs/platform/remoteTunnel/common/remoteTunnel.ts new file mode 100644 index 00000000000..f7c9e2a3609 --- /dev/null +++ b/src/vs/platform/remoteTunnel/common/remoteTunnel.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { Event } from 'vs/base/common/event'; + + +export interface IRemoteTunnelAccount { + readonly authenticationProviderId: string; + readonly token: string; +} + +export const IRemoteTunnelService = createDecorator('IRemoteTunnelService'); +export interface IRemoteTunnelService { + readonly _serviceBrand: undefined; + + readonly onDidTokenFailed: Event; + readonly onDidChangeTunnelStatus: Event; + + getAccount(): Promise; + readonly onDidChangeAccount: Event; + updateAccount(account: IRemoteTunnelAccount | undefined): Promise; + +} + +export const enum TunnelStatus { + Uninitialized = 'uninitialized', + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', +} diff --git a/src/vs/platform/remoteTunnel/electron-browser/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/electron-browser/remoteTunnelService.ts new file mode 100644 index 00000000000..5ee3758c59d --- /dev/null +++ b/src/vs/platform/remoteTunnel/electron-browser/remoteTunnelService.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRemoteTunnelAccount, IRemoteTunnelService, TunnelStatus } from 'vs/platform/remoteTunnel/common/remoteTunnel'; +import { Emitter } from 'vs/base/common/event'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { INativeEnvironmentService } from 'vs/platform/environment/common/environment'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { ILogger, ILoggerService } from 'vs/platform/log/common/log'; +import { URI } from 'vs/base/common/uri'; +import { dirname, join } from 'vs/base/common/path'; +import { ChildProcess, spawn } from 'child_process'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { isWindows } from 'vs/base/common/platform'; +import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; +import { ISharedProcessLifecycleService } from 'vs/platform/lifecycle/electron-browser/sharedProcessLifecycleService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; + + +type RemoteTunnelEnablementClassification = { + owner: 'aeschli'; + comment: 'Reporting when Remote Tunnel access is turned on or off'; + enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; +}; + +type RemoteTunnelEnablementEvent = { + enabled: boolean; +}; + +/** + * This service runs on the shared service. It is running the `code-tunnel` command + * to make the current machine available for remote access. + */ +export class RemoteTunnelService extends Disposable implements IRemoteTunnelService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidTokenFailedEmitter = new Emitter(); + public readonly onDidTokenFailed = this._onDidTokenFailedEmitter.event; + + private readonly _onDidChangeTunnelStatusEmitter = new Emitter(); + public readonly onDidChangeTunnelStatus = this._onDidChangeTunnelStatusEmitter.event; + + private readonly _onDidChangeAccountEmitter = new Emitter(); + public readonly onDidChangeAccount = this._onDidChangeAccountEmitter.event; + + private readonly _logger: ILogger; + + private _account: IRemoteTunnelAccount | undefined; + private _tunnelProcess: CancelablePromise | undefined; + + private _tunnelStatus: TunnelStatus = TunnelStatus.Disconnected; + + constructor( + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IProductService private readonly productService: IProductService, + @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, + @ILoggerService loggerService: ILoggerService, + @ISharedProcessLifecycleService sharedProcessLifecycleService: ISharedProcessLifecycleService, + @IConfigurationService configurationService: IConfigurationService + ) { + super(); + const logFileUri = URI.file(join(dirname(environmentService.logsPath), 'remoteTunnel.log')); + this._logger = this._register(loggerService.createLogger(logFileUri, { name: 'remoteTunnel' })); + + this._register(sharedProcessLifecycleService.onWillShutdown(e => { + if (this._tunnelProcess) { + this._tunnelProcess.cancel(); + this._tunnelProcess = undefined; + } + this.dispose(); + })); + } + + async getAccount(): Promise { + return this._account; + } + + async updateAccount(account: IRemoteTunnelAccount | undefined): Promise { + if (account && this._account ? account.token !== this._account.token || account.authenticationProviderId !== this._account.authenticationProviderId : account !== this._account) { + this._account = account; + this._onDidChangeAccountEmitter.fire(account); + + this._logger.info(`Account updated: ${account ? account.authenticationProviderId : 'undefined'}`); + + this.telemetryService.publicLog2('remoteTunnel.enablement', { enabled: !!account }); + + try { + this.updateTunnelProcess(); + } catch (e) { + this._logger.error(e); + } + } + + } + + private async updateTunnelProcess(): Promise { + if (this._tunnelProcess) { + this._tunnelProcess.cancel(); + this._tunnelProcess = undefined; + } + if (!this._account) { + this.setTunnelStatus(TunnelStatus.Disconnected); + return; + } + this.setTunnelStatus(TunnelStatus.Connecting); + const loginProcess = this.runCodeTunneCommand('login', ['user', 'login', '--provider', this._account.authenticationProviderId, '--access-token', this._account.token]); + this._tunnelProcess = loginProcess; + try { + await loginProcess; + } catch (e) { + this._logger.error(e); + this._tunnelProcess = undefined; + this._onDidTokenFailedEmitter.fire(true); + this.setTunnelStatus(TunnelStatus.Disconnected); + } + if (this._tunnelProcess === loginProcess) { + const serveCommand = this.runCodeTunneCommand('tunnel', ['--random-name'], (message: string) => { + }); + this._tunnelProcess = serveCommand; + serveCommand.finally(() => { + if (serveCommand === this._tunnelProcess) { + // process exited unexpectedly + this._logger.info(`tunnel process terminated`); + this._tunnelProcess = undefined; + this._account = undefined; + + this.setTunnelStatus(TunnelStatus.Disconnected); + } + }); + + } + } + + private setTunnelStatus(tunnelStatus: TunnelStatus) { + if (tunnelStatus !== this._tunnelStatus) { + this._tunnelStatus = tunnelStatus; + this._onDidChangeTunnelStatusEmitter.fire(tunnelStatus); + } + } + + + private runCodeTunneCommand(logLabel: string, commandArgs: string[], onOutput: (message: string, isError: boolean) => void = () => { }): CancelablePromise { + return createCancelablePromise(token => { + return new Promise((resolve, reject) => { + if (token.isCancellationRequested) { + resolve(); + } + let tunnelProcess: ChildProcess | undefined; + token.onCancellationRequested(() => { + if (tunnelProcess) { + this._logger.info(`${logLabel} terminating (${tunnelProcess.pid})`); + tunnelProcess.kill(); + } + }); + if (process.env['VSCODE_DEV']) { + this._logger.info(`${logLabel} Spawning: cargo run --bin code-tunnel -- ${commandArgs.join(' ')}`); + tunnelProcess = spawn('cargo', ['run', '--bin', 'code-tunnel', '--', ...commandArgs], { cwd: join(this.environmentService.appRoot, 'cli') }); + } else { + const tunnelCommand = join(dirname(process.execPath), 'bin', `${this.productService.tunnelApplicationName}${isWindows ? '.exe' : ''}`); + this._logger.info(`${logLabel} Spawning: ${tunnelCommand} ${commandArgs.join(' ')}`); + tunnelProcess = spawn(tunnelCommand, commandArgs); + } + + tunnelProcess.stdout!.on('data', data => { + if (tunnelProcess) { + const message = data.toString(); + onOutput(message, false); + this._logger.info(`${logLabel} stdout (${tunnelProcess.pid}): + ${message}`); + } + }); + tunnelProcess.stderr!.on('data', data => { + if (tunnelProcess) { + const message = data.toString(); + onOutput(message, true); + this._logger.info(`${logLabel} stderr (${tunnelProcess.pid}): + ${message}`); + } + }); + tunnelProcess.on('exit', e => { + if (tunnelProcess) { + this._logger.info(`${logLabel} exit (${tunnelProcess.pid}): + ${e}`); + tunnelProcess = undefined; + resolve(); + } + }); + tunnelProcess.on('error', e => { + if (tunnelProcess) { + this._logger.info(`${logLabel} error (${tunnelProcess.pid}): + ${e}`); + tunnelProcess = undefined; + reject(); + } + }); + }); + }); + } + +} diff --git a/src/vs/platform/remoteTunnel/electron-sandbox/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/electron-sandbox/remoteTunnelService.ts new file mode 100644 index 00000000000..4d626c9de47 --- /dev/null +++ b/src/vs/platform/remoteTunnel/electron-sandbox/remoteTunnelService.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSharedProcessRemoteService } from 'vs/platform/ipc/electron-sandbox/services'; +import { IRemoteTunnelService } from 'vs/platform/remoteTunnel/common/remoteTunnel'; + +registerSharedProcessRemoteService(IRemoteTunnelService, 'remoteTunnel'); diff --git a/src/vs/server/node/server.cli.ts b/src/vs/server/node/server.cli.ts index d7b1c9b307e..138f6dd8130 100644 --- a/src/vs/server/node/server.cli.ts +++ b/src/vs/server/node/server.cli.ts @@ -94,7 +94,7 @@ export async function main(desc: ProductDescription, args: string[]): Promise = { ...OPTIONS }; + const options: OptionDescriptions> = { ...OPTIONS, gitCredential: { type: 'string' }, openExternal: { type: 'boolean' } }; const isSupported = cliCommand ? isSupportedForCmd : isSupportedForPipe; for (const optionId in OPTIONS) { const optId = optionId; diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index 8c5a3c89cb8..646e3ae2af1 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -10,7 +10,7 @@ import { OPTIONS, OptionDescriptions } from 'vs/platform/environment/node/argv'; import { refineServiceDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IEnvironmentService, INativeEnvironmentService } from 'vs/platform/environment/common/environment'; -export const serverOptions: OptionDescriptions = { +export const serverOptions: OptionDescriptions> = { /* ----- server setup ----- */ diff --git a/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts b/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts new file mode 100644 index 00000000000..5572254d5e2 --- /dev/null +++ b/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts @@ -0,0 +1,382 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { IRemoteTunnelService, TunnelStatus } from 'vs/platform/remoteTunnel/common/remoteTunnel'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; +import { localize } from 'vs/nls'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { ILocalizedString } from 'vs/platform/action/common/action'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { ILogger, ILoggerService, ILogService } from 'vs/platform/log/common/log'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { URI } from 'vs/base/common/uri'; +import { join } from 'vs/base/common/path'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { IStringDictionary } from 'vs/base/common/collections'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; +import { registerLogChannel } from 'vs/workbench/services/output/common/output'; +import { IFileService } from 'vs/platform/files/common/files'; + +export const REMOTE_TUNNEL_CATEGORY: ILocalizedString = { + original: 'Remote Tunnel', + value: localize('remoteTunnel.category', 'Remote Tunnel') +}; + +export const REMOTE_TUNNEL_SIGNED_IN_KEY = 'remoteTunnelSignedIn'; +export const REMOTE_TUNNEL_SIGNED_IN = new RawContextKey(REMOTE_TUNNEL_SIGNED_IN_KEY, false); + +const CACHED_SESSION_STORAGE_KEY = 'remoteTunnelAccountPreference'; + +type ExistingSession = IQuickPickItem & { session: AuthenticationSession & { providerId: string } }; +type IAuthenticationProvider = { id: string; scopes: string[] }; +type AuthenticationProviderOption = IQuickPickItem & { provider: IAuthenticationProvider }; + +export class RemoteTunnelWorkbenchContribution extends Disposable implements IWorkbenchContribution { + + private readonly signedInContext: IContextKey; + + private readonly serverConfiguration: { authenticationProviders: IStringDictionary<{ scopes: string[] }> }; + + private initialized = false; + #authenticationInfo: { sessionId: string; token: string; providerId: string } | undefined; + + private readonly logger: ILogger; + + constructor( + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IDialogService private readonly dialogService: IDialogService, + @IExtensionService private readonly extensionService: IExtensionService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IProductService productService: IProductService, + @IStorageService private readonly storageService: IStorageService, + @ILoggerService loggerService: ILoggerService, + @ILogService logService: ILogService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IEnvironmentService environmentService: IEnvironmentService, + @IFileService fileService: IFileService, + @IRemoteTunnelService private remoteTunnelService: IRemoteTunnelService + ) { + super(); + + const logPathURI = URI.file(join(environmentService.logsPath, 'remoteTunnel.log')); + + this.logger = this._register(loggerService.createLogger(logPathURI, { name: 'remoteTunnel' })); + + const promise = registerLogChannel('remoteTunnel', localize('remoteTunnel.outputTitle', "Remote Tunnel"), logPathURI, fileService, logService); + this._register(toDisposable(() => promise.cancel())); + + this.signedInContext = REMOTE_TUNNEL_SIGNED_IN.bindTo(this.contextKeyService); + + const serverConfiguration = productService.tunnelApplicationConfig; + if (!serverConfiguration || !productService.tunnelApplicationName) { + this.logger.error('Missing \'tunnelApplicationConfig\' or \'tunnelApplicationName\' in product.json. Remote tunneling is not available.'); + this.serverConfiguration = { authenticationProviders: {} }; + return; + } + this.serverConfiguration = serverConfiguration; + + this._register(this.remoteTunnelService.onDidTokenFailed(() => { + this.logger.info('Clearing authentication preference because of successive token failures.'); + this.clearAuthenticationPreference(); + })); + this._register(this.remoteTunnelService.onDidChangeTunnelStatus(status => { + if (status === TunnelStatus.Disconnected) { + this.logger.info('Clearing authentication preference because of tunnel disconnected.'); + this.clearAuthenticationPreference(); + } + })); + + // If the user signs out of the current session, reset our cached auth state in memory and on disk + this._register(this.authenticationService.onDidChangeSessions((e) => this.onDidChangeSessions(e.event))); + + // If another window changes the preferred session storage, reset our cached auth state in memory + this._register(this.storageService.onDidChangeValue(e => this.onDidChangeStorage(e))); + + this.registerTurnOnAction(); + this.registerTurnOffAction(); + + this.signedInContext.set(this.existingSessionId !== undefined); + + if (this.existingSessionId) { + this.initialize(true); + } + + } + + private get existingSessionId() { + return this.storageService.get(CACHED_SESSION_STORAGE_KEY, StorageScope.APPLICATION); + } + + private set existingSessionId(sessionId: string | undefined) { + this.logger.trace(`Saving authentication preference for ID ${sessionId}.`); + if (sessionId === undefined) { + this.storageService.remove(CACHED_SESSION_STORAGE_KEY, StorageScope.APPLICATION); + } else { + this.storageService.store(CACHED_SESSION_STORAGE_KEY, sessionId, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } + + public async initialize(silent: boolean = false) { + if (this.initialized) { + return true; + } + this.initialized = await this.doInitialize(silent); + this.signedInContext.set(this.initialized); + return this.initialized; + } + + /** + * + * Ensures that the store client is initialized, + * meaning that authentication is configured and it + * can be used to communicate with the remote storage service + */ + private async doInitialize(silent: boolean): Promise { + // Wait for authentication extensions to be registered + await this.extensionService.whenInstalledExtensionsRegistered(); + + // If we already have an existing auth session in memory, use that + if (this.#authenticationInfo !== undefined) { + return true; + } + + const authenticationSession = await this.getAuthenticationSession(silent); + if (authenticationSession !== undefined) { + this.#authenticationInfo = authenticationSession; + this.remoteTunnelService.updateAccount({ token: authenticationSession.token, authenticationProviderId: authenticationSession.providerId }); + } + + return authenticationSession !== undefined; + } + + private async getAuthenticationSession(silent: boolean) { + // If the user signed in previously and the session is still available, reuse that without prompting the user again + if (this.existingSessionId) { + this.logger.info(`Searching for existing authentication session with ID ${this.existingSessionId}`); + const existingSession = await this.getExistingSession(); + if (existingSession) { + this.logger.info(`Found existing authentication session with ID ${existingSession.session.id}`); + return { sessionId: existingSession.session.id, token: existingSession.session.idToken ?? existingSession.session.accessToken, providerId: existingSession.session.providerId }; + } else { + //this._didSignOut.fire(); + } + } + + // If we aren't supposed to prompt the user because + // we're in a silent flow, just return here + if (silent) { + return; + } + + // Ask the user to pick a preferred account + const authenticationSession = await this.getAccountPreference(); + if (authenticationSession !== undefined) { + this.existingSessionId = authenticationSession.id; + return { sessionId: authenticationSession.id, token: authenticationSession.idToken ?? authenticationSession.accessToken, providerId: authenticationSession.providerId }; + } + + return undefined; + } + + private async getAccountPreference(): Promise { + const quickpick = this.quickInputService.createQuickPick(); + quickpick.title = localize('accountPreference.title', 'Enable remote access by signing up to remote tunnels.'); + quickpick.ok = false; + quickpick.placeholder = localize('accountPreference.placeholder', "Select an account to sign in"); + quickpick.ignoreFocusOut = true; + quickpick.items = await this.createQuickpickItems(); + + return new Promise((resolve, reject) => { + quickpick.onDidHide((e) => { + resolve(undefined); + quickpick.dispose(); + }); + + quickpick.onDidAccept(async (e) => { + const selection = quickpick.selectedItems[0]; + const session = 'provider' in selection ? { ...await this.authenticationService.createSession(selection.provider.id, selection.provider.scopes), providerId: selection.provider.id } : ('session' in selection ? selection.session : undefined); + resolve(session); + quickpick.hide(); + }); + + quickpick.show(); + }); + } + + private async createQuickpickItems(): Promise<(ExistingSession | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[]> { + const options: (ExistingSession | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[] = []; + + options.push({ type: 'separator', label: localize('signed in', "Signed In") }); + + const sessions = await this.getAllSessions(); + options.push(...sessions); + + options.push({ type: 'separator', label: localize('others', "Others") }); + + for (const authenticationProvider of (await this.getAuthenticationProviders())) { + const signedInForProvider = sessions.some(account => account.session.providerId === authenticationProvider.id); + if (!signedInForProvider || this.authenticationService.supportsMultipleAccounts(authenticationProvider.id)) { + const providerName = this.authenticationService.getLabel(authenticationProvider.id); + options.push({ label: localize('sign in using account', "Sign in with {0}", providerName), provider: authenticationProvider }); + } + } + + return options; + } + + + private async getExistingSession() { + const accounts = await this.getAllSessions(); + return accounts.find((account) => account.session.id === this.existingSessionId); + } + + private async onDidChangeStorage(e: IStorageValueChangeEvent): Promise { + if (e.key === CACHED_SESSION_STORAGE_KEY && e.scope === StorageScope.APPLICATION) { + const newSessionId = this.existingSessionId; + const previousSessionId = this.#authenticationInfo?.sessionId; + + if (previousSessionId !== newSessionId) { + this.logger.trace(`Resetting authentication state because authentication session ID preference changed from ${previousSessionId} to ${newSessionId}.`); + this.#authenticationInfo = undefined; + this.initialized = false; + } + } + } + + private clearAuthenticationPreference(): void { + this.#authenticationInfo = undefined; + this.initialized = false; + this.existingSessionId = undefined; + this.signedInContext.set(false); + } + + private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void { + if (this.#authenticationInfo?.sessionId && e.removed.find(session => session.id === this.#authenticationInfo?.sessionId)) { + this.clearAuthenticationPreference(); + } + } + + /** + * + * Returns all authentication sessions available from {@link getAuthenticationProviders}. + */ + private async getAllSessions() { + const authenticationProviders = await this.getAuthenticationProviders(); + const accounts = new Map(); + let currentSession: ExistingSession | undefined; + + for (const provider of authenticationProviders) { + const sessions = await this.authenticationService.getSessions(provider.id, provider.scopes); + + for (const session of sessions) { + const item = { + label: session.account.label, + description: this.authenticationService.getLabel(provider.id), + session: { ...session, providerId: provider.id } + }; + accounts.set(item.session.account.id, item); + if (this.existingSessionId === session.id) { + currentSession = item; + } + } + } + + if (currentSession !== undefined) { + accounts.set(currentSession.session.account.id, currentSession); + } + + return [...accounts.values()]; + } + + /** + * Returns all authentication providers which can be used to authenticate + * to the remote storage service, based on product.json configuration + * and registered authentication providers. + */ + private async getAuthenticationProviders() { + // Get the list of authentication providers configured in product.json + const authenticationProviders = this.serverConfiguration.authenticationProviders; + const configuredAuthenticationProviders = Object.keys(authenticationProviders).reduce((result, id) => { + result.push({ id, scopes: authenticationProviders[id].scopes }); + return result; + }, []); + + // Filter out anything that isn't currently available through the authenticationService + const availableAuthenticationProviders = this.authenticationService.declaredProviders; + + return configuredAuthenticationProviders.filter(({ id }) => availableAuthenticationProviders.some(provider => provider.id === id)); + } + + private registerTurnOnAction() { + const that = this; + this._register(registerAction2(class ShareMachineAction extends Action2 { + constructor() { + super({ + id: 'workbench.remoteTunnel.actions.turnOn', + title: localize('remoteTunnel.turnOn', 'Turn on Remote Tunnel Access...'), + category: REMOTE_TUNNEL_CATEGORY, + precondition: ContextKeyExpr.equals(REMOTE_TUNNEL_SIGNED_IN_KEY, false), + menu: [{ + id: MenuId.CommandPalette, + }, + { + id: MenuId.AccountsContext, + group: '2_remoteTunnel', + when: ContextKeyExpr.equals(REMOTE_TUNNEL_SIGNED_IN_KEY, false), + }] + }); + } + + async run() { + return await that.initialize(false); + } + })); + } + + private registerTurnOffAction() { + const that = this; + this._register(registerAction2(class ResetShareMachineAuthenticationAction extends Action2 { + constructor() { + super({ + id: 'workbench.remoteTunnel.actions.turnOff', + title: localize('remoteTunnel.turnOff', 'Turn off Remote Tunnel Access...'), + category: REMOTE_TUNNEL_CATEGORY, + precondition: ContextKeyExpr.equals(REMOTE_TUNNEL_SIGNED_IN_KEY, true), + menu: [{ + id: MenuId.CommandPalette, + }, + { + id: MenuId.AccountsContext, + group: '2_remoteTunnel', + when: ContextKeyExpr.equals(REMOTE_TUNNEL_SIGNED_IN_KEY, true), + }] + }); + } + + async run() { + const result = await that.dialogService.confirm({ + type: 'info', + message: localize('remoteTunnel.turnOff.confirm', 'Do you want to turn off Remote Tunnel Access?'), + primaryButton: localize('remoteTunnel.turnOff.yesButton', 'Yes'), + }); + if (result.confirmed) { + that.clearAuthenticationPreference(); + that.remoteTunnelService.updateAccount(undefined); + } + } + })); + } + +} + +const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); +workbenchRegistry.registerWorkbenchContribution(RemoteTunnelWorkbenchContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 628a581d5a2..ca74c51637b 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -79,6 +79,7 @@ import 'vs/workbench/services/tunnel/electron-sandbox/tunnelService'; import 'vs/platform/diagnostics/electron-sandbox/diagnosticsService'; import 'vs/platform/profiling/electron-sandbox/profilingService'; import 'vs/platform/telemetry/electron-sandbox/customEndpointTelemetryService'; +import 'vs/platform/remoteTunnel/electron-sandbox/remoteTunnelService'; import 'vs/workbench/services/files/electron-sandbox/elevatedFileService'; import 'vs/workbench/services/search/electron-sandbox/searchService'; import 'vs/workbench/services/workingCopy/electron-sandbox/workingCopyHistoryService'; @@ -160,4 +161,7 @@ import 'vs/workbench/contrib/localHistory/electron-sandbox/localHistory.contribu // Merge Editor import 'vs/workbench/contrib/mergeEditor/electron-sandbox/mergeEditor.contribution'; +// Remote Tunnel +import 'vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution'; + //#endregion