Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ apps/desktop/build/official-release-cache/

# Local package archives are build outputs.
/packages/plugins/*/*.tgz

# Development desktop data written by `yarn dev`, and local agent notes.
/apps/desktop/build/dev-user-data/
/.workbuddy/
Binary file modified apps/desktop/build/app-icon.ico
Binary file not shown.
70 changes: 70 additions & 0 deletions apps/desktop/scripts/prepare-workdsh-profile.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/** Keep the default WorkDSH Profile candidate in sync with the current owned sources. */
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PACKAGE_DIRECTORIES, RELEASE_PACKAGES } from './workdsh-package-boundary.mjs'
import { verifyBuiltPackageExports } from './verify-profile-release.mjs'
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const root = resolve(desktopRoot, '../..')
const webRoot = join(root, 'apps/web')
const release = join(desktopRoot, 'build/workdsh-profile-release')
const manifestFile = join(release, 'release-manifest.json')
const stampFile = join(release, 'source-fingerprint.txt')
const corepack = process.platform === 'win32' ? 'corepack.cmd' : 'corepack'
if (process.env.WORKDSH_RELEASE_DIRECTORY) {
console.log('WORKDSH_RELEASE_DIRECTORY selects an explicit Profile candidate; packing stays manual')
process.exit(0)
}
function run(command, args, cwd) {
const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' && command === corepack })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`)
}
function hashDirectory(directory, hash) {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name === 'node_modules' || entry.name === '.DS_Store') continue
const path = join(directory, entry.name)
if (entry.isDirectory()) { hashDirectory(path, hash); continue }
if (!entry.isFile()) continue
hash.update(relative(root, path).split(sep).join('/'))
hash.update(readFileSync(path))
}
}
function hashFile(path, hash) {
hash.update(relative(root, path).split(sep).join('/'))
hash.update(readFileSync(path))
}
/** Fingerprint everything the packed Profile candidate consumes, independent of git. */
function sourceFingerprint() {
const hash = createHash('sha256')
for (const name of RELEASE_PACKAGES) hashDirectory(resolve(webRoot, PACKAGE_DIRECTORIES[name]), hash)
hashFile(join(root, 'upstream.json'), hash)
hashFile(join(webRoot, 'package.json'), hash)
hashFile(join(webRoot, 'pnpm-lock.yaml'), hash)
hashFile(join(webRoot, 'scripts/official-web-clients.mjs'), hash)
return hash.digest('hex')
}
function builtExportsPresent() {
try {
for (const name of RELEASE_PACKAGES) {
const directory = resolve(webRoot, PACKAGE_DIRECTORIES[name])
verifyBuiltPackageExports(JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')), directory)
}
return true
} catch { return false }
}
const fingerprint = sourceFingerprint()
const stamped = existsSync(stampFile) ? readFileSync(stampFile, 'utf8').trim() : null
if (stamped === fingerprint && existsSync(manifestFile)) {
console.log(`WorkDSH Profile candidate at ${release} already matches the current sources`)
process.exit(0)
}
run(corepack, ['pnpm', 'install', '--frozen-lockfile'], webRoot)
if (stamped !== fingerprint || !builtExportsPresent()) run(corepack, ['pnpm', 'build'], webRoot)
// Candidate archives are immutable; replace the default directory atomically per pack run.
rmSync(release, { recursive: true, force: true })
run(process.execPath, [join(desktopRoot, 'scripts/pack-workdsh-profile.mjs'), webRoot, release])
writeFileSync(stampFile, fingerprint + '\n')
14 changes: 13 additions & 1 deletion apps/desktop/scripts/start-workdsh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@ for (const path of [join(root, 'lib', 'workdsh-main.js'), profile, node, primary

const require = createRequire(import.meta.url)
const electron = require('electron')
// The single-instance lock is keyed by userData. Keep development data apart
// from an installed WorkDSH so both can run side by side, and so a development
// Profile install never rewrites the installed application's Home.
const userData = process.env.WORKDSH_DESKTOP_USER_DATA ?? join(root, 'build', 'dev-user-data')
console.log(`WorkDSH development data: ${userData}`)
const startedAt = Date.now()
const child = spawn(electron, [root], {
cwd: root,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: undefined,
WORKDSH_DESKTOP_USER_DATA: userData,
WORKDSH_BUNDLED_PROFILE: profile,
WORKDSH_NODE_EXECUTABLE: node,
WORKDSH_PRIMARY_RUNTIME: primary,
Expand All @@ -33,4 +40,9 @@ child.on('error', error => {
console.error(error)
process.exitCode = 1
})
child.on('exit', code => { process.exitCode = code ?? 1 })
child.on('exit', code => {
process.exitCode = code ?? 1
if (code === 0 && Date.now() - startedAt < 5000) {
console.error(`WorkDSH exited immediately: another instance already owns ${userData}. Close it before running yarn dev again.`)
}
})
73 changes: 51 additions & 22 deletions apps/desktop/src/owned-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function selectOwnedWindowsProcesses(rows: WindowsProcessIdentity[], owne
return [...generations.values()].filter(row => current.get(row.pid)?.createdAt === row.createdAt)
}

async function windowsCommand(script: string, timeoutMs: number): Promise<string> {
async function windowsCommandOnce(script: string, timeoutMs: number): Promise<string> {
const executable = process.env.SystemRoot ? join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe') : 'powershell.exe'
return new Promise((resolve, reject) => {
const task = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
Expand All @@ -60,6 +60,26 @@ async function windowsCommand(script: string, timeoutMs: number): Promise<string
task.once('close', code => finish(code === 0 ? undefined : new Error(`Windows 本机进程清理查询失败 (${code})${/access|denied|unauthorized/iu.test(error) ? ',请核对权限' : ''}`)))
})
}
/** Run a WMI/powershell command, retrying on a transient query timeout (cold WMI
* start on first launch). `retries` is the number of additional attempts. */
async function windowsCommand(script: string, timeoutMs: number, retries = 1): Promise<string> {
let lastError: unknown
for (let attempt = 0; attempt <= retries; attempt++) {
try { return await windowsCommandOnce(script, timeoutMs) }
catch (error) {
lastError = error
// Only retry genuine transient timeouts; surface auth/permission/parse errors.
if (error instanceof Error && error.message.includes('Windows 本机进程清理查询超时') && attempt < retries) continue
throw error
}
}
throw lastError instanceof Error ? lastError : new Error('Windows 本机进程清理查询超时')
}
/** Native fallback: a single direct taskkill /T /F on the owned root, bypassing
* the WMI snapshot entirely. Used when the query path keeps timing out. */
async function windowsKillTreeDirect(pid: number, timeoutMs: number): Promise<void> {
await windowsCommand(`$ErrorActionPreference='Stop'; & "$env:SystemRoot\\System32\\taskkill.exe" /PID ${pid} /T /F`, timeoutMs, 0)
}
async function windowsSnapshot(pids: number[], timeoutMs: number): Promise<WindowsProcessIdentity[]> {
const filter = [...new Set(pids)].map(pid => `(ProcessId = ${pid} OR ParentProcessId = ${pid})`).join(' OR ')
const output = await windowsCommand(`$ErrorActionPreference='Stop'; $rows=@(Get-CimInstance Win32_Process -Filter '${filter}' -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { [pscustomobject]@{pid=[int]$_.ProcessId;ppid=[int]$_.ParentProcessId;createdAt=([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()} }); ConvertTo-Json -InputObject $rows -Compress`, timeoutMs)
Expand All @@ -80,30 +100,39 @@ function processMissing(pid: number): boolean {
async function cleanWindowsTree(child: ChildProcess): Promise<void> {
const state = ownership.get(child)
if (!state) throw new Error('Windows 本机进程缺少所有权记录,未停止未知进程')
const deadline = Date.now() + 12_000
const budget = (): number => { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Windows 本机进程树清理超时'); return Math.min(4000, remaining) }
for (let pass = 0; pass < 8; pass++) {
const queried = new Set([state.pid, ...state.known.map(row => row.pid)])
const rows = await windowsSnapshot([...queried], budget())
const selected = selectOwnedWindowsProcesses(rows, state, state.known)
for (const row of selected) if (!state.known.some(known => known.pid === row.pid && known.createdAt === row.createdAt)) state.known.push(row)
if (state.known.length > 128) throw new Error('Windows 本机进程树超出安全清理范围')
if (!selected.length) {
const current = rows.find(row => row.pid === state.pid)
if (current && (current.createdAt < state.startedAt || current.createdAt > state.registeredAt)) return // New generation, never ours.
if (processMissing(state.pid)) return
throw new Error('Windows 本机进程树清理未确认')
}
// Discover nested descendants before removing their only visible parent.
if (selected.some(row => !queried.has(row.pid))) continue
for (const row of [...selected].sort((a, b) => b.createdAt - a.createdAt)) {
const result = await windowsTaskkill(row, budget())
if (result.status === 'taskkill' && result.code !== 0 && !(result.code === 128 && processMissing(row.pid))) {
throw new Error(`Windows 本机进程树清理失败 (${result.code})${/access|denied|权限|拒绝/iu.test(result.output ?? '') ? ',操作被拒绝' : ''},未确认停止`)
const deadline = Date.now() + 45_000
const budget = (): number => { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Windows 本机进程树清理超时'); return Math.min(15_000, remaining) }
try {
for (let pass = 0; pass < 8; pass++) {
const queried = new Set([state.pid, ...state.known.map(row => row.pid)])
const rows = await windowsSnapshot([...queried], budget())
const selected = selectOwnedWindowsProcesses(rows, state, state.known)
for (const row of selected) if (!state.known.some(known => known.pid === row.pid && known.createdAt === row.createdAt)) state.known.push(row)
if (state.known.length > 128) throw new Error('Windows 本机进程树超出安全清理范围')
if (!selected.length) {
const current = rows.find(row => row.pid === state.pid)
if (current && (current.createdAt < state.startedAt || current.createdAt > state.registeredAt)) return // New generation, never ours.
if (processMissing(state.pid)) return
throw new Error('Windows 本机进程树清理未确认')
}
// Discover nested descendants before removing their only visible parent.
if (selected.some(row => !queried.has(row.pid))) continue
for (const row of [...selected].sort((a, b) => b.createdAt - a.createdAt)) {
const result = await windowsTaskkill(row, budget())
if (result.status === 'taskkill' && result.code !== 0 && !(result.code === 128 && processMissing(row.pid))) {
throw new Error(`Windows 本机进程树清理失败 (${result.code})${/access|denied|权限|拒绝/iu.test(result.output ?? '') ? ',操作被拒绝' : ''},未确认停止`)
}
}
}
throw new Error('Windows 本机进程树清理未确认')
} catch (error) {
// On a transient WMI timeout/uncertainty (cold start), fall back to a direct
// taskkill /T /F on the owned root before surfacing the original failure.
if (error instanceof Error && /超时|未确认|无效|失败|超出/.test(error.message) && !processMissing(state.pid)) {
try { await windowsKillTreeDirect(state.pid, 15_000); return } catch { /* surface original error below */ }
}
throw error
}
throw new Error('Windows 本机进程树清理未确认')
}

export function cleanOwnedProcessTree(child: ChildProcess): Promise<void> {
Expand Down
Loading