DSH-Arch-Setup/plugins/dsh-touched-git/lib/editor-runtime.js
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
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.
2026-09-12 19:13:36 +03:00

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)
})
}