From d528d11bc140c8a4384f47452090ea6a4be8e99e Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Wed, 23 Sep 2026 18:18:46 +0800 Subject: [PATCH] fix: bundle WorkDSH runtime in desktop apps --- dsh-plugin-desktop-beta/.gitignore | 3 + dsh-plugin-desktop-beta/package.json | 23 ++- .../scripts/prepare-workdsh-runtime.mjs | 102 ++++++++++ dsh-plugin-desktop-beta/src/workdsh-main.ts | 188 ++++++++++++++++++ dsh-plugin-desktop-beta/tests/package.spec.ts | 10 +- dsh-plugin-desktop-beta/tsdown.config.ts | 1 + dsh-plugin-desktop/.gitignore | 4 + dsh-plugin-desktop/package.json | 28 ++- .../scripts/prepare-workdsh-runtime.mjs | 102 ++++++++++ dsh-plugin-desktop/src/workdsh-main.ts | 188 ++++++++++++++++++ dsh-plugin-desktop/tests/package.spec.ts | 11 +- dsh-plugin-desktop/tsdown.config.ts | 1 + 12 files changed, 637 insertions(+), 24 deletions(-) create mode 100644 dsh-plugin-desktop-beta/scripts/prepare-workdsh-runtime.mjs create mode 100644 dsh-plugin-desktop-beta/src/workdsh-main.ts create mode 100644 dsh-plugin-desktop/scripts/prepare-workdsh-runtime.mjs create mode 100644 dsh-plugin-desktop/src/workdsh-main.ts diff --git a/dsh-plugin-desktop-beta/.gitignore b/dsh-plugin-desktop-beta/.gitignore index b7d4007834..341008d80a 100644 --- a/dsh-plugin-desktop-beta/.gitignore +++ b/dsh-plugin-desktop-beta/.gitignore @@ -1,3 +1,6 @@ dist/ /lib/ node_modules/ +build/workdsh-runtime +build/.workdsh-release-* +build/.workdsh-node diff --git a/dsh-plugin-desktop-beta/package.json b/dsh-plugin-desktop-beta/package.json index 87c1d7f227..3c337938cd 100644 --- a/dsh-plugin-desktop-beta/package.json +++ b/dsh-plugin-desktop-beta/package.json @@ -12,7 +12,7 @@ "directory": "dsh-plugin-desktop-beta" }, "type": "module", - "main": "lib/main.js", + "main": "lib/workdsh-main.js", "types": "lib/types/index.d.ts", "bin": { "dsh-desktop-beta": "lib/bin.js", @@ -124,16 +124,17 @@ "verify:operations": "node --test scripts/operation-reliability.spec.mjs && node scripts/verify-operation-reliability.mjs", "verify:notices": "node scripts/verify-licenses.mjs --notices THIRD_PARTY_NOTICES.md", "prepare:electron-native": "node scripts/prepare-fs-ext.ts", + "prepare:workdsh-runtime": "node scripts/prepare-workdsh-runtime.mjs", "check": "yarn run build && yarn run typecheck && yarn run test && yarn run verify:closure && yarn run verify:cli && yarn run verify:loader && yarn run verify:profile && yarn run verify:licenses && yarn run verify:operations", "check:win-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package.spec.ts tests/package-win.spec.ts tests/desktop-installer-quit.spec.ts tests/installer-nsh.spec.ts tests/update-checker.spec.ts tests/update-download.spec.ts tests/verify-win-installer.spec.ts tests/verify-win-portable.spec.ts tests/verify-packaged-runtime.spec.ts tests/windows-pwsh-sandbox.spec.ts tests/windows-volume-diagnostics.spec.ts tests/window-options.spec.ts tests/safe-mode.spec.ts && yarn run verify:closure", "check:mac-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package.spec.ts tests/package-mac.spec.ts tests/verify-mac-smoke.spec.ts tests/verify-packaged-runtime.spec.ts tests/mac-universal.spec.ts && yarn run verify:closure", "start": "node lib/bin.js", "dev": "yarn run build && yarn run prepare:electron-native && node lib/bin.js", - "package:dir": "yarn run build && yarn run prepare:electron-native && node scripts/package-dir.mjs", - "dist:mac": "node scripts/release-mac.ts", - "dist:mac-smoke": "node scripts/package-mac.ts", - "dist:win": "node scripts/package-win.ts", - "dist:win-portable": "node scripts/package-win-portable.ts", + "package:dir": "yarn run build && yarn run prepare:electron-native && yarn run prepare:workdsh-runtime && node scripts/package-dir.mjs", + "dist:mac": "yarn run prepare:workdsh-runtime && node scripts/release-mac.ts", + "dist:mac-smoke": "yarn run prepare:workdsh-runtime && node scripts/package-mac.ts", + "dist:win": "yarn run prepare:workdsh-runtime && node scripts/package-win.ts", + "dist:win-portable": "yarn run prepare:workdsh-runtime && node scripts/package-win-portable.ts", "prepack": "yarn run check" }, "dependencies": { @@ -348,6 +349,16 @@ "output": "dist", "buildResources": "build" }, + "extraResources": [ + { + "from": "build/workdsh-runtime", + "to": "workdsh-runtime", + "filter": [ + "profiles/workdsh/**", + "node/**" + ] + } + ], "toolsets": { "nsis": "1.2.1" }, diff --git a/dsh-plugin-desktop-beta/scripts/prepare-workdsh-runtime.mjs b/dsh-plugin-desktop-beta/scripts/prepare-workdsh-runtime.mjs new file mode 100644 index 0000000000..e7cde8e8e4 --- /dev/null +++ b/dsh-plugin-desktop-beta/scripts/prepare-workdsh-runtime.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WORKDSH_VERSION = '0.1.0-alpha.8' +const DSH_VERSION = '0.1.7-alpha.1' +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const output = join(desktopRoot, 'build', 'workdsh-runtime') +const destination = join(output, 'profiles', 'workdsh') +const cli = join(destination, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`${command} exited with ${result.status}`) +} + +async function download(url, path) { + if (existsSync(path)) return + const response = await fetch(url, { redirect: 'follow' }) + if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`) + writeFileSync(path, Buffer.from(await response.arrayBuffer())) +} + +async function installReleasedProfile() { + const releaseDir = join(desktopRoot, 'build', `.workdsh-release-${WORKDSH_VERSION}`) + mkdirSync(releaseDir, { recursive: true }) + const base = `https://github.com/techflag/workdsh/releases/download/v${WORKDSH_VERSION}` + const manifestPath = join(releaseDir, 'release-manifest.json') + await download(`${base}/release-manifest.json`, manifestPath) + const installerPath = join(releaseDir, 'install-workdsh.mjs') + await download(`${base}/install-workdsh.mjs`, installerPath) + if (process.platform === 'win32') { + let installer = readFileSync(installerPath, 'utf8') + installer = installer + .replace("import { spawnSync } from 'node:child_process';", "import { spawnSync as nativeSpawnSync } from 'node:child_process';") + .replace('const argv = process.argv.slice(2);', "const portableSpawn = (command, args, options = {}) => nativeSpawnSync(command, args, { ...options, shell: true });\nconst argv = process.argv.slice(2);") + .replaceAll('spawnSync(', 'portableSpawn(') + writeFileSync(installerPath, installer) + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + for (const item of manifest.packages) { + await download(`${base}/${item.filename}`, join(releaseDir, item.filename)) + } + + mkdirSync(output, { recursive: true }) + const shim = join(output, process.platform === 'win32' ? 'dsh-runtime.cmd' : 'dsh-runtime') + if (process.platform === 'win32') { + writeFileSync(shim, `@echo off\r\nnpx --yes @deepseek-ai/dsh@${DSH_VERSION} %*\r\n`) + } else { + writeFileSync(shim, `#!/bin/sh\nexec npx --yes @deepseek-ai/dsh@${DSH_VERSION} \"$@\"\n`) + chmodSync(shim, 0o755) + } + run(process.execPath, [installerPath, '--directory', releaseDir, '--dsh', shim], { + env: { ...process.env, DSH_HOME: output }, + }) +} + +async function prepareNodeExecutable(path) { + mkdirSync(dirname(path), { recursive: true }) + if (process.platform !== 'darwin') { + cpSync(process.execPath, path) + if (process.platform !== 'win32') chmodSync(path, 0o755) + return + } + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64' + const cache = join(desktopRoot, 'build', '.workdsh-node') + const archive = join(cache, `node-v${process.versions.node}-darwin-${otherArch}.tar.gz`) + const extracted = join(cache, `node-v${process.versions.node}-darwin-${otherArch}`, 'bin', 'node') + mkdirSync(cache, { recursive: true }) + await download(`https://nodejs.org/dist/v${process.versions.node}/node-v${process.versions.node}-darwin-${otherArch}.tar.gz`, archive) + if (!existsSync(extracted)) run('/usr/bin/tar', ['-xzf', archive, '-C', cache]) + run('/usr/bin/lipo', ['-create', process.execPath, extracted, '-output', path]) + chmodSync(path, 0o755) +} + +const candidates = [ + process.env.WORKDSH_BUNDLED_PROFILE, + process.env.WORKDSH_DSH_HOME && join(process.env.WORKDSH_DSH_HOME, 'profiles', 'workdsh'), + '/tmp/workdsh-desktop-profile.IbF6US/profiles/workdsh', +].filter(Boolean) +const source = candidates.find(candidate => existsSync(join(candidate, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))) + +if (source) { + rmSync(output, { recursive: true, force: true }) + mkdirSync(dirname(destination), { recursive: true }) + // electron-builder intentionally does not follow extra-resource directory links. + // Clone into a real directory; APFS copy-on-write keeps local preparation fast. + if (process.platform === 'darwin') run('/bin/cp', ['-cR', resolve(source), destination]) + else cpSync(resolve(source), destination, { recursive: true, dereference: true }) +} else if (!existsSync(cli)) { + await installReleasedProfile() +} + +if (!existsSync(cli)) throw new Error(`Failed to prepare WorkDSH runtime at ${destination}`) +cpSync(join(destination, 'package.json'), join(output, 'profile-package.json')) +const nodeExecutable = join(output, 'node', process.platform === 'win32' ? 'node.exe' : 'node') +await prepareNodeExecutable(nodeExecutable) +console.log(`Prepared WorkDSH ${WORKDSH_VERSION} runtime at ${output}`) diff --git a/dsh-plugin-desktop-beta/src/workdsh-main.ts b/dsh-plugin-desktop-beta/src/workdsh-main.ts new file mode 100644 index 0000000000..910480c201 --- /dev/null +++ b/dsh-plugin-desktop-beta/src/workdsh-main.ts @@ -0,0 +1,188 @@ +/** Minimal Electron carrier for the bundled WorkDSH release profile. */ + +import { app, BrowserWindow, shell } from 'electron' +import { spawn, type ChildProcess } from 'node:child_process' +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PROFILE_NAME = 'workdsh' +const RUNTIME_VERSION = '0.1.0-alpha.8' +const READY_PATTERN = /dsh web:\s+(http:\/\/127\.0\.0\.1:\d+\/?\?token=[^\s]+)/u + +let runtime: ChildProcess | undefined +let window: BrowserWindow | undefined +let quitting = false + +function bundledProfileDirectory(): string { + const overridden = process.env.WORKDSH_BUNDLED_PROFILE + if (overridden !== undefined && overridden.length > 0) return overridden + return join(process.resourcesPath, 'workdsh-runtime', 'profiles', PROFILE_NAME) +} + +function runtimeHome(): string { + const overridden = process.env.WORKDSH_DSH_HOME + if (overridden !== undefined && overridden.length > 0) return overridden + return join(app.getPath('userData'), 'dsh-home') +} + +function bundledNodeExecutable(): string { + const overridden = process.env.WORKDSH_NODE_EXECUTABLE + if (overridden !== undefined && overridden.length > 0) return overridden + return join( + process.resourcesPath, + 'workdsh-runtime', + 'node', + process.platform === 'win32' ? 'node.exe' : 'node', + ) +} + +function materializeRuntimeProfile(home: string): string { + const source = bundledProfileDirectory() + const target = join(home, 'profiles', PROFILE_NAME) + const marker = join(target, '.workdsh-desktop-runtime') + const sourceModules = join(source, 'node_modules') + const targetModules = join(target, 'node_modules') + if (!existsSync(sourceModules)) { + throw new Error(`Bundled WorkDSH runtime is incomplete: ${sourceModules}`) + } + mkdirSync(target, { recursive: true }) + for (const name of ['package.json', 'cordis.yml', 'cordis.patch.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { + const from = join(source, name) + if (existsSync(from)) cpSync(from, join(target, name), { force: true }) + } + const installedVersion = existsSync(marker) ? readFileSync(marker, 'utf8').trim() : undefined + if (existsSync(targetModules) && installedVersion !== RUNTIME_VERSION) { + const stat = lstatSync(targetModules) + if (!stat.isSymbolicLink()) { + throw new Error( + `WorkDSH cannot replace the existing unmanaged runtime at ${targetModules}. ` + + 'Move that directory, then reopen WorkDSH.', + ) + } + readlinkSync(targetModules) + unlinkSync(targetModules) + } + if (!existsSync(targetModules)) { + symlinkSync(sourceModules, targetModules, process.platform === 'win32' ? 'junction' : 'dir') + } + writeFileSync(marker, `${RUNTIME_VERSION}\n`, 'utf8') + return target +} + +function openWindow(url: string): void { + const icon = fileURLToPath(new URL('../build/app-icon.png', import.meta.url)) + window = new BrowserWindow({ + width: 1440, + height: 960, + minWidth: 960, + minHeight: 640, + title: 'WorkDSH', + icon, + backgroundColor: '#111113', + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + window.on('page-title-updated', event => { + event.preventDefault() + window?.setTitle('WorkDSH') + }) + window.webContents.setWindowOpenHandler(({ url: target }) => { + if (target.startsWith('https://') || target.startsWith('http://')) void shell.openExternal(target) + return { action: 'deny' } + }) + void window.loadURL(url).then(() => window?.show()) + window.on('closed', () => { window = undefined }) +} + +function startRuntime(home: string, profileDir: string): void { + const cli = join(profileDir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') + if (!existsSync(cli)) throw new Error(`Bundled WorkDSH launcher is missing: ${cli}`) + const executable = bundledNodeExecutable() + if (!existsSync(executable)) throw new Error(`Bundled Node.js runtime is missing: ${executable}`) + const child = spawn(executable, [cli, '--profile', PROFILE_NAME, '--no-open'], { + env: { + ...process.env, + DSH_HOME: home, + ELECTRON_RUN_AS_NODE: undefined, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + runtime = child + let ready = false + let output = '' + const consume = (chunk: Buffer): void => { + const text = chunk.toString('utf8') + process.stderr.write(text) + if (ready) return + output = `${output}${text}`.slice(-16_384) + const match = READY_PATTERN.exec(output) + if (match?.[1] !== undefined) { + ready = true + openWindow(match[1]) + } + } + child.stdout.on('data', consume) + child.stderr.on('data', chunk => process.stderr.write(chunk)) + child.once('error', cause => { + if (!quitting) throw cause + }) + child.once('exit', code => { + runtime = undefined + if (!quitting && code !== 0) app.quit() + }) +} + +function stopRuntime(): void { + quitting = true + if (runtime !== undefined && runtime.exitCode === null) runtime.kill('SIGTERM') + runtime = undefined +} + +app.setName('WorkDSH') +if (!app.requestSingleInstanceLock()) { + app.quit() +} else { + app.on('second-instance', () => { + if (window === undefined) return + if (window.isMinimized()) window.restore() + window.focus() + }) + app.on('before-quit', stopRuntime) + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + app.on('activate', () => { + if (window === undefined) { + // A healthy runtime always owns a window. Relaunching keeps the profile + // lifecycle simple after the last macOS window was closed. + app.relaunch() + app.quit() + } + }) + void app.whenReady().then(() => { + const home = runtimeHome() + const profile = process.env.WORKDSH_DSH_HOME === home + ? join(home, 'profiles', PROFILE_NAME) + : materializeRuntimeProfile(home) + startRuntime(home, profile) + }).catch(cause => { + process.stderr.write(`WorkDSH failed to start: ${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}\n`) + app.quit() + }) +} diff --git a/dsh-plugin-desktop-beta/tests/package.spec.ts b/dsh-plugin-desktop-beta/tests/package.spec.ts index d19f73cd55..82a92c91b4 100644 --- a/dsh-plugin-desktop-beta/tests/package.spec.ts +++ b/dsh-plugin-desktop-beta/tests/package.spec.ts @@ -912,7 +912,7 @@ describe('published package surface', () => { expect(manifest.scripts?.['prepare:electron-native']).toBe('node scripts/prepare-fs-ext.ts') expect(manifest.scripts?.dev).toContain('yarn run prepare:electron-native') expect(manifest.scripts?.['package:dir']) - .toBe('yarn run build && yarn run prepare:electron-native && node scripts/package-dir.mjs') + .toBe('yarn run build && yarn run prepare:electron-native && yarn run prepare:workdsh-runtime && node scripts/package-dir.mjs') expect(packageDir).toContain("CSC_IDENTITY_AUTO_DISCOVERY: 'false'") expect(packageDir).toContain("'--config.forceCodeSigning=false'") expect(packageDir).toContain("'--config.mac.identity=null'") @@ -921,10 +921,10 @@ describe('published package surface', () => { expect(packageDir).toContain("require.resolve('electron/package.json')") expect(packageDir).toContain('--config.electronDist=') expect(packageDir).toContain('electronBuilderEnvironment') - expect(manifest.scripts?.['dist:mac']).toBe('node scripts/release-mac.ts') - expect(manifest.scripts?.['dist:mac-smoke']).toBe('node scripts/package-mac.ts') - expect(manifest.scripts?.['dist:win']).toBe('node scripts/package-win.ts') - expect(manifest.scripts?.['dist:win-portable']).toBe('node scripts/package-win-portable.ts') + expect(manifest.scripts?.['dist:mac']).toBe('yarn run prepare:workdsh-runtime && node scripts/release-mac.ts') + expect(manifest.scripts?.['dist:mac-smoke']).toBe('yarn run prepare:workdsh-runtime && node scripts/package-mac.ts') + expect(manifest.scripts?.['dist:win']).toBe('yarn run prepare:workdsh-runtime && node scripts/package-win.ts') + expect(manifest.scripts?.['dist:win-portable']).toBe('yarn run prepare:workdsh-runtime && node scripts/package-win-portable.ts') expect(manifest.scripts?.['check:win-package']).toContain('yarn workspace dsh-community-market build') expect(manifest.scripts?.['check:win-package']).toContain('yarn run build') expect(manifest.scripts?.['check:win-package']).toContain('yarn run typecheck') diff --git a/dsh-plugin-desktop-beta/tsdown.config.ts b/dsh-plugin-desktop-beta/tsdown.config.ts index b424957424..d0aea1493f 100644 --- a/dsh-plugin-desktop-beta/tsdown.config.ts +++ b/dsh-plugin-desktop-beta/tsdown.config.ts @@ -32,6 +32,7 @@ export default defineConfig([ 'windows-pwsh-sandbox': 'src/windows-pwsh-sandbox.ts', 'windows-acl-runner': 'src/windows-acl-runner.ts', main: 'src/main.ts', + 'workdsh-main': 'src/workdsh-main.ts', }, outDir: 'lib', format: 'esm', diff --git a/dsh-plugin-desktop/.gitignore b/dsh-plugin-desktop/.gitignore index b7d4007834..512bdde121 100644 --- a/dsh-plugin-desktop/.gitignore +++ b/dsh-plugin-desktop/.gitignore @@ -1,3 +1,7 @@ dist/ /lib/ node_modules/ + +build/workdsh-runtime +build/.workdsh-release-* +build/.workdsh-node diff --git a/dsh-plugin-desktop/package.json b/dsh-plugin-desktop/package.json index e728b7f191..d433a9e09b 100644 --- a/dsh-plugin-desktop/package.json +++ b/dsh-plugin-desktop/package.json @@ -12,7 +12,7 @@ "directory": "dsh-plugin-desktop" }, "type": "module", - "main": "lib/main.js", + "main": "lib/workdsh-main.js", "types": "lib/types/index.d.ts", "bin": { "dsh-desktop": "lib/bin.js", @@ -129,12 +129,14 @@ "check:mac-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package.spec.ts tests/package-mac.spec.ts tests/verify-mac-smoke.spec.ts tests/verify-packaged-runtime.spec.ts tests/mac-universal.spec.ts && yarn run verify:closure", "start": "node lib/bin.js", "dev": "yarn run build && node lib/bin.js", - "package:dir": "yarn run build && node scripts/package-dir.mjs", - "dist:mac": "node scripts/release-mac.ts", - "dist:mac-smoke": "node scripts/package-mac.ts", - "dist:win": "node scripts/package-win.ts", - "dist:win-portable": "node scripts/package-win-portable.ts", - "prepack": "yarn run check" + "package:dir": "yarn run build && yarn run prepare:electron-native && yarn run prepare:workdsh-runtime && node scripts/package-dir.mjs", + "dist:mac": "yarn run prepare:workdsh-runtime && node scripts/release-mac.ts", + "dist:mac-smoke": "yarn run prepare:workdsh-runtime && node scripts/package-mac.ts", + "dist:win": "yarn run prepare:workdsh-runtime && node scripts/package-win.ts", + "dist:win-portable": "yarn run prepare:workdsh-runtime && node scripts/package-win-portable.ts", + "prepack": "yarn run check", + "prepare:electron-native": "node scripts/prepare-fs-ext.ts", + "prepare:workdsh-runtime": "node scripts/prepare-workdsh-runtime.mjs" }, "dependencies": { "@deepseek-ai/cordis": "4.0.2", @@ -430,6 +432,16 @@ "dir" ], "icon": "build/app-icon.png" - } + }, + "extraResources": [ + { + "from": "build/workdsh-runtime", + "to": "workdsh-runtime", + "filter": [ + "profiles/workdsh/**", + "node/**" + ] + } + ] } } diff --git a/dsh-plugin-desktop/scripts/prepare-workdsh-runtime.mjs b/dsh-plugin-desktop/scripts/prepare-workdsh-runtime.mjs new file mode 100644 index 0000000000..e7cde8e8e4 --- /dev/null +++ b/dsh-plugin-desktop/scripts/prepare-workdsh-runtime.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WORKDSH_VERSION = '0.1.0-alpha.8' +const DSH_VERSION = '0.1.7-alpha.1' +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const output = join(desktopRoot, 'build', 'workdsh-runtime') +const destination = join(output, 'profiles', 'workdsh') +const cli = join(destination, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`${command} exited with ${result.status}`) +} + +async function download(url, path) { + if (existsSync(path)) return + const response = await fetch(url, { redirect: 'follow' }) + if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`) + writeFileSync(path, Buffer.from(await response.arrayBuffer())) +} + +async function installReleasedProfile() { + const releaseDir = join(desktopRoot, 'build', `.workdsh-release-${WORKDSH_VERSION}`) + mkdirSync(releaseDir, { recursive: true }) + const base = `https://github.com/techflag/workdsh/releases/download/v${WORKDSH_VERSION}` + const manifestPath = join(releaseDir, 'release-manifest.json') + await download(`${base}/release-manifest.json`, manifestPath) + const installerPath = join(releaseDir, 'install-workdsh.mjs') + await download(`${base}/install-workdsh.mjs`, installerPath) + if (process.platform === 'win32') { + let installer = readFileSync(installerPath, 'utf8') + installer = installer + .replace("import { spawnSync } from 'node:child_process';", "import { spawnSync as nativeSpawnSync } from 'node:child_process';") + .replace('const argv = process.argv.slice(2);', "const portableSpawn = (command, args, options = {}) => nativeSpawnSync(command, args, { ...options, shell: true });\nconst argv = process.argv.slice(2);") + .replaceAll('spawnSync(', 'portableSpawn(') + writeFileSync(installerPath, installer) + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + for (const item of manifest.packages) { + await download(`${base}/${item.filename}`, join(releaseDir, item.filename)) + } + + mkdirSync(output, { recursive: true }) + const shim = join(output, process.platform === 'win32' ? 'dsh-runtime.cmd' : 'dsh-runtime') + if (process.platform === 'win32') { + writeFileSync(shim, `@echo off\r\nnpx --yes @deepseek-ai/dsh@${DSH_VERSION} %*\r\n`) + } else { + writeFileSync(shim, `#!/bin/sh\nexec npx --yes @deepseek-ai/dsh@${DSH_VERSION} \"$@\"\n`) + chmodSync(shim, 0o755) + } + run(process.execPath, [installerPath, '--directory', releaseDir, '--dsh', shim], { + env: { ...process.env, DSH_HOME: output }, + }) +} + +async function prepareNodeExecutable(path) { + mkdirSync(dirname(path), { recursive: true }) + if (process.platform !== 'darwin') { + cpSync(process.execPath, path) + if (process.platform !== 'win32') chmodSync(path, 0o755) + return + } + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64' + const cache = join(desktopRoot, 'build', '.workdsh-node') + const archive = join(cache, `node-v${process.versions.node}-darwin-${otherArch}.tar.gz`) + const extracted = join(cache, `node-v${process.versions.node}-darwin-${otherArch}`, 'bin', 'node') + mkdirSync(cache, { recursive: true }) + await download(`https://nodejs.org/dist/v${process.versions.node}/node-v${process.versions.node}-darwin-${otherArch}.tar.gz`, archive) + if (!existsSync(extracted)) run('/usr/bin/tar', ['-xzf', archive, '-C', cache]) + run('/usr/bin/lipo', ['-create', process.execPath, extracted, '-output', path]) + chmodSync(path, 0o755) +} + +const candidates = [ + process.env.WORKDSH_BUNDLED_PROFILE, + process.env.WORKDSH_DSH_HOME && join(process.env.WORKDSH_DSH_HOME, 'profiles', 'workdsh'), + '/tmp/workdsh-desktop-profile.IbF6US/profiles/workdsh', +].filter(Boolean) +const source = candidates.find(candidate => existsSync(join(candidate, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))) + +if (source) { + rmSync(output, { recursive: true, force: true }) + mkdirSync(dirname(destination), { recursive: true }) + // electron-builder intentionally does not follow extra-resource directory links. + // Clone into a real directory; APFS copy-on-write keeps local preparation fast. + if (process.platform === 'darwin') run('/bin/cp', ['-cR', resolve(source), destination]) + else cpSync(resolve(source), destination, { recursive: true, dereference: true }) +} else if (!existsSync(cli)) { + await installReleasedProfile() +} + +if (!existsSync(cli)) throw new Error(`Failed to prepare WorkDSH runtime at ${destination}`) +cpSync(join(destination, 'package.json'), join(output, 'profile-package.json')) +const nodeExecutable = join(output, 'node', process.platform === 'win32' ? 'node.exe' : 'node') +await prepareNodeExecutable(nodeExecutable) +console.log(`Prepared WorkDSH ${WORKDSH_VERSION} runtime at ${output}`) diff --git a/dsh-plugin-desktop/src/workdsh-main.ts b/dsh-plugin-desktop/src/workdsh-main.ts new file mode 100644 index 0000000000..910480c201 --- /dev/null +++ b/dsh-plugin-desktop/src/workdsh-main.ts @@ -0,0 +1,188 @@ +/** Minimal Electron carrier for the bundled WorkDSH release profile. */ + +import { app, BrowserWindow, shell } from 'electron' +import { spawn, type ChildProcess } from 'node:child_process' +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PROFILE_NAME = 'workdsh' +const RUNTIME_VERSION = '0.1.0-alpha.8' +const READY_PATTERN = /dsh web:\s+(http:\/\/127\.0\.0\.1:\d+\/?\?token=[^\s]+)/u + +let runtime: ChildProcess | undefined +let window: BrowserWindow | undefined +let quitting = false + +function bundledProfileDirectory(): string { + const overridden = process.env.WORKDSH_BUNDLED_PROFILE + if (overridden !== undefined && overridden.length > 0) return overridden + return join(process.resourcesPath, 'workdsh-runtime', 'profiles', PROFILE_NAME) +} + +function runtimeHome(): string { + const overridden = process.env.WORKDSH_DSH_HOME + if (overridden !== undefined && overridden.length > 0) return overridden + return join(app.getPath('userData'), 'dsh-home') +} + +function bundledNodeExecutable(): string { + const overridden = process.env.WORKDSH_NODE_EXECUTABLE + if (overridden !== undefined && overridden.length > 0) return overridden + return join( + process.resourcesPath, + 'workdsh-runtime', + 'node', + process.platform === 'win32' ? 'node.exe' : 'node', + ) +} + +function materializeRuntimeProfile(home: string): string { + const source = bundledProfileDirectory() + const target = join(home, 'profiles', PROFILE_NAME) + const marker = join(target, '.workdsh-desktop-runtime') + const sourceModules = join(source, 'node_modules') + const targetModules = join(target, 'node_modules') + if (!existsSync(sourceModules)) { + throw new Error(`Bundled WorkDSH runtime is incomplete: ${sourceModules}`) + } + mkdirSync(target, { recursive: true }) + for (const name of ['package.json', 'cordis.yml', 'cordis.patch.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { + const from = join(source, name) + if (existsSync(from)) cpSync(from, join(target, name), { force: true }) + } + const installedVersion = existsSync(marker) ? readFileSync(marker, 'utf8').trim() : undefined + if (existsSync(targetModules) && installedVersion !== RUNTIME_VERSION) { + const stat = lstatSync(targetModules) + if (!stat.isSymbolicLink()) { + throw new Error( + `WorkDSH cannot replace the existing unmanaged runtime at ${targetModules}. ` + + 'Move that directory, then reopen WorkDSH.', + ) + } + readlinkSync(targetModules) + unlinkSync(targetModules) + } + if (!existsSync(targetModules)) { + symlinkSync(sourceModules, targetModules, process.platform === 'win32' ? 'junction' : 'dir') + } + writeFileSync(marker, `${RUNTIME_VERSION}\n`, 'utf8') + return target +} + +function openWindow(url: string): void { + const icon = fileURLToPath(new URL('../build/app-icon.png', import.meta.url)) + window = new BrowserWindow({ + width: 1440, + height: 960, + minWidth: 960, + minHeight: 640, + title: 'WorkDSH', + icon, + backgroundColor: '#111113', + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + window.on('page-title-updated', event => { + event.preventDefault() + window?.setTitle('WorkDSH') + }) + window.webContents.setWindowOpenHandler(({ url: target }) => { + if (target.startsWith('https://') || target.startsWith('http://')) void shell.openExternal(target) + return { action: 'deny' } + }) + void window.loadURL(url).then(() => window?.show()) + window.on('closed', () => { window = undefined }) +} + +function startRuntime(home: string, profileDir: string): void { + const cli = join(profileDir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') + if (!existsSync(cli)) throw new Error(`Bundled WorkDSH launcher is missing: ${cli}`) + const executable = bundledNodeExecutable() + if (!existsSync(executable)) throw new Error(`Bundled Node.js runtime is missing: ${executable}`) + const child = spawn(executable, [cli, '--profile', PROFILE_NAME, '--no-open'], { + env: { + ...process.env, + DSH_HOME: home, + ELECTRON_RUN_AS_NODE: undefined, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + runtime = child + let ready = false + let output = '' + const consume = (chunk: Buffer): void => { + const text = chunk.toString('utf8') + process.stderr.write(text) + if (ready) return + output = `${output}${text}`.slice(-16_384) + const match = READY_PATTERN.exec(output) + if (match?.[1] !== undefined) { + ready = true + openWindow(match[1]) + } + } + child.stdout.on('data', consume) + child.stderr.on('data', chunk => process.stderr.write(chunk)) + child.once('error', cause => { + if (!quitting) throw cause + }) + child.once('exit', code => { + runtime = undefined + if (!quitting && code !== 0) app.quit() + }) +} + +function stopRuntime(): void { + quitting = true + if (runtime !== undefined && runtime.exitCode === null) runtime.kill('SIGTERM') + runtime = undefined +} + +app.setName('WorkDSH') +if (!app.requestSingleInstanceLock()) { + app.quit() +} else { + app.on('second-instance', () => { + if (window === undefined) return + if (window.isMinimized()) window.restore() + window.focus() + }) + app.on('before-quit', stopRuntime) + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + app.on('activate', () => { + if (window === undefined) { + // A healthy runtime always owns a window. Relaunching keeps the profile + // lifecycle simple after the last macOS window was closed. + app.relaunch() + app.quit() + } + }) + void app.whenReady().then(() => { + const home = runtimeHome() + const profile = process.env.WORKDSH_DSH_HOME === home + ? join(home, 'profiles', PROFILE_NAME) + : materializeRuntimeProfile(home) + startRuntime(home, profile) + }).catch(cause => { + process.stderr.write(`WorkDSH failed to start: ${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}\n`) + app.quit() + }) +} diff --git a/dsh-plugin-desktop/tests/package.spec.ts b/dsh-plugin-desktop/tests/package.spec.ts index be1e340d13..c52634c9c8 100644 --- a/dsh-plugin-desktop/tests/package.spec.ts +++ b/dsh-plugin-desktop/tests/package.spec.ts @@ -831,16 +831,17 @@ describe('published package surface', () => { expect(manifest.scripts?.build).toContain('node scripts/generate-windows-app-icon.mjs') expect(manifest.scripts?.build).toContain('node scripts/generate-mac-app-icon.mjs') - expect(manifest.scripts?.['package:dir']).toBe('yarn run build && node scripts/package-dir.mjs') + expect(manifest.scripts?.['package:dir']) + .toBe('yarn run build && yarn run prepare:electron-native && yarn run prepare:workdsh-runtime && node scripts/package-dir.mjs') expect(packageDir).toContain("CSC_IDENTITY_AUTO_DISCOVERY: 'false'") expect(packageDir).toContain("'--config.forceCodeSigning=false'") expect(packageDir).toContain("'--config.mac.identity=null'") expect(packageDir).toContain("'--config.mac.notarize=false'") expect(packageDir).toContain("'--config.win.signExecutable=false'") - expect(manifest.scripts?.['dist:mac']).toBe('node scripts/release-mac.ts') - expect(manifest.scripts?.['dist:mac-smoke']).toBe('node scripts/package-mac.ts') - expect(manifest.scripts?.['dist:win']).toBe('node scripts/package-win.ts') - expect(manifest.scripts?.['dist:win-portable']).toBe('node scripts/package-win-portable.ts') + expect(manifest.scripts?.['dist:mac']).toBe('yarn run prepare:workdsh-runtime && node scripts/release-mac.ts') + expect(manifest.scripts?.['dist:mac-smoke']).toBe('yarn run prepare:workdsh-runtime && node scripts/package-mac.ts') + expect(manifest.scripts?.['dist:win']).toBe('yarn run prepare:workdsh-runtime && node scripts/package-win.ts') + expect(manifest.scripts?.['dist:win-portable']).toBe('yarn run prepare:workdsh-runtime && node scripts/package-win-portable.ts') expect(manifest.scripts?.['check:win-package']).toContain('yarn workspace dsh-community-market build') expect(manifest.scripts?.['check:win-package']).toContain('yarn run build') expect(manifest.scripts?.['check:win-package']).toContain('yarn run typecheck') diff --git a/dsh-plugin-desktop/tsdown.config.ts b/dsh-plugin-desktop/tsdown.config.ts index 37a34c8f52..d9e536efa4 100644 --- a/dsh-plugin-desktop/tsdown.config.ts +++ b/dsh-plugin-desktop/tsdown.config.ts @@ -32,6 +32,7 @@ export default defineConfig([ 'windows-pwsh-sandbox': 'src/windows-pwsh-sandbox.ts', 'windows-acl-runner': 'src/windows-acl-runner.ts', main: 'src/main.ts', + 'workdsh-main': 'src/workdsh-main.ts', }, outDir: 'lib', format: 'esm',