Skip to content
Merged
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: 2 additions & 2 deletions apps/desktop/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop/scripts/dev.mjs
Original file line numberDiff line numberDiff line change
@@ -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));
26 changes: 26 additions & 0 deletions apps/desktop/src/main/__tests__/dev-startup-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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/);
});
Original file line numberDiff line numberDiff line change
@@ -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, /<div id="root">\s*<div class="maka-preload"/);
assert.match(html, /aria-busy="true"/);
// Styled inline (before external CSS) with hardcoded colors, since
// maka-tokens.css has not loaded yet — no CSS variables in the skeleton.
assert.match(html, /\.maka-preload\s*\{[\s\S]*?background:\s*#[0-9a-fA-F]{3,6}/);
assert.doesNotMatch(html.match(/\.maka-preload\s*\{[\s\S]*?\}/)?.[0] ?? '', /var\(/);
// Dark mode handled to match cached-theme-bootstrap fallback.
assert.match(html, /@media \(prefers-color-scheme: dark\)/);
});
38 changes: 38 additions & 0 deletions apps/desktop/src/main/main-window.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,44 @@ function emitRealWindowSmokeDiagnostic(stage: string): void {
searchModalPresent: Boolean(document.querySelector('.maka-search-modal')),
searchModalBackdropPresent: Boolean(document.querySelector('.maka-dialog-backdrop')),
errorBoundaryPresent: Boolean(document.querySelector('.maka-error-surface')),
bodyTextLength: document.body?.innerText?.trim().length ?? 0,
bodyTextSample: document.body?.innerText?.trim().slice(0, 240) ?? '',
stylesheetCount: document.styleSheets.length,
rootChildren: document.getElementById('root')?.children.length ?? 0,
elements: ['body', '#root', '.appFrame', '.app', '.maka-panel-list', '.maka-panel-detail', '.mainColumn', '.maka-onboarding-loading'].map((selector) => {
const element = document.querySelector(selector);
if (!element) return { selector, present: false };
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
selector,
present: true,
textLength: (element.textContent ?? '').trim().length,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
color: style.color,
backgroundColor: style.backgroundColor,
};
}),
centerElement: (() => {
const element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
if (!element) return null;
const style = getComputedStyle(element);
return {
tagName: element.tagName,
className: typeof element.className === 'string' ? element.className : '',
text: (element.textContent ?? '').trim().slice(0, 120),
color: style.color,
backgroundColor: style.backgroundColor,
};
})(),
activeElementInSearchModal: Boolean(document.activeElement && document.activeElement.closest && document.activeElement.closest('.maka-search-modal')),
activeElement: document.activeElement ? {
tagName: document.activeElement.tagName,
Expand Down
71 changes: 70 additions & 1 deletion apps/desktop/src/renderer/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,78 @@
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
/>
<title>Maka</title>
<style>
/*
* Preload skeleton — renders BEFORE React mounts and before any
* external CSS loads. Prevents a blank white/gray screen during
* the 355KB CSS + 5.8MB JS loading window.
*
* Colors are hardcoded (not CSS variables) because maka-tokens.css
* (70KB) hasn't loaded yet. Light/dark chosen via prefers-color-scheme
* to match cached-theme-bootstrap.ts fallback logic.
*/
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
}
#root {
display: flex;
align-items: center;
justify-content: center;
}
.maka-preload {
position: relative;
width: min(620px, calc(100% - 48px));
min-height: 132px;
margin: auto;
overflow: hidden;
border-radius: 12px;
}
/* light theme (default) */
.maka-preload {
background: #f3f3f5;
border: 1px solid #d4d4d8;
box-shadow: 0 10px 24px rgba(0,0,0,0.06);
}
.maka-preload::before,
.maka-preload::after {
content: "";
position: absolute;
left: 24px;
right: 24px;
border-radius: 999px;
background: linear-gradient(90deg, #e4e4e7, #d4d4d8, #e4e4e7);
}
.maka-preload::before {
top: 34px;
width: min(280px, calc(100% - 48px));
height: 16px;
}
.maka-preload::after {
top: 66px;
width: min(460px, calc(100% - 48px));
height: 12px;
opacity: 0.7;
}
/* dark theme overrides */
@media (prefers-color-scheme: dark) {
.maka-preload {
background: #1c1d21;
border-color: #2e3035;
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.maka-preload::before,
.maka-preload::after {
background: linear-gradient(90deg, #2a2b30, #3a3c42, #2a2b30);
}
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div class="maka-preload" role="status" aria-busy="true" aria-label="加载中"></div>
</div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
40 changes: 39 additions & 1 deletion apps/desktop/src/renderer/styles/onboarding.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -615,7 +615,45 @@
default EmptyChatHero so the user doesn't see prompt suggestions
flash before the state-routed hero mounts. */
.maka-onboarding-loading {
min-height: 120px;
position: relative;
width: min(620px, calc(100% - 48px));
min-height: 132px;
margin: auto;
overflow: hidden;
border: 1px solid var(--border-strong);
border-radius: var(--radius-modal);
background: var(--foreground-5);
box-shadow:
0 0 0 1px oklch(from var(--foreground) l c h / 0.03),
0 10px 24px oklch(from var(--foreground) l c h / 0.06);
}

.maka-onboarding-loading::before,
.maka-onboarding-loading::after {
content: "";
position: absolute;
left: 24px;
right: 24px;
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--foreground-8),
oklch(from var(--foreground) l c h / 0.16),
var(--foreground-8)
);
}

.maka-onboarding-loading::before {
top: 34px;
width: min(280px, calc(100% - 48px));
height: 16px;
}

.maka-onboarding-loading::after {
top: 66px;
width: min(460px, calc(100% - 48px));
height: 12px;
opacity: 0.7;
}

.maka-list-row-name {
Expand Down
Loading