mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-09 01:47:06 +08:00
fix: bundle WorkDSH runtime in desktop apps
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
dist/
|
||||
/lib/
|
||||
node_modules/
|
||||
build/workdsh-runtime
|
||||
build/.workdsh-release-*
|
||||
build/.workdsh-node
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
dist/
|
||||
/lib/
|
||||
node_modules/
|
||||
|
||||
build/workdsh-runtime
|
||||
build/.workdsh-release-*
|
||||
build/.workdsh-node
|
||||
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user