diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1b90c95cb0..2e54bbf8cc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,12 +7,12 @@ "main": "dist/main/main.js", "scripts": { "start": "electron .", - "dev": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/runtime run build && npm --workspace @maka/ui run build && npm run build && electron .", + "dev": "node scripts/dev.mjs", "dev:hmr": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/runtime run build && npm --workspace @maka/ui run build && npm run build:main && npm run build:preload && node scripts/dev-hmr.mjs", "storybook": "storybook dev -p 6006 -c .storybook", "build-storybook": "storybook build -c .storybook --output-dir storybook-static", "build": "npm run build:main && npm run build:preload && npm run build:renderer", - "clean:main": "rm -rf dist/main tsconfig.main.tsbuildinfo", + "clean:main": "node ../../scripts/clean-paths.mjs dist/main tsconfig.main.tsbuildinfo", "build:main": "tsc -p tsconfig.main.json", "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", "build:renderer": "vite build", diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs new file mode 100644 index 0000000000..4a66e43550 --- /dev/null +++ b/apps/desktop/scripts/dev.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * Dev launcher with PARALLEL + INCREMENTAL builds. + * + * Uses `tsc --build` for library packages so the compiler skips + * unchanged sub-projects via .tsbuildinfo (incremental). + * + * Dependency graph (→ compiles after): + * core ─┬→ storage + * ├→ runtime + * └→ ui + * + * libs (tsc --build tsconfig.lib.json) ─── covers core+storage+runtime+ui + * preload (esbuild) ─── parallel, no tsc dependency + * main (esbuild) ─── fast app bundle for Electron + * Vite dev server + Electron ─── fork + */ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); +const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); +const ON_WINDOWS = process.platform === 'win32'; +const TSC_CLI = join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'); +const ESBUILD_CLI = join(REPO_ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild'); + +// ── helpers ────────────────────────────────────────────────────────────────── + +function log(label, msg) { + const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false }); + console.log(`[${ts}][${label}] ${msg}`); +} + +function runNodeTool(dir, script, args) { + return new Promise((resolve_, reject_) => { + const child = spawn(process.execPath, [script, ...args], { + cwd: dir, + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.on('exit', (code) => { + if (code === 0) resolve_(); + else reject_(new Error(`"${script} ${args.join(' ')}" exited with code ${code}`)); + }); + child.on('error', reject_); + }); +} + +function resolveElectronBin() { + for (let dir = DESKTOP_DIR; ; dir = dirname(dir)) { + const exe = ON_WINDOWS + ? join(dir, 'node_modules', 'electron', 'dist', 'electron.exe') + : join(dir, 'node_modules', '.bin', 'electron'); + if (existsSync(exe)) return exe; + if (dirname(dir) === dir) return 'electron'; + } +} + +// ── build phases ───────────────────────────────────────────────────────────── + +const TIMER_START = Date.now(); + +// Phase 1: all library packages via `tsc --build` (single process, shared +// .tsbuildinfo, sub-project incremental detection). Also runs preload +// (esbuild is fast) in parallel since it has no tsc dependency. +log('build', 'libraries — starting (tsc --build + preload)'); +await Promise.all([ + runNodeTool(REPO_ROOT, TSC_CLI, ['--build', 'tsconfig.lib.json']).then( + () => log('build', 'libraries (all) — done'), + (e) => { log('build', `libraries — FAILED: ${e.message}`); throw e; }, + ), + runNodeTool(DESKTOP_DIR, ESBUILD_CLI, ['src/preload/preload.ts', '--bundle', '--platform=node', '--format=cjs', '--outfile=dist/preload/preload.cjs', '--external:electron']).then( + () => log('build', 'preload — done'), + (e) => { log('build', `preload — FAILED: ${e.message}`); throw e; }, + ), +]); + +// Phase 2: main — esbuild bundle for dev startup. The full +// tsconfig.main.json still compiles tests for `npm test` and typechecks +// main-process code in verification commands. +log('build', 'main — starting'); +await runNodeTool(DESKTOP_DIR, ESBUILD_CLI, ['src/main/main.ts', '--bundle', '--platform=node', '--format=esm', '--packages=external', '--outfile=dist/main/main.js', '--external:electron']); +log('build', 'main — done'); + +const BUILD_MS = Date.now() - TIMER_START; +log('build', `all builds finished in ${(BUILD_MS / 1000).toFixed(1)}s`); + +// ── Vite dev server + Electron ─────────────────────────────────────────────── + +process.chdir(DESKTOP_DIR); +log('vite', 'starting dev server...'); +const server = await createServer(); +await server.listen(); +server.printUrls(); + +const devUrl = server.resolvedUrls?.local?.[0]?.replace(/\/$/, ''); +if (!devUrl) { + console.error('[dev] vite did not report a local URL; aborting.'); + await server.close(); + process.exit(1); +} + +log('electron', `launching against ${devUrl} (renderer HMR live)`); +const electron = spawn(resolveElectronBin(), ['.', ...process.argv.slice(2)], { + cwd: DESKTOP_DIR, + stdio: 'inherit', + env: { ...process.env, VITE_DEV_SERVER_URL: devUrl }, +}); + +let shuttingDown = false; +async function shutdown(code, options = {}) { + if (shuttingDown) return; + shuttingDown = true; + if (options.killElectron !== false) { + await terminateProcessTree(electron); + } + await server.close().catch(() => {}); + process.exit(code); +} + +function terminateProcessTree(child) { + if (child.exitCode !== null || child.killed) return Promise.resolve(); + if (ON_WINDOWS && child.pid) { + return new Promise((resolve_) => { + const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + killer.on('exit', () => resolve_()); + killer.on('error', () => resolve_()); + }); + } + child.kill('SIGTERM'); + return Promise.resolve(); +} + +electron.on('exit', (code) => shutdown(code ?? 0, { killElectron: false })); +electron.on('error', (err) => { + console.error(`[dev] failed to start Electron: ${err.message}`); + shutdown(1); +}); +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); diff --git a/apps/desktop/src/main/__tests__/dev-startup-contract.test.ts b/apps/desktop/src/main/__tests__/dev-startup-contract.test.ts new file mode 100644 index 0000000000..b699d62156 --- /dev/null +++ b/apps/desktop/src/main/__tests__/dev-startup-contract.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +test('dev launcher bundles app main sources without compiling main-process tests', async () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const devScript = await readFile(join(desktopRoot, 'scripts', 'dev.mjs'), 'utf8'); + assert.match(devScript, /esbuild/); + assert.match(devScript, /src\/main\/main\.ts/); + assert.match(devScript, /--bundle/); + assert.match(devScript, /--packages=external/); + assert.doesNotMatch(devScript, /\['tsc', '-p'/); + assert.doesNotMatch(devScript, /tsconfig\.main\.app\.json/); +}); + +test('dev launcher tears down the Electron process tree on Windows', async () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const devScript = await readFile(join(desktopRoot, 'scripts', 'dev.mjs'), 'utf8'); + + assert.match(devScript, /taskkill/); + assert.match(devScript, /\/T/); + assert.match(devScript, /\/F/); +}); diff --git a/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts b/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts new file mode 100644 index 0000000000..6abff4e443 --- /dev/null +++ b/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +test('startup onboarding loading slot paints visible skeleton chrome', () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const css = readFileSync(join(desktopRoot, 'src', 'renderer', 'styles', 'onboarding.css'), 'utf8'); + const block = css.match(/\.maka-onboarding-loading\s*\{[\s\S]*?\n\}/)?.[0] ?? ''; + + assert.match(block, /background:\s*var\(--foreground-5\)/); + assert.match(block, /border:\s*1px solid var\(--border-strong\)/); + assert.match(block, /box-shadow:/); + assert.match(css, /var\(--foreground-8\)/); + assert.match(css, /\.maka-onboarding-loading::before\s*\{/); + assert.match(css, /\.maka-onboarding-loading::after\s*\{/); +}); + +test('index.html paints an inline preload skeleton before React mounts', () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const html = readFileSync(join(desktopRoot, 'src', 'renderer', 'index.html'), 'utf8'); + + // #root ships a non-empty, accessible skeleton so there is no blank window + // during the CSS + JS loading gap; createRoot() replaces it on mount. + assert.match(html, /