Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
167 lines
6.5 KiB
JavaScript
167 lines
6.5 KiB
JavaScript
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)
|
|
})
|
|
}
|