diff --git a/extensions/open-remote-wsl/README.md b/extensions/open-remote-wsl/README.md new file mode 100644 index 00000000..40b6c3a9 --- /dev/null +++ b/extensions/open-remote-wsl/README.md @@ -0,0 +1,3 @@ +# Remote - WSL Support + +Inherited for Void from [Open Remote - WSL](https://github.com/jeanp413/open-remote-wsl). diff --git a/extensions/open-remote-wsl/extension-browser.webpack.config.js b/extensions/open-remote-wsl/extension-browser.webpack.config.js new file mode 100644 index 00000000..7fcc53a7 --- /dev/null +++ b/extensions/open-remote-wsl/extension-browser.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withBrowserDefaults = require('../shared.webpack.config').browser; + +module.exports = withBrowserDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts' + } +}); diff --git a/extensions/open-remote-wsl/extension.webpack.config.js b/extensions/open-remote-wsl/extension.webpack.config.js new file mode 100644 index 00000000..de88398e --- /dev/null +++ b/extensions/open-remote-wsl/extension.webpack.config.js @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); + +module.exports = withDefaults({ + context: __dirname, + resolve: { + mainFields: ['module', 'main'] + }, + entry: { + extension: './src/extension.ts', + } +}); diff --git a/extensions/open-remote-wsl/package-lock.json b/extensions/open-remote-wsl/package-lock.json new file mode 100644 index 00000000..090fe040 --- /dev/null +++ b/extensions/open-remote-wsl/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "open-remote-wsl", + "version": "0.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-remote-wsl", + "version": "0.0.4", + "engines": { + "vscode": "^1.70.2" + } + } + } +} diff --git a/extensions/open-remote-wsl/package.json b/extensions/open-remote-wsl/package.json new file mode 100644 index 00000000..baf507b6 --- /dev/null +++ b/extensions/open-remote-wsl/package.json @@ -0,0 +1,281 @@ +{ + "name": "open-remote-wsl", + "displayName": "Remote - WSL", + "description": "Open any folder in the Windows Subsystem for Linux (WSL).", + "version": "0.0.4", + "icon": "resources/icon.png", + "engines": { + "vscode": "^1.70.2" + }, + "extensionKind": [ + "ui" + ], + "enabledApiProposals": [ + "resolvers", + "contribViewsRemote" + ], + "keywords": [ + "remote development", + "remote", + "wsl" + ], + "api": "none", + "activationEvents": [ + "onCommand:openremotewsl.connect", + "onCommand:openremotewsl.connectInNewWindow", + "onCommand:openremotewsl.connectUsingDistro", + "onCommand:openremotewsl.connectUsingDistroInNewWindow", + "onCommand:openremotewsl.showLog", + "onResolveRemoteAuthority:wsl", + "onView:wslTargets" + ], + "main": "./out/extension.js", + "contributes": { + "configuration": { + "title": "WSL", + "properties": { + "remote.WSL.serverDownloadUrlTemplate": { + "type": "string", + "description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number", + "scope": "application", + "default": "https://github.com/codestoryai/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz" + } + } + }, + "views": { + "remote": [ + { + "id": "wslTargets", + "name": "WSL Targets", + "group": "targets@1", + "when": "(isWindows && !isWeb)", + "remoteName": "wsl" + } + ] + }, + "commands": [ + { + "command": "openremotewsl.connect", + "title": "Connect to WSL", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.connectInNewWindow", + "title": "Connect to WSL in New Window", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.connectUsingDistro", + "title": "Connect to WSL using Distro...", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.connectUsingDistroInNewWindow", + "title": "Connect to WSL using Distro in New Window...", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.showLog", + "title": "Show Log", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "title": "Connect in New Window", + "icon": "$(empty-window)" + }, + { + "command": "openremotewsl.explorer.emptyWindowInCurrentWindow", + "title": "Connect in Current Window" + }, + { + "command": "openremotewsl.explorer.reopenFolderInCurrentWindow", + "title": "Open in Current Window" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "title": "Open in New Window", + "icon": "$(folder-opened)" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "title": "Remove From Recent List", + "icon": "$(x)" + }, + { + "command": "openremotewsl.explorer.refresh", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "openremotewsl.explorer.addDistro", + "title": "Add a Distro", + "icon": "$(plus)" + }, + { + "command": "openremotewsl.explorer.setDefaultDistro", + "title": "Set as Default Distro" + }, + { + "command": "openremotewsl.explorer.deleteDistro", + "title": "Delete Distro" + } + ], + "resourceLabelFormatters": [ + { + "scheme": "vscode-remote", + "authority": "wsl+*", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": true, + "workspaceSuffix": "WSL" + } + } + ], + "menus": { + "statusBar/remoteIndicator": [ + { + "command": "openremotewsl.connect", + "when": "(isWindows && !isWeb)", + "group": "remote_20_wsl_1general@1" + }, + { + "command": "openremotewsl.connectUsingDistro", + "when": "(isWindows && !isWeb)", + "group": "remote_20_wsl_1general@2" + }, + { + "command": "openremotewsl.showLog", + "when": "remoteName =~ /^wsl$/", + "group": "remote_20_wsl_1general@4" + } + ], + "commandPalette": [ + { + "command": "openremotewsl.connect", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.connectInNewWindow", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.connectUsingDistro", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.connectUsingDistroInNewWindow", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.explorer.refresh", + "when": "false" + }, + { + "command": "openremotewsl.explorer.addDistro", + "when": "false" + }, + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.emptyWindowInCurrentWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.reopenFolderInCurrentWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "when": "false" + }, + { + "command": "openremotewsl.explorer.setDefaultDistro", + "when": "false" + }, + { + "command": "openremotewsl.explorer.deleteDistro", + "when": "false" + } + ], + "view/title": [ + { + "command": "openremotewsl.explorer.addDistro", + "when": "view == wslTargets", + "group": "navigation" + }, + { + "command": "openremotewsl.explorer.refresh", + "when": "view == wslTargets", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "inline@1" + }, + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "navigation@2" + }, + { + "command": "openremotewsl.explorer.emptyWindowInCurrentWindow", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "navigation@1" + }, + { + "command": "openremotewsl.explorer.setDefaultDistro", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "management@1" + }, + { + "command": "openremotewsl.explorer.deleteDistro", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "management@2" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotewsl.explorer.folder", + "group": "inline@1" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotewsl.explorer.folder", + "group": "navigation@2" + }, + { + "command": "openremotewsl.explorer.reopenFolderInCurrentWindow", + "when": "viewItem == openremotewsl.explorer.folder", + "group": "navigation@1" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotewsl.explorer.folder/", + "group": "navigation@3" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotewsl.explorer.folder/", + "group": "inline@2" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "gulp compile-extension:open-remote-wsl", + "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", + "watch": "gulp watch-extension:open-remote-wsl", + "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" + } +} diff --git a/extensions/open-remote-wsl/src/authResolver.ts b/extensions/open-remote-wsl/src/authResolver.ts new file mode 100644 index 00000000..b73ea4be --- /dev/null +++ b/extensions/open-remote-wsl/src/authResolver.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { installCodeServer, ServerInstallError } from './serverSetup'; +import { WSLManager } from './wsl/wslManager'; + +export const REMOTE_WSL_AUTHORITY = 'wsl'; + +export function getRemoteAuthority(distro: string) { + return `${REMOTE_WSL_AUTHORITY}+${distro}`; +} + +class Tunnel implements vscode.Tunnel { + private _onDidDisposeEmitter = new vscode.EventEmitter(); + + readonly onDidDispose = this._onDidDisposeEmitter.event; + + constructor( + readonly remoteAddress: { port: number; host: string }, + readonly localAddress: { port: number; host: string } + ) { + // If ipv6 localhost 0:0:0:0:0:0:0:1 or [::1] replace with localhost + if (localAddress.host !== 'localhost' && localAddress.host !== '127.0.0.1') { + localAddress.host = 'localhost'; + } + } + + dispose() { + this._onDidDisposeEmitter.fire(); + } +} + +export class RemoteWSLResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable { + + private labelFormatterDisposable: vscode.Disposable | undefined; + + constructor( + private readonly wslManager: WSLManager, + private readonly logger: Log + ) { + } + + resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { + const [type, distroName] = authority.split('+'); + if (type !== REMOTE_WSL_AUTHORITY) { + throw new Error(`Invalid authority type for WSL resolver: ${type}`); + } + + this.logger.info(`Resolving wsl remote authority '${authority}' (attemp #${context.resolveAttempt})`); + + // It looks like default values are not loaded yet when resolving a remote, + // so let's hardcode the default values here + const remoteSSHconfig = vscode.workspace.getConfiguration('remote.WSL'); + const serverDownloadUrlTemplate = remoteSSHconfig.get('serverDownloadUrlTemplate'); + + return vscode.window.withProgress({ + title: `Setting up WSL Distro: ${distroName}`, + location: vscode.ProgressLocation.Notification, + cancellable: false + }, async () => { + try { + const installResult = await installCodeServer(this.wslManager, distroName, serverDownloadUrlTemplate, [], [], this.logger); + + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `${REMOTE_WSL_AUTHORITY}+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: `WSL: ${distroName}`, + workspaceTooltip: `Running in ${distroName}` + } + }); + + return new vscode.ResolvedAuthority('127.0.0.1', installResult.listeningOn, installResult.connectionToken); + } catch (e: unknown) { + this.logger.error(`Error resolving authority`, e); + + // Initial connection + if (context.resolveAttempt === 1) { + this.logger.show(); + + const closeRemote = 'Close Remote'; + const retry = 'Retry'; + const result = await vscode.window.showErrorMessage(`Could not establish connection to WSL distro "${distroName}"`, { modal: true }, closeRemote, retry); + if (result === closeRemote) { + await vscode.commands.executeCommand('workbench.action.remote.close'); + } else if (result === retry) { + await vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + } + + if (e instanceof ServerInstallError || !(e instanceof Error)) { + throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e)); + } else { + throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message); + } + } + }); + } + + async tunnelFactory(tunnelOptions: vscode.TunnelOptions) { + return new Tunnel( + tunnelOptions.remoteAddress, + { + host: tunnelOptions.remoteAddress.host, + port: tunnelOptions.localAddressPort ?? tunnelOptions.remoteAddress.port + } + ); + } + + dispose() { + this.labelFormatterDisposable?.dispose(); + } +} diff --git a/extensions/open-remote-wsl/src/commands.ts b/extensions/open-remote-wsl/src/commands.ts new file mode 100644 index 00000000..23732717 --- /dev/null +++ b/extensions/open-remote-wsl/src/commands.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getRemoteAuthority } from './authResolver'; +import { WSLDistro, WSLManager, WSLOnlineDistro } from './wsl/wslManager'; +import wslTerminal from './wsl/wslTerminal'; + +async function showDistrosPicker(wslManager: WSLManager, placeHolder: string): Promise { + const pickItemsPromise = wslManager.listDistros() + .then(distros => distros.map(distroData => { + return { + ...distroData, + label: `${distroData.name}`, + detail: distroData.isDefault ? 'default distro' : undefined, + }; + })); + + const picked = await vscode.window.showQuickPick(pickItemsPromise, { canPickMany: false, placeHolder }); + return picked; +} + +async function showOnlineDistrosPicker(wslManager: WSLManager, placeHolder: string): Promise { + const pickItemsPromise = Promise.all([wslManager.listOnlineDistros(), wslManager.listDistros()]) + .then(([onlineDistros, localDistros]) => { + const distroToInstall = onlineDistros.filter(d => !localDistros.some(l => l.name === d.name)); + return distroToInstall.map(distroData => { + return { + ...distroData, + label: `${distroData.friendlyName}`, + }; + }); + }); + + const picked = await vscode.window.showQuickPick(pickItemsPromise, { canPickMany: false, placeHolder }); + return picked; +} + +export async function promptOpenRemoteWSLWindow(wslManager: WSLManager, useDefault: boolean, reuseWindow: boolean) { + let distroName: string | undefined; + if (useDefault) { + const distros = await wslManager.listDistros(); + distroName = distros.find(distro => distro.isDefault)?.name; + } else { + distroName = (await showDistrosPicker(wslManager, 'Select WSL distro'))?.name; + } + + if (!distroName) { + return; + } + + openRemoteWSLWindow(distroName, reuseWindow); +} + +export async function promptInstallNewWSLDistro(wslManager: WSLManager) { + const distroName = (await showOnlineDistrosPicker(wslManager, 'Select the WSL distro to install'))?.name; + if (!distroName) { + return; + } + + wslTerminal.runCommand(`wsl.exe --install -d ${distroName}`); +} + +export function openRemoteWSLWindow(distro: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.newWindow', { remoteAuthority: getRemoteAuthority(distro), reuseWindow }); +} + +export function openRemoteWSLLocationWindow(distro: string, path: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.from({ scheme: 'vscode-remote', authority: getRemoteAuthority(distro), path }), { forceNewWindow: !reuseWindow }); +} + +export async function setDefaultWSLDistro(wslManager: WSLManager, distroName: string) { + await wslManager.setDefaultDistro(distroName); +} + +export async function deleteWSLDistro(wslManager: WSLManager, distroName: string) { + const deleteAction = 'Delete'; + const resp = await vscode.window.showInformationMessage(`Are you sure you want to permanently delete the distro "${distroName}" including all its data?`, { modal: true }, deleteAction); + if (resp === deleteAction) { + await wslManager.deleteDistro(distroName); + return true; + } + return false; +} diff --git a/extensions/open-remote-wsl/src/common/async.ts b/extensions/open-remote-wsl/src/common/async.ts new file mode 100644 index 00000000..bc143643 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/async.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function timeout(millis: number): Promise { + return new Promise((resolve) => setTimeout(resolve, millis)); +} + +export interface ITask { + (): T; +} + +export async function retry(task: ITask>, delay: number, retries: number): Promise { + let lastError: Error | undefined; + + for (let i = 0; i < retries; i++) { + try { + return await task(); + } catch (error) { + lastError = error; + + await timeout(delay); + } + } + + throw lastError; +} diff --git a/extensions/open-remote-wsl/src/common/disposable.ts b/extensions/open-remote-wsl/src/common/disposable.ts new file mode 100644 index 00000000..8c1ee853 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/disposable.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export function disposeAll(disposables: vscode.Disposable[]): void { + while (disposables.length) { + const item = disposables.pop(); + if (item) { + item.dispose(); + } + } +} + +export abstract class Disposable { + private _isDisposed = false; + + protected _disposables: vscode.Disposable[] = []; + + public dispose(): any { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + disposeAll(this._disposables); + } + + protected _register(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/extensions/open-remote-wsl/src/common/event.ts b/extensions/open-remote-wsl/src/common/event.ts new file mode 100644 index 00000000..0944c88d --- /dev/null +++ b/extensions/open-remote-wsl/src/common/event.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDisposable { + dispose(): void; +} + +export interface Event { + (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable; +} + +/** + * Returns a promise that resolves when the event fires, or when cancellation + * is requested, whichever happens first. + */ +export function toPromise(event: Event): Promise; +export function toPromise(event: Event, signal: AbortSignal): Promise; +export function toPromise(event: Event, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => once(event, resolve)); + } + + if (signal.aborted) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const d2 = once(event, (data) => { + (signal as any).removeEventListener('abort', d1); + resolve(data); + }); + + const d1 = () => { + d2.dispose(); + (signal as any).removeEventListener('abort', d1); + resolve(undefined); + }; + + (signal as any).addEventListener('abort', d1); + }); +} + +/** + * Adds a handler that handles one event on the emitter, then disposes itself. + */ +export const once = (event: Event, listener: (data: T) => void): IDisposable => { + const disposable = event((value) => { + listener(value); + disposable.dispose(); + }); + + return disposable; +}; + +/** + * Base event emitter. Calls listeners when data is emitted. + */ +export class EventEmitter { + private listeners?: Array<(data: T) => void> | ((data: T) => void); + + /** + * Event function. + */ + public readonly event: Event = (listener, thisArgs, disposables) => { + const d = this.add(thisArgs ? listener.bind(thisArgs) : listener); + disposables?.push(d); + return d; + }; + + /** + * Gets the number of event listeners. + */ + public get size() { + if (!this.listeners) { + return 0; + } else if (typeof this.listeners === 'function') { + return 1; + } else { + return this.listeners.length; + } + } + + /** + * Emits event data. + */ + public fire(value: T) { + if (!this.listeners) { + // no-op + } else if (typeof this.listeners === 'function') { + this.listeners(value); + } else { + for (const listener of this.listeners) { + listener(value); + } + } + } + + /** + * Disposes of the emitter. + */ + public dispose() { + this.listeners = undefined; + } + + private add(listener: (data: T) => void): IDisposable { + if (!this.listeners) { + this.listeners = listener; + } else if (typeof this.listeners === 'function') { + this.listeners = [this.listeners, listener]; + } else { + this.listeners.push(listener); + } + + return { dispose: () => this.rm(listener) }; + } + + private rm(listener: (data: T) => void) { + if (!this.listeners) { + return; + } + + if (typeof this.listeners === 'function') { + if (this.listeners === listener) { + this.listeners = undefined; + } + return; + } + + const index = this.listeners.indexOf(listener); + if (index === -1) { + return; + } + + if (this.listeners.length === 2) { + this.listeners = index === 0 ? this.listeners[1] : this.listeners[0]; + } else { + this.listeners = this.listeners.slice(0, index).concat(this.listeners.slice(index + 1)); + } + } +} diff --git a/extensions/open-remote-wsl/src/common/files.ts b/extensions/open-remote-wsl/src/common/files.ts new file mode 100644 index 00000000..fd4efd8d --- /dev/null +++ b/extensions/open-remote-wsl/src/common/files.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as os from 'os'; + +const homeDir = os.homedir(); + +export async function exists(path: string) { + try { + await fs.promises.access(path); + return true; + } catch { + return false; + } +} + +export function untildify(path: string) { + return path.replace(/^~(?=$|\/|\\)/, homeDir); +} + +export function normalizeToSlash(path: string) { + return path.replace(/\\/g, '/'); +} diff --git a/extensions/open-remote-wsl/src/common/logger.ts b/extensions/open-remote-wsl/src/common/logger.ts new file mode 100644 index 00000000..0fdbde8e --- /dev/null +++ b/extensions/open-remote-wsl/src/common/logger.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +type LogLevel = 'Trace' | 'Info' | 'Error'; + +export default class Log { + private output: vscode.OutputChannel; + + constructor(name: string) { + this.output = vscode.window.createOutputChannel(name); + } + + private data2String(data: any): string { + if (data instanceof Error) { + return data.stack || data.message; + } + if (data.success === false && data.message) { + return data.message; + } + return data.toString(); + } + + public trace(message: string, data?: any): void { + this.logLevel('Trace', message, data); + } + + public info(message: string, data?: any): void { + this.logLevel('Info', message, data); + } + + public error(message: string, data?: any): void { + this.logLevel('Error', message, data); + } + + public logLevel(level: LogLevel, message: string, data?: any): void { + this.output.appendLine(`[${level} - ${this.now()}] ${message}`); + if (data) { + this.output.appendLine(this.data2String(data)); + } + } + + private now(): string { + const now = new Date(); + return padLeft(now.getUTCHours() + '', 2, '0') + + ':' + padLeft(now.getMinutes() + '', 2, '0') + + ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds(); + } + + public show() { + this.output.show(); + } + + public dispose() { + this.output.dispose(); + } +} + +function padLeft(s: string, n: number, pad = ' ') { + return pad.repeat(Math.max(0, n - s.length)) + s; +} diff --git a/extensions/open-remote-wsl/src/common/platform.ts b/extensions/open-remote-wsl/src/common/platform.ts new file mode 100644 index 00000000..91673ab0 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/platform.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const isWindows = process.platform === 'win32'; +export const isMacintosh = process.platform === 'darwin'; +export const isLinux = process.platform === 'linux'; diff --git a/extensions/open-remote-wsl/src/common/ports.ts b/extensions/open-remote-wsl/src/common/ports.ts new file mode 100644 index 00000000..f56df4b5 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/ports.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as net from 'net'; + +/** + * Finds a random unused port assigned by the operating system. Will reject in case no free port can be found. + */ +export function findRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer({ pauseOnConnect: true }); + server.on('error', reject); + server.on('listening', () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + server.listen(0, '127.0.0.1'); + }); +} + +/** + * Given a start point and a max number of retries, will find a port that + * is openable. Will return 0 in case no free port can be found. + */ +export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise { + let done = false; + + return new Promise(resolve => { + const timeoutHandle = setTimeout(() => { + if (!done) { + done = true; + return resolve(0); + } + }, timeout); + + doFindFreePort(startPort, giveUpAfter, stride, (port) => { + if (!done) { + done = true; + clearTimeout(timeoutHandle); + return resolve(port); + } + }); + }); +} + +function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void { + if (giveUpAfter === 0) { + return clb(0); + } + + const client = new net.Socket(); + + // If we can connect to the port it means the port is already taken so we continue searching + client.once('connect', () => { + dispose(client); + + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + }); + + client.once('data', () => { + // this listener is required since node.js 8.x + }); + + client.once('error', (err: Error & { code?: string }) => { + dispose(client); + + // If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect + if (err.code !== 'ECONNREFUSED') { + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + } + + // Otherwise it means the port is free to use! + return clb(startPort); + }); + + client.connect(startPort, '127.0.0.1'); +} + +/** + * Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener. + */ +export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise { + let resolved = false; + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + let countTried = 1; + const server = net.createServer({ pauseOnConnect: true }); + function doResolve(port: number, resolve: (port: number) => void) { + if (!resolved) { + resolved = true; + server.removeAllListeners(); + server.close(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + resolve(port); + } + } + return new Promise(resolve => { + timeoutHandle = setTimeout(() => { + doResolve(0, resolve); + }, timeout); + + server.on('listening', () => { + doResolve(startPort, resolve); + }); + server.on('error', err => { + if (err && ((err).code === 'EADDRINUSE' || (err).code === 'EACCES') && (countTried < giveUpAfter)) { + startPort++; + countTried++; + server.listen(startPort, '127.0.0.1'); + } else { + doResolve(0, resolve); + } + }); + server.on('close', () => { + doResolve(0, resolve); + }); + server.listen(startPort, '127.0.0.1'); + }); +} + +function dispose(socket: net.Socket): void { + try { + socket.removeAllListeners('connect'); + socket.removeAllListeners('error'); + socket.end(); + socket.destroy(); + socket.unref(); + } catch (error) { + console.error(error); // otherwise this error would get lost in the callback chain + } +} diff --git a/extensions/open-remote-wsl/src/distroTreeView.ts b/extensions/open-remote-wsl/src/distroTreeView.ts new file mode 100644 index 00000000..e0c4b89f --- /dev/null +++ b/extensions/open-remote-wsl/src/distroTreeView.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { RemoteLocationHistory } from './remoteLocationHistory'; +import { Disposable } from './common/disposable'; +import { openRemoteWSLWindow, openRemoteWSLLocationWindow, promptInstallNewWSLDistro, deleteWSLDistro, setDefaultWSLDistro } from './commands'; +import { WSLManager } from './wsl/wslManager'; + +class DistroItem { + constructor( + public name: string, + public isDefault: boolean, + public locations: string[] + ) { + } +} + +class DistroLocationItem { + constructor( + public path: string, + public name: string + ) { + } +} + +type DataTreeItem = DistroItem | DistroLocationItem; + +export class DistroTreeDataProvider extends Disposable implements vscode.TreeDataProvider { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private readonly locationHistory: RemoteLocationHistory, + private readonly wslManager: WSLManager + ) { + super(); + + this._register(vscode.commands.registerCommand('openremotewsl.explorer.addDistro', () => promptInstallNewWSLDistro(wslManager))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.refresh', () => this.refresh())); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.emptyWindowInNewWindow', e => this.openRemoteWSLWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.emptyWindowInCurrentWindow', e => this.openRemoteWSLWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.reopenFolderInNewWindow', e => this.openRemoteWSLocationWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.reopenFolderInCurrentWindow', e => this.openRemoteWSLocationWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.deleteFolderHistoryItem', e => this.deleteDistroLocation(e))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.setDefaultDistro', e => this.setDefaultDistro(e))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.deleteDistro', e => this.deleteDistro(e))); + } + + getTreeItem(element: DataTreeItem): vscode.TreeItem { + if (element instanceof DistroLocationItem) { + const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)'); + const treeItem = new vscode.TreeItem(label); + treeItem.description = path.posix.dirname(element.path); + treeItem.iconPath = new vscode.ThemeIcon('folder'); + treeItem.contextValue = 'openremotewsl.explorer.folder'; + return treeItem; + } + + const treeItem = new vscode.TreeItem(element.name); + treeItem.description = element.isDefault ? 'default distro' : undefined; + treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon('vm'); + treeItem.contextValue = 'openremotewsl.explorer.distro'; + return treeItem; + } + + async getChildren(element?: DistroItem): Promise { + if (!element) { + const distros = await this.wslManager.listDistros(); + return distros.map(distro => new DistroItem(distro.name, distro.isDefault, this.locationHistory.getHistory(distro.name))); + } + if (element instanceof DistroItem) { + return element.locations.map(location => new DistroLocationItem(location, element.name)); + } + return []; + } + + private refresh() { + this._onDidChangeTreeData.fire(); + } + + private async deleteDistroLocation(element: DistroLocationItem) { + await this.locationHistory.removeLocation(element.name, element.path); + this.refresh(); + } + + private async openRemoteWSLWindow(element: DistroItem, reuseWindow: boolean) { + openRemoteWSLWindow(element.name, reuseWindow); + } + + private async openRemoteWSLocationWindow(element: DistroLocationItem, reuseWindow: boolean) { + openRemoteWSLLocationWindow(element.name, element.path, reuseWindow); + } + + private async setDefaultDistro(element: DistroItem) { + await setDefaultWSLDistro(this.wslManager, element.name); + this.refresh(); + } + + private async deleteDistro(element: DistroItem) { + await deleteWSLDistro(this.wslManager, element.name); + this.refresh(); + } +} diff --git a/extensions/open-remote-wsl/src/extension.ts b/extensions/open-remote-wsl/src/extension.ts new file mode 100644 index 00000000..987d00f6 --- /dev/null +++ b/extensions/open-remote-wsl/src/extension.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { RemoteWSLResolver, REMOTE_WSL_AUTHORITY } from './authResolver'; +import { promptOpenRemoteWSLWindow } from './commands'; +import { DistroTreeDataProvider } from './distroTreeView'; +import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory'; +import { WSLManager } from './wsl/wslManager'; +import { isWindows } from './common/platform'; + +export async function activate(context: vscode.ExtensionContext) { + if (!isWindows) { + return; + } + + const logger = new Log('Remote - WSL'); + context.subscriptions.push(logger); + + const wslManager = new WSLManager(logger); + const remoteWSLResolver = new RemoteWSLResolver(wslManager, logger); + context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_WSL_AUTHORITY, remoteWSLResolver)); + context.subscriptions.push(remoteWSLResolver); + + const locationHistory = new RemoteLocationHistory(context); + const locationData = getRemoteWorkspaceLocationData(); + if (locationData) { + await locationHistory.addLocation(locationData[0], locationData[1]); + } + + const distroTreeDataProvider = new DistroTreeDataProvider(locationHistory, wslManager); + context.subscriptions.push(vscode.window.createTreeView('wslTargets', { treeDataProvider: distroTreeDataProvider })); + context.subscriptions.push(distroTreeDataProvider); + + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connect', () => promptOpenRemoteWSLWindow(wslManager, true, true))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connectInNewWindow', () => promptOpenRemoteWSLWindow(wslManager, true, false))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connectUsingDistro', () => promptOpenRemoteWSLWindow(wslManager, false, true))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connectUsingDistroInNewWindow', () => promptOpenRemoteWSLWindow(wslManager, false, false))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.showLog', () => logger.show())); +} + +export function deactivate() { +} diff --git a/extensions/open-remote-wsl/src/remoteLocationHistory.ts b/extensions/open-remote-wsl/src/remoteLocationHistory.ts new file mode 100644 index 00000000..d3aa5f36 --- /dev/null +++ b/extensions/open-remote-wsl/src/remoteLocationHistory.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { REMOTE_WSL_AUTHORITY } from './authResolver'; + +export class RemoteLocationHistory { + private static STORAGE_KEY = 'remoteLocationHistory_v0'; + + private remoteLocationHistory: Record = {}; + + constructor(private context: vscode.ExtensionContext) { + // context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined); + this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {}; + } + + getHistory(host: string): string[] { + return this.remoteLocationHistory[host] || []; + } + + async addLocation(host: string, path: string) { + const hostLocations = this.remoteLocationHistory[host] || []; + if (!hostLocations.includes(path)) { + hostLocations.unshift(path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } + } + + async removeLocation(host: string, path: string) { + let hostLocations = this.remoteLocationHistory[host] || []; + hostLocations = hostLocations.filter(l => l !== path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } +} + +export function getRemoteWorkspaceLocationData(): [string, string] | undefined { + let location = vscode.workspace.workspaceFile; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_WSL_AUTHORITY) && location.path.endsWith('.code-workspace')) { + const [, distroName] = location.authority.split('+'); + return [distroName, location.path]; + } + + location = vscode.workspace.workspaceFolders?.[0].uri; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_WSL_AUTHORITY)) { + const [, distroName] = location.authority.split('+'); + return [distroName, location.path]; + } + + return undefined; +} diff --git a/extensions/open-remote-wsl/src/serverConfig.ts b/extensions/open-remote-wsl/src/serverConfig.ts new file mode 100644 index 00000000..572e3be3 --- /dev/null +++ b/extensions/open-remote-wsl/src/serverConfig.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +let vscodeProductJson: any; +async function getVSCodeProductJson() { + if (!vscodeProductJson) { + const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8'); + vscodeProductJson = JSON.parse(productJsonStr); + } + + return vscodeProductJson; +} + +export interface IServerConfig { + version: string; + commit: string; + quality: string; + release?: string; // void-like specific + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate?: string; // void-like specific +} + +export async function getVSCodeServerConfig(): Promise { + const productJson = await getVSCodeProductJson(); + + return { + version: vscode.version.replace('-insider', ''), + commit: productJson.commit, + quality: productJson.quality, + release: productJson.release, + serverApplicationName: productJson.serverApplicationName, + serverDataFolderName: productJson.serverDataFolderName, + serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate + }; +} diff --git a/extensions/open-remote-wsl/src/serverSetup.ts b/extensions/open-remote-wsl/src/serverSetup.ts new file mode 100644 index 00000000..60e56093 --- /dev/null +++ b/extensions/open-remote-wsl/src/serverSetup.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import Log from './common/logger'; +import { getVSCodeServerConfig } from './serverConfig'; +import { WSLManager } from './wsl/wslManager'; + +export interface ServerInstallOptions { + id: string; + quality: string; + commit: string; + version: string; + release?: string; // void specific + extensionIds: string[]; + envVariables: string[]; + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate: string; +} + +export interface ServerInstallResult { + exitCode: number; + listeningOn: number; + connectionToken: string; + logFile: string; + osReleaseId: string; + arch: string; + platform: string; + tmpDir: string; + [key: string]: any; +} + +export class ServerInstallError extends Error { + constructor(message: string) { + super(message); + } +} + +const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/codestoryai/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz'; + +export async function installCodeServer(wslManager: WSLManager, distroName: string, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], logger: Log): Promise { + const scriptId = crypto.randomBytes(12).toString('hex'); + + const vscodeServerConfig = await getVSCodeServerConfig(); + const installOptions: ServerInstallOptions = { + id: scriptId, + version: vscodeServerConfig.version, + commit: vscodeServerConfig.commit, + quality: vscodeServerConfig.quality, + release: vscodeServerConfig.release, + extensionIds, + envVariables, + serverApplicationName: vscodeServerConfig.serverApplicationName, + serverDataFolderName: vscodeServerConfig.serverDataFolderName, + serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE, + }; + + const installServerScript = generateBashInstallScript(installOptions); + + // Fish shell does not support heredoc so let's workaround it using -c option, + // also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676 + const resp = await wslManager.exec('bash', ['-c', `'${installServerScript.replace(/'/g, `'\\''`)}'`], distroName); + + const endScriptRegex = new RegExp(`${scriptId}: Server installation script done`, 'm'); + const commandOutput = await Promise.race([ + resp.exitPromise.then(result => ({ stdout: resp.stdout, stderr: resp.stderr, exitCode: result.exitCode })), + new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + resp.onStdoutData(buffer => { + if (endScriptRegex.test(buffer.toString('utf8'))) { + resolve({ stdout: resp.stdout, stderr: resp.stderr, exitCode: 0 }); + } + }); + }) + ]); + + if (commandOutput.exitCode) { + logger.trace('Server install command stderr:', commandOutput.stderr); + } + logger.trace('Server install command stdout:', commandOutput.stdout); + + const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId); + if (!resultMap) { + throw new ServerInstallError(`Failed parsing install script output`); + } + + const exitCode = parseInt(resultMap.exitCode, 10); + if (exitCode !== 0) { + throw new ServerInstallError(`Couldn't install void server on remote server, install script returned non-zero exit status`); + } + + const listeningOn = parseInt(resultMap.listeningOn, 10); + + const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key))); + + return { + exitCode, + listeningOn, + connectionToken: resultMap.connectionToken, + logFile: resultMap.logFile, + osReleaseId: resultMap.osReleaseId, + arch: resultMap.arch, + platform: resultMap.platform, + tmpDir: resultMap.tmpDir, + ...remoteEnvVars + }; +} + +function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { + const startResultStr = `${scriptId}: start`; + const endResultStr = `${scriptId}: end`; + + const startResultIdx = str.indexOf(startResultStr); + if (startResultIdx < 0) { + return undefined; + } + + const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length); + if (endResultIdx < 0) { + return undefined; + } + + const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx); + + const resultMap: { [k: string]: string } = {}; + const resultArr = installResult.split(/\r?\n/); + for (const line of resultArr) { + const [key, value] = line.split('=='); + resultMap[key] = value; + } + + return resultMap; +} + +function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { + const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); + return ` +# Server installation script + +TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}" + +DISTRO_VERSION="${version}" +DISTRO_COMMIT="${commit}" +DISTRO_QUALITY="${quality}" +DISTRO_VSCODIUM_RELEASE="${release ?? ''}" + +SERVER_APP_NAME="${serverApplicationName}" +SERVER_INITIAL_EXTENSIONS="${extensions}" +SERVER_LISTEN_FLAG="--port=0" +SERVER_DATA_DIR="$HOME/${serverDataFolderName}" +SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT" +SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME" +SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log" +SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid" +SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token" +SERVER_OS= +SERVER_ARCH= +SERVER_CONNECTION_TOKEN= +SERVER_DOWNLOAD_URL= + +LISTENING_ON= +OS_RELEASE_ID= +ARCH= +PLATFORM= + +# Mimic output from logs of remote-ssh extension +print_install_results_and_exit() { + echo "${id}: start" + echo "exitCode==$1==" + echo "listeningOn==$LISTENING_ON==" + echo "connectionToken==$SERVER_CONNECTION_TOKEN==" + echo "logFile==$SERVER_LOGFILE==" + echo "osReleaseId==$OS_RELEASE_ID==" + echo "arch==$ARCH==" + echo "platform==$PLATFORM==" + echo "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} + echo "${id}: end" + exit 0 +} + +# Check if platform is supported +PLATFORM="$(uname -s)" +case $PLATFORM in + Linux) + SERVER_OS="linux" + ;; + *) + echo "Error platform not supported: $PLATFORM" + print_install_results_and_exit 1 + ;; +esac + +# Check machine architecture +ARCH="$(uname -m)" +case $ARCH in + x86_64 | amd64) + SERVER_ARCH="x64" + ;; + armv7l | armv8l) + SERVER_ARCH="armhf" + ;; + arm64 | aarch64) + SERVER_ARCH="arm64" + ;; + *) + echo "Error architecture not supported: $ARCH" + print_install_results_and_exit 1 + ;; +esac + +# https://www.freedesktop.org/software/systemd/man/os-release.html +OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" +if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" + if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="unknown" + fi +fi + +# Create installation folder +if [[ ! -d $SERVER_DIR ]]; then + mkdir -p $SERVER_DIR + if (( $? > 0 )); then + echo "Error creating server install directory" + print_install_results_and_exit 1 + fi +fi + +SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$SERVER_OS/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VSCODIUM_RELEASE/g")" + +# Check if server script is already installed +if [[ ! -f $SERVER_SCRIPT ]]; then + if [[ "$SERVER_OS" = "dragonfly" ]] || [[ "$SERVER_OS" = "freebsd" ]]; then + echo "Error "$SERVER_OS" needs manual installation of remote extension host" + print_install_results_and_exit 1 + fi + + pushd $SERVER_DIR > /dev/null + + if [[ ! -z $(which wget) ]]; then + wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL + elif [[ ! -z $(which curl) ]]; then + curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL + else + echo "Error no tool to download server binary" + print_install_results_and_exit 1 + fi + + if (( $? > 0 )); then + echo "Error downloading server from $SERVER_DOWNLOAD_URL" + print_install_results_and_exit 1 + fi + + tar -xf vscode-server.tar.gz --strip-components 1 + if (( $? > 0 )); then + echo "Error while extracting server contents" + print_install_results_and_exit 1 + fi + + if [[ ! -f $SERVER_SCRIPT ]]; then + echo "Error server contents are corrupted" + print_install_results_and_exit 1 + fi + + rm -f vscode-server.tar.gz + + popd > /dev/null +else + echo "Server script already installed in $SERVER_SCRIPT" +fi + +# Try to find if server is already running +if [[ -f $SERVER_PIDFILE ]]; then + SERVER_PID="$(cat $SERVER_PIDFILE)" + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" +else + SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)" +fi + +if [[ -z $SERVER_RUNNING_PROCESS ]]; then + if [[ -f $SERVER_LOGFILE ]]; then + rm $SERVER_LOGFILE + fi + if [[ -f $SERVER_TOKENFILE ]]; then + rm $SERVER_TOKENFILE + fi + + touch $SERVER_TOKENFILE + chmod 600 $SERVER_TOKENFILE + SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" + echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE + + $SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --use-host-proxy --disable-websocket-compression --without-browser-env-var --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE & + echo $! > $SERVER_PIDFILE +else + echo "Server script is already running $SERVER_SCRIPT" +fi + +if [[ -f $SERVER_TOKENFILE ]]; then + SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" +else + echo "Error server token file not found $SERVER_TOKENFILE" + print_install_results_and_exit 1 +fi + +if [[ -f $SERVER_LOGFILE ]]; then + for i in {1..5}; do + LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')" + if [[ -n $LISTENING_ON ]]; then + break + fi + sleep 0.5 + done + + if [[ -z $LISTENING_ON ]]; then + echo "Error server did not start sucessfully" + print_install_results_and_exit 1 + fi +else + echo "Error server log file not found $SERVER_LOGFILE" + print_install_results_and_exit 1 +fi + +# Finish server setup and keep script running +if [[ -z $SERVER_RUNNING_PROCESS ]]; then + echo "${id}: start" + echo "exitCode==0==" + echo "listeningOn==$LISTENING_ON==" + echo "connectionToken==$SERVER_CONNECTION_TOKEN==" + echo "logFile==$SERVER_LOGFILE==" + echo "osReleaseId==$OS_RELEASE_ID==" + echo "arch==$ARCH==" + echo "platform==$PLATFORM==" + echo "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} + echo "${id}: end" + + echo "${id}: Server installation script done" + + SERVER_PID="$(cat $SERVER_PIDFILE)" + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" + while [[ -n $SERVER_RUNNING_PROCESS ]]; do + sleep 300; + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" + done +else + print_install_results_and_exit 0 +fi +`; +} diff --git a/extensions/open-remote-wsl/src/wsl/wslManager.ts b/extensions/open-remote-wsl/src/wsl/wslManager.ts new file mode 100644 index 00000000..f5dae1a8 --- /dev/null +++ b/extensions/open-remote-wsl/src/wsl/wslManager.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import Log from '../common/logger'; +import { EventEmitter } from '../common/event'; + +const wslBinary = 'wsl.exe'; + +export interface WSLDistro { + isDefault: boolean; + name: string; + state: string; + version: string; +} + +export interface WSLOnlineDistro { + name: string; + friendlyName: string; +} + +export class WSLManager { + constructor(private readonly logger: Log) { + } + + async listDistros() { + const resp = this._runWSLCommand(['--list', '--verbose'], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl listDistros exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl listDistros exited with code ${exitCode}`); + } + + const regex = /(?\*|\s)\s+(?[\w\.-]+)\s+(?[\w]+)\s+(?\d)/; + const distros: WSLDistro[] = []; + for (const line of stdout.split(/\r?\n/)) { + const matches = line.match(regex); + if (matches && matches.groups) { + distros.push({ + isDefault: matches.groups.default === '*', + name: matches.groups.name, + state: matches.groups.state, + version: matches.groups.version, + }); + } + } + + return distros; + } + + async listOnlineDistros() { + const resp = this._runWSLCommand(['--list', '--online'], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl listOnlineDistros exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl listOnlineDistros exited with code ${exitCode}`); + } + + let lines = stdout.split(/\r?\n/); + const idx = lines.findIndex(l => /\s*NAME\s+FRIENDLY NAME\s*/.test(l)); + lines = lines.slice(idx + 1); + + const regex = /(?[\w\.-]+)\s+(?\w.+\w)/; + const distros: WSLOnlineDistro[] = []; + for (const line of lines) { + const matches = line.match(regex); + if (matches && matches.groups) { + distros.push({ + name: matches.groups.name, + friendlyName: matches.groups.friendlyName, + }); + } + } + + return distros; + } + + async setDefaultDistro(distroName: string) { + const resp = this._runWSLCommand(['--set-default', distroName], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl setDefaultDistro exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl setDefaultDistro exited with code ${exitCode}`); + } + } + + async deleteDistro(distroName: string) { + const resp = this._runWSLCommand(['--unregister', distroName], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl deleteDistro exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl deleteDistro exited with code ${exitCode}`); + } + } + + async exec(cmd: string, args: string[], distro: string) { + return this._runWSLCommand(['--distribution', distro, '--', cmd, ...args], 'utf8'); + } + + private _runWSLCommand(args: string[], encoding: 'utf8' | 'utf16le') { + this.logger.trace(`Running WSL command: ${wslBinary} ${args.join(' ')}`); + + const cmd = cp.spawn(wslBinary, args, { windowsHide: true, windowsVerbatimArguments: true }); + + const stdoutDataEmitter = new EventEmitter(); + const stdoutData: Buffer[] = []; + const stderrDataEmitter = new EventEmitter(); + const stderrData: Buffer[] = []; + cmd.stdout.on('data', (data: Buffer) => { + stdoutData.push(data); + stdoutDataEmitter.fire(data); + }); + cmd.stderr.on('data', (data: Buffer) => { + stderrData.push(data); + stderrDataEmitter.fire(data); + }); + + const exitPromise = new Promise<{ exitCode: number }>((resolve, reject) => { + cmd.on('error', (err) => { + this.logger.error(`Error running WSL command: ${wslBinary} ${args.join(' ')}`, err); + reject(err); + }); + cmd.on('exit', (code, _signal) => { + resolve({ exitCode: code ?? 0 }); + }); + }); + + return { + get stdout() { + return Buffer.concat(stdoutData).toString(encoding); + }, + get stderr() { + return Buffer.concat(stderrData).toString(encoding); + }, + get onStdoutData() { + return stdoutDataEmitter.event; + }, + get onStderrData() { + return stderrDataEmitter.event; + }, + exitPromise + }; + } +} diff --git a/extensions/open-remote-wsl/src/wsl/wslTerminal.ts b/extensions/open-remote-wsl/src/wsl/wslTerminal.ts new file mode 100644 index 00000000..190141c6 --- /dev/null +++ b/extensions/open-remote-wsl/src/wsl/wslTerminal.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +class WSLTerminal { + static NAME = 'WSL'; + + private getTerminal() { + const wslTerminal = vscode.window.terminals.find(t => t.name === WSLTerminal.NAME); + if (wslTerminal) { + return wslTerminal; + } + return vscode.window.createTerminal(WSLTerminal.NAME); + } + + runCommand(command: string) { + const wslTerminal = this.getTerminal(); + wslTerminal.show(false); + wslTerminal.sendText(command, true); + } +} + +export default new WSLTerminal(); diff --git a/extensions/open-remote-wsl/tsconfig.json b/extensions/open-remote-wsl/tsconfig.json new file mode 100644 index 00000000..2e08e0d2 --- /dev/null +++ b/extensions/open-remote-wsl/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts", + "../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts", + ] +}