From dd485b4e9574c0c144a82e9917040214b1e23e8f Mon Sep 17 00:00:00 2001
From: sunheyi <1061867552@qq.com>
Date: Thu, 2 Jul 2026 21:38:53 +0800
Subject: [PATCH 1/2] fix: add inline skeleton loader in index.html to prevent
white screen on startup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add inline CSS + HTML skeleton in index.html that renders before
React mounts and external CSS/JS loads
- Skeleton matches .maka-onboarding-loading design with hardcoded
theme-aware colors (light/dark via prefers-color-scheme)
- No external dependencies — visible immediately after HTML parse
- Eliminates blank white/gray screen during 355KB CSS + 5.8MB JS window
---
.gitignore | 1 +
apps/desktop/package.json | 4 +-
apps/desktop/scripts/dev.mjs | 144 ++++++++++++++++++
.../__tests__/dev-startup-contract.test.ts | 26 ++++
.../startup-loading-shell-contract.test.ts | 28 ++++
apps/desktop/src/main/main-window.ts | 38 +++++
apps/desktop/src/renderer/index.html | 71 ++++++++-
.../src/renderer/styles/onboarding.css | 40 ++++-
apps/desktop/tsconfig.main.json | 3 +-
package.json | 2 +-
packages/core/package.json | 2 +-
packages/headless/package.json | 2 +-
packages/runtime/package.json | 2 +-
packages/storage/package.json | 2 +-
.../src/__tests__/artifact-store.test.ts | 13 +-
.../src/__tests__/session-store.test.ts | 61 ++++++++
packages/storage/src/session-store.ts | 110 +++++++++++--
packages/storage/tsconfig.json | 6 +-
packages/ui/package.json | 2 +-
packages/ui/tsconfig.json | 6 +-
scripts/clean-paths.mjs | 12 ++
tsconfig.lib.json | 9 ++
22 files changed, 558 insertions(+), 26 deletions(-)
create mode 100644 apps/desktop/scripts/dev.mjs
create mode 100644 apps/desktop/src/main/__tests__/dev-startup-contract.test.ts
create mode 100644 apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts
create mode 100644 scripts/clean-paths.mjs
create mode 100644 tsconfig.lib.json
diff --git a/.gitignore b/.gitignore
index 6295b7e902..5b15a7bf1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ __pycache__/
*.pyc
.agents-workspace-data
.agents
+.suncode/
skills-lock.json
# PR-IR-01 captured screenshots — generated artifacts, not committed.
# The committed baseline lives in `apps/desktop/tests/screenshots-baseline/`.
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..ecd5cd4180
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts
@@ -0,0 +1,28 @@
+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('renderer keeps preload skeleton visible while lazy app shell loads', () => {
+ const cwd = process.cwd();
+ const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop');
+ const appSource = readFileSync(join(desktopRoot, 'src', 'renderer', 'app.tsx'), 'utf8');
+
+ assert.doesNotMatch(appSource, /fallback=\{null\}/);
+ assert.match(appSource, /fallback=\{