import { spawn } from 'node:child_process' import { randomBytes } from 'node:crypto' import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { constants } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { openFileInExistingWorkspace } from './editor-file-bridge.js' const PLUGIN_DIRECTORY = dirname(dirname(fileURLToPath(import.meta.url))) export class EditorWorkspaces { constructor(startRuntime = startEditorRuntime, openExistingFile = openFileInExistingWorkspace) { this.startRuntime = startRuntime this.openExistingFile = openExistingFile this.starting = null this.disposed = false } async ensureRuntime() { if (this.disposed) throw new Error('The editor launcher has stopped.') if (!this.starting) { const starting = this.startRuntime() this.starting = starting starting.then(runtime => { runtime.closed.finally(() => { if (this.starting === starting) this.starting = null }) }, () => { if (this.starting === starting) this.starting = null }) } const runtime = await this.starting if (this.disposed) throw new Error('The editor launcher has stopped.') return runtime } async open(directory) { const runtime = await this.ensureRuntime() return { url: workspaceUrl(runtime, directory).href, directory } } async openFile(directory, filePath, { requireExisting = false, signal } = {}) { signal?.throwIfAborted() if (requireExisting && !this.starting) throw new Error('The existing workspace is not connected to this DSH host. Save it and reopen it from the Git Working Dirs menu.') const runtime = await this.ensureRuntime() signal?.throwIfAborted() const delivery = await this.openExistingFile({ bridgeDirectory: runtime.bridgeDirectory, workspaceDirectory: directory, filePath, signal }) signal?.throwIfAborted() if (this.disposed) throw new Error('The editor launcher has stopped.') if (delivery.opened) return { delivery: 'existing', directory } if (requireExisting) throw new Error('The workspace file bridge is not ready. Refresh that VS Code tab after installing the bridge, then try again.') const url = workspaceUrl(runtime, directory) const remoteFile = new URL(`vscode-remote://${url.host}`) remoteFile.pathname = filePath.split('/').map(encodeURIComponent).join('/') url.searchParams.set('payload', JSON.stringify([['openFile', remoteFile.href]])) return { delivery: 'new', directory, url: url.href } } async dispose() { this.disposed = true if (!this.starting) return const runtime = await this.starting.catch(() => null) if (runtime) await runtime.stop() } } function workspaceUrl(runtime, directory) { const url = new URL(runtime.url) url.searchParams.set('tkn', runtime.token) url.searchParams.set('folder', directory) return url } function editorEnvironment() { const allowedNames = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TZ', 'TMPDIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'SSH_AUTH_SOCK']) return Object.fromEntries(Object.entries(process.env).filter(([name]) => allowedNames.has(name) || name.startsWith('LC_') || name.startsWith('XDG_'))) } export async function startEditorRuntime() { const executable = join(PLUGIN_DIRECTORY, 'runtime', 'current', 'node') const serverEntry = join(PLUGIN_DIRECTORY, 'runtime', 'current', 'out', 'server-main.js') try { await access(executable, constants.X_OK) await access(serverEntry, constants.R_OK) } catch { throw new Error(`VS Code runtime is not installed. Run node ${join(PLUGIN_DIRECTORY, 'scripts', 'install-editor.mjs')} and try again.`) } const dataDirectory = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'touched-git', 'editor') await mkdir(dataDirectory, { recursive: true, mode: 0o700 }) const launchDirectory = await mkdtemp(join(dataDirectory, 'launch-')) const bridgeDirectory = await mkdtemp(join(tmpdir(), 'dshb-')) const token = randomBytes(32).toString('hex') const tokenFile = join(launchDirectory, 'connection-token') await writeFile(tokenFile, token, { mode: 0o600 }) const child = spawn(executable, [ serverEntry, '--host', '127.0.0.1', '--port', '0', '--connection-token-file', tokenFile, '--server-data-dir', join(dataDirectory, 'data'), '--extensions-dir', join(dataDirectory, 'extensions'), '--telemetry-level', 'off', ], { cwd: PLUGIN_DIRECTORY, env: { ...editorEnvironment(), DSH_EDITOR_BRIDGE_DIRECTORY: bridgeDirectory }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }) const closed = new Promise(resolve => { child.once('close', async () => { await Promise.all([ rm(launchDirectory, { recursive: true, force: true }).catch(() => {}), rm(bridgeDirectory, { recursive: true, force: true }).catch(() => {}), ]) resolve() }) }) const stop = async () => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') await closed } try { const url = await waitForEditorReady(child) return { url, token, bridgeDirectory, closed, stop } } catch (error) { await stop() throw error } } export function waitForEditorReady(child, timeoutMilliseconds = 30000) { return new Promise((resolve, reject) => { let output = '' const timer = setTimeout(() => fail(new Error('VS Code did not become ready within 30 seconds.')), timeoutMilliseconds) const cleanup = () => { clearTimeout(timer) child.stdout.off('data', onOutput) child.stderr.off('data', onOutput) child.off('error', onError) child.off('exit', onExit) } const fail = error => { cleanup() reject(error) } const onError = () => fail(new Error('Unable to execute the installed VS Code runtime.')) const onExit = code => fail(new Error(`VS Code exited before becoming ready (exit ${code ?? 'signal'}).`)) const onOutput = chunk => { output = (output + chunk.toString()).slice(-16384) const match = output.match(/Web UI available at http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(?=[/?\s])/) if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535) return cleanup() resolve(`http://127.0.0.1:${match[1]}`) } child.stdout.on('data', onOutput) child.stderr.on('data', onOutput) child.once('error', onError) child.once('exit', onExit) }) }