mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-09 18:07:02 +08:00
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
/** Fail-loud verification of the runtime entries sealed into Electron's app.asar. */
|
|
|
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, isAbsolute, join, relative, sep } from 'node:path'
|
|
import { Worker } from 'node:worker_threads'
|
|
import { listPackage } from '@electron/asar'
|
|
import AdmZip from 'adm-zip'
|
|
import {
|
|
FORBIDDEN_MACOS_UNIVERSAL_ENTRIES,
|
|
MACOS_UNIVERSAL_NATIVE_ENTRIES,
|
|
} from './mac-universal.ts'
|
|
|
|
const DSH_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json'))
|
|
|
|
/** Every generated JavaScript file shipped by the installed DSH CLI package. */
|
|
export const REQUIRED_DSH_CLI_RUNTIME_ENTRIES = Object.freeze(
|
|
readdirSync(join(DSH_PACKAGE_ROOT, 'lib'), { withFileTypes: true })
|
|
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))
|
|
.map(entry => `node_modules/@deepseek-ai/dsh/lib/${entry.name}`)
|
|
.sort(),
|
|
)
|
|
|
|
/** AfterPack fields consumed without importing Electron Builder's incomplete declaration graph. */
|
|
export interface PackagedRuntimeContext {
|
|
/** Completed platform application directory. */
|
|
readonly appOutDir: string
|
|
/** Electron Builder target architecture (`4` is its stable universal enum value). */
|
|
readonly arch?: number
|
|
/** Electron target platform selected by the packager. */
|
|
readonly electronPlatformName: string
|
|
/** Product metadata used to locate the macOS application bundle. */
|
|
readonly packager: {
|
|
readonly appInfo: {
|
|
readonly productFilename: string
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Exact archive entries required by the desktop launcher on every supported platform. */
|
|
export const REQUIRED_PACKAGED_RUNTIME_ENTRIES = [
|
|
'package.json',
|
|
'lib/main.js',
|
|
'lib/client.js',
|
|
'lib/native-ui/profile-create.html',
|
|
'lib/native-ui/recovery.html',
|
|
'lib/native-ui/setup-wizard.html',
|
|
'lib/profile.js',
|
|
'lib/profile-manager.js',
|
|
'lib/profile-service.js',
|
|
'lib/pnpm.js',
|
|
'lib/profiles.js',
|
|
'lib/diagnostics.js',
|
|
'lib/diagnostic-export-worker.js',
|
|
'lib/desktop-cli.js',
|
|
'lib/desktop-runtime-environment.js',
|
|
'lib/desktop-terminal.js',
|
|
'lib/terminal.js',
|
|
'lib/update-checker.js',
|
|
'lib/update-download.js',
|
|
'lib/updates.js',
|
|
'lib/windows-acl-runner.js',
|
|
...REQUIRED_DSH_CLI_RUNTIME_ENTRIES,
|
|
'node_modules/@deepseek-ai/dsh-subprocess-local/lib/index.js',
|
|
'node_modules/@deepseek-ai/dsh-web-frontend/dist/index.html',
|
|
'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js',
|
|
'node_modules/open/index.js',
|
|
'node_modules/pnpm/bin/pnpm.mjs',
|
|
] as const
|
|
|
|
/** Physical entries required because profile fallback symlinks cannot target ASAR paths. */
|
|
export const REQUIRED_UNPACKED_RUNTIME_ENTRIES = [
|
|
'package.json',
|
|
'cordis.patch.yml',
|
|
'build/app-icon.png',
|
|
'build/app-icon-mac.png',
|
|
'build/tray-iconTemplate.png',
|
|
'build/tray-icon-blue.png',
|
|
'lib/main.js',
|
|
'lib/client.js',
|
|
'lib/native-ui/profile-create.html',
|
|
'lib/native-ui/recovery.html',
|
|
'lib/native-ui/setup-wizard.html',
|
|
'lib/index.js',
|
|
'lib/profile.js',
|
|
'lib/profile-manager.js',
|
|
'lib/profile-service.js',
|
|
'lib/pnpm.js',
|
|
'lib/profiles.js',
|
|
'lib/diagnostics.js',
|
|
'lib/diagnostic-export-worker.js',
|
|
'lib/terminal.js',
|
|
'lib/update-download.js',
|
|
'lib/updates.js',
|
|
'lib/windows-pwsh-sandbox.js',
|
|
'node_modules/@deepseek-ai/dsh/package.json',
|
|
'node_modules/@deepseek-ai/dsh-agent-presets/package.json',
|
|
'node_modules/@deepseek-ai/dsh-agent-presets/presets/cordis/agent.cordis.yml',
|
|
'node_modules/@deepseek-ai/dsh-agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md',
|
|
'node_modules/@deepseek-ai/dsh-agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md',
|
|
...REQUIRED_DSH_CLI_RUNTIME_ENTRIES,
|
|
'node_modules/@deepseek-ai/dsh-subprocess-local/lib/index.js',
|
|
'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js',
|
|
'node_modules/@deepseek-ai/dsh-web-frontend/dist/index.html',
|
|
'node_modules/open/index.js',
|
|
'node_modules/pnpm/bin/pnpm.mjs',
|
|
] as const
|
|
|
|
/** Prebuilt Node-API modules required when the Windows package skips native source rebuilds. */
|
|
export const REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES = [
|
|
'node_modules/node-pty/prebuilds/win32-x64/conpty.node',
|
|
'node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node',
|
|
'node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe',
|
|
'node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll',
|
|
] as const
|
|
|
|
/** CPU-specific runtime assets that must coexist in a universal macOS application. */
|
|
export const REQUIRED_MACOS_UNIVERSAL_ENTRIES = [
|
|
...MACOS_UNIVERSAL_NATIVE_ENTRIES.map(entry => entry.path),
|
|
] as const
|
|
|
|
/** Package exports that profile fallback links must resolve from the physical application tree. */
|
|
export const REQUIRED_UNPACKED_PACKAGE_SPECIFIERS = [
|
|
'dsh-plugin-desktop-beta',
|
|
'dsh-plugin-desktop-beta/profile',
|
|
'dsh-plugin-desktop-beta/client',
|
|
'dsh-plugin-desktop-beta/terminal',
|
|
'dsh-plugin-desktop-beta/pnpm',
|
|
'dsh-plugin-desktop-beta/profile-service',
|
|
'dsh-plugin-desktop-beta/profiles',
|
|
'dsh-plugin-desktop-beta/diagnostics',
|
|
'dsh-plugin-desktop-beta/notifications',
|
|
'dsh-plugin-desktop-beta/updates',
|
|
'dsh-plugin-desktop-beta/windows-pwsh-sandbox',
|
|
'dsh-plugin-desktop-beta/package.json',
|
|
'@deepseek-ai/dsh-base/package.json',
|
|
'@deepseek-ai/schemastery/package.json',
|
|
'@deepseek-ai/dsh-web-app/package.json',
|
|
] as const
|
|
|
|
/** Injectable archive listing seam used by focused tests. */
|
|
export type ArchiveLister = (archivePath: string, options: { isPack: boolean }) => readonly string[]
|
|
|
|
/** Injectable physical-file probe used by focused tests. */
|
|
export type FileProbe = (filename: string) => boolean
|
|
|
|
/** Injectable Node package resolver used by focused tests. */
|
|
export type PackageResolver = (specifier: string) => string
|
|
|
|
/** Inputs understood by the bundled diagnostics Worker. */
|
|
export interface PackagedDiagnosticWorkerData {
|
|
readonly logsDir: string
|
|
readonly userDataDir: string
|
|
readonly appVersion: string
|
|
readonly maxEvidenceBytes: number
|
|
readonly crashDumpsDir: string
|
|
}
|
|
|
|
/** Injectable packaged Worker launcher used by focused tests. */
|
|
export type PackagedDiagnosticWorkerLauncher = (
|
|
workerPath: string,
|
|
workerData: PackagedDiagnosticWorkerData,
|
|
) => Promise<string>
|
|
|
|
/** Injectable smoke seam used to verify afterPack ordering. */
|
|
export type PackagedDiagnosticWorkerSmoke = (unpackedRoot: string) => Promise<void>
|
|
|
|
/** Result posted by the bundled diagnostics Worker. */
|
|
type PackagedDiagnosticWorkerResult =
|
|
| { readonly ok: true, readonly path: string }
|
|
| { readonly ok: false, readonly error: string }
|
|
|
|
const PACKAGED_DIAGNOSTIC_WORKER_TIMEOUT_MS = 30_000
|
|
|
|
/** Start the physical packaged diagnostics Worker and wait for its terminal result. */
|
|
async function launchPackagedDiagnosticWorker(
|
|
workerPath: string,
|
|
workerData: PackagedDiagnosticWorkerData,
|
|
): Promise<string> {
|
|
return await new Promise((resolve, reject) => {
|
|
const worker = new Worker(workerPath, {
|
|
name: 'dsh-packaged-diagnostic-smoke',
|
|
workerData,
|
|
resourceLimits: { maxOldGenerationSizeMb: 256 },
|
|
})
|
|
let settled = false
|
|
const timeout = setTimeout(() => {
|
|
if (settled) return
|
|
settled = true
|
|
void worker.terminate()
|
|
reject(new Error(
|
|
`dsh-plugin-desktop: packaged diagnostic worker timed out after ${String(PACKAGED_DIAGNOSTIC_WORKER_TIMEOUT_MS)}ms`,
|
|
))
|
|
}, PACKAGED_DIAGNOSTIC_WORKER_TIMEOUT_MS)
|
|
const settle = (complete: () => void): void => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimeout(timeout)
|
|
void worker.terminate()
|
|
complete()
|
|
}
|
|
worker.once('message', (result: PackagedDiagnosticWorkerResult) => {
|
|
if (result.ok) settle(() => resolve(result.path))
|
|
else settle(() => reject(new Error(result.error)))
|
|
})
|
|
worker.once('error', cause => settle(() => reject(cause)))
|
|
worker.once('exit', (code) => {
|
|
settle(() => reject(new Error(
|
|
`dsh-plugin-desktop: packaged diagnostic worker exited with code ${String(code)}`,
|
|
)))
|
|
})
|
|
})
|
|
}
|
|
|
|
/** Exercise the physical Worker emitted beside app.asar with a minimal archive. */
|
|
export async function smokePackagedDiagnosticWorker(
|
|
unpackedRoot: string,
|
|
launch: PackagedDiagnosticWorkerLauncher = launchPackagedDiagnosticWorker,
|
|
): Promise<void> {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-packaged-diagnostics-'))
|
|
const logsDir = join(root, 'logs')
|
|
const userDataDir = join(root, 'user-data')
|
|
const crashDumpsDir = join(root, 'Crashpad')
|
|
mkdirSync(logsDir)
|
|
mkdirSync(userDataDir)
|
|
mkdirSync(join(crashDumpsDir, 'pending'), { recursive: true })
|
|
writeFileSync(join(logsDir, 'dsh-2000-01-01.log'), 'packaged worker smoke\n')
|
|
writeFileSync(join(crashDumpsDir, 'pending', 'packaged-smoke.dmp'), 'packaged crash dump smoke\n')
|
|
try {
|
|
const output = await launch(
|
|
join(unpackedRoot, 'lib', 'diagnostic-export-worker.js'),
|
|
{ logsDir, userDataDir, appVersion: 'packaged-smoke', maxEvidenceBytes: 1024, crashDumpsDir },
|
|
)
|
|
if (!existsSync(output)) {
|
|
throw new Error(`dsh-plugin-desktop: packaged diagnostic worker produced no archive at ${output}`)
|
|
}
|
|
const crashEntry = 'crash-dumps/pending/packaged-smoke.dmp'
|
|
if (new AdmZip(output).getEntry(crashEntry) === null) {
|
|
throw new Error(`dsh-plugin-desktop: packaged diagnostic worker omitted ${crashEntry}`)
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the platform-specific archive produced by Electron Builder.
|
|
* @param context - completed application directory and target platform.
|
|
* @returns absolute path to the packaged app.asar.
|
|
*/
|
|
export function resolvePackagedAsarPath(context: PackagedRuntimeContext): string {
|
|
if (context.electronPlatformName === 'darwin') {
|
|
return join(
|
|
context.appOutDir,
|
|
`${context.packager.appInfo.productFilename}.app`,
|
|
'Contents',
|
|
'Resources',
|
|
'app.asar',
|
|
)
|
|
}
|
|
if (context.electronPlatformName === 'win32' || context.electronPlatformName === 'linux') {
|
|
return join(context.appOutDir, 'resources', 'app.asar')
|
|
}
|
|
throw new Error(
|
|
`dsh-plugin-desktop: unsupported Electron afterPack platform ${JSON.stringify(context.electronPlatformName)}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Resolve the physical dependency tree emitted beside app.asar.
|
|
* @param context - completed application directory and target platform.
|
|
* @returns absolute path to app.asar.unpacked.
|
|
*/
|
|
export function resolvePackagedUnpackedRoot(context: PackagedRuntimeContext): string {
|
|
return `${resolvePackagedAsarPath(context)}.unpacked`
|
|
}
|
|
|
|
/** Normalize the host-specific separators emitted by the ASAR reader. */
|
|
function normalizeArchiveEntry(entry: string): string {
|
|
return entry.replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '')
|
|
}
|
|
|
|
/**
|
|
* Inspect one archive and reject an incomplete packaged runtime.
|
|
* @param archivePath - resolved app.asar path.
|
|
* @param list - ASAR listing implementation.
|
|
* @returns The normalized archive entry set for physical mirror verification.
|
|
*/
|
|
export function verifyPackagedAsar(
|
|
archivePath: string,
|
|
list: ArchiveLister = listPackage,
|
|
): ReadonlySet<string> {
|
|
let entries: readonly string[]
|
|
try {
|
|
entries = list(archivePath, { isPack: false })
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: failed to inspect packaged runtime at ${archivePath}`,
|
|
{ cause },
|
|
)
|
|
}
|
|
|
|
const present = new Set(entries.map(normalizeArchiveEntry))
|
|
const missing = REQUIRED_PACKAGED_RUNTIME_ENTRIES.filter(entry => !present.has(entry))
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: packaged runtime at ${archivePath} is missing required ASAR entries: ${missing.join(', ')}`,
|
|
)
|
|
}
|
|
return present
|
|
}
|
|
|
|
/**
|
|
* Require every ASAR header entry to have a physical counterpart.
|
|
*
|
|
* The Desktop packaging contract unpacks every included application file so
|
|
* profile fallback links and Node ESM resolution never target virtual paths.
|
|
* Checking the complete header closes the gap left by a curated entry list:
|
|
* a collector regression cannot silently omit transitive packages such as
|
|
* yaml, zod, or typebox from app.asar.unpacked.
|
|
*/
|
|
export function verifyUnpackedArchiveMirror(
|
|
archiveEntries: ReadonlySet<string>,
|
|
unpackedRoot: string,
|
|
exists: FileProbe = existsSync,
|
|
): void {
|
|
const missing = [...archiveEntries]
|
|
.filter(entry => entry.length > 0 && !exists(join(unpackedRoot, entry)))
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: unpacked runtime at ${unpackedRoot} is missing ASAR-declared physical entries: ${missing.join(', ')}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify package exports resolve through the physical tree instead of the build workspace.
|
|
* @param unpackedRoot - absolute path to app.asar.unpacked.
|
|
* @param resolvePackage - package resolver anchored at the physical root manifest.
|
|
* @returns Nothing; failure rejects missing exports and paths outside app.asar.unpacked.
|
|
*/
|
|
export function verifyUnpackedPackageResolution(
|
|
unpackedRoot: string,
|
|
resolvePackage: PackageResolver = createRequire(join(unpackedRoot, 'package.json')).resolve,
|
|
): void {
|
|
for (const specifier of REQUIRED_UNPACKED_PACKAGE_SPECIFIERS) {
|
|
let resolvedPath: string
|
|
try {
|
|
resolvedPath = resolvePackage(specifier)
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: packaged runtime at ${unpackedRoot} cannot resolve required package export ${specifier}`,
|
|
{ cause },
|
|
)
|
|
}
|
|
|
|
const relativePath = relative(unpackedRoot, resolvedPath)
|
|
if (
|
|
!isAbsolute(resolvedPath)
|
|
|| relativePath === '..'
|
|
|| relativePath.startsWith(`..${sep}`)
|
|
|| isAbsolute(relativePath)
|
|
) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: required package export ${specifier} resolved outside ${unpackedRoot}: ${resolvedPath}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify Electron Builder's completed application before signing begins.
|
|
* @param context - Electron Builder's afterPack context.
|
|
* @param list - ASAR listing implementation.
|
|
* @param exists - physical-file probe for the unpacked CLI dependency tree.
|
|
* @param resolvePackage - package resolver anchored at the physical root manifest.
|
|
* @returns Nothing; failure rejects the package before signing.
|
|
*/
|
|
export function verifyPackagedRuntime(
|
|
context: PackagedRuntimeContext,
|
|
list: ArchiveLister = listPackage,
|
|
exists: FileProbe = existsSync,
|
|
resolvePackage?: PackageResolver,
|
|
): void {
|
|
const archiveEntries = verifyPackagedAsar(resolvePackagedAsarPath(context), list)
|
|
const unpackedRoot = resolvePackagedUnpackedRoot(context)
|
|
const requiredPhysicalEntries = context.electronPlatformName === 'win32'
|
|
? [...REQUIRED_UNPACKED_RUNTIME_ENTRIES, ...REQUIRED_WINDOWS_X64_NODE_PTY_ENTRIES]
|
|
: context.electronPlatformName === 'darwin' && context.arch === 4
|
|
? [...REQUIRED_UNPACKED_RUNTIME_ENTRIES, ...REQUIRED_MACOS_UNIVERSAL_ENTRIES]
|
|
: REQUIRED_UNPACKED_RUNTIME_ENTRIES
|
|
const missing = requiredPhysicalEntries.filter(entry => !exists(join(unpackedRoot, entry)))
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: packaged runtime at ${unpackedRoot} is missing required physical entries: ${missing.join(', ')}`,
|
|
)
|
|
}
|
|
if (context.electronPlatformName === 'darwin' && context.arch === 4) {
|
|
const forbidden = FORBIDDEN_MACOS_UNIVERSAL_ENTRIES
|
|
.filter(entry => exists(join(unpackedRoot, entry)))
|
|
if (forbidden.length > 0) {
|
|
throw new Error(
|
|
`dsh-plugin-desktop: universal macOS runtime at ${unpackedRoot} contains host-architecture build output: ${forbidden.join(', ')}`,
|
|
)
|
|
}
|
|
}
|
|
verifyUnpackedArchiveMirror(archiveEntries, unpackedRoot, exists)
|
|
verifyUnpackedPackageResolution(unpackedRoot, resolvePackage)
|
|
}
|
|
|
|
/**
|
|
* Run the static packaged-runtime check as Electron Builder's afterPack hook.
|
|
* @param context - Electron Builder's afterPack context.
|
|
* @returns A promise that rejects before signing when the runtime is incomplete.
|
|
*/
|
|
export async function afterPack(
|
|
context: PackagedRuntimeContext,
|
|
verify: typeof verifyPackagedRuntime = verifyPackagedRuntime,
|
|
smoke: PackagedDiagnosticWorkerSmoke = smokePackagedDiagnosticWorker,
|
|
): Promise<void> {
|
|
verify(context)
|
|
await smoke(resolvePackagedUnpackedRoot(context))
|
|
}
|
|
|
|
export default afterPack
|