Skip to content

Commit 0b3005e

Browse files
phodalQoder-AI
andcommitted
chore(desktop): 打包时裁剪桌面运行时的多余产物
打包出的 macOS arm64 未压缩应用约 518MB,其中 staging 的 Node 运行时仍带着 调试元数据、完整的 Phosphor 图标包,以及 PDF.js 的两套构建。Phosphor 图标 已经内联进 Studio 的浏览器资源,Node 侧的 PDF 适配器只 import legacy 入口, 因此这些体积对运行时没有价值。 新增 scripts/prune-desktop-runtime.mjs 作为保留/丢弃规则的唯一归属,在 stage.mjs 完成 --omit=dev 安装后调用:删除 *.map、*.d.ts/.d.mts/.d.cts 和 包内 README,移除 @phosphor-icons,PDF.js 只保留 legacy/build/pdf.mjs、 pdf.worker.mjs 与 cmaps/wasm/standard_fonts/iccs/image_decoders 支持树。 路径遍历统一走 node:path,不按 '/' 切分原生路径。 验证:npm test -w @qoder-ai/better-harness-desktop 10 项通过,其中三项在 fixture 树上断言保留与丢弃行为;harness-studio 的 pdf-artifact-adapter 测试 4 项通过,确认 legacy 入口仍可加载;实际 stage 裁掉 5394 个文件, staged node_modules 从 285MB 降到 141MB。 Co-authored-by: Qoder (Grok 4.6) <qoder_ai@qoder.com>
1 parent 3a4eb31 commit 0b3005e

5 files changed

Lines changed: 366 additions & 1 deletion

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Desktop runtime prune for debug metadata, Phosphor, and PDF.js
2+
3+
## Traceability
4+
5+
- Spec ID: `2026-09-10-desktop-runtime-prune`
6+
- Status: Implemented
7+
- Request: shrink the packaged Harness Studio `.app` by dropping pack-time debug metadata, unused Phosphor files, and unused PDF.js trees
8+
- AI involvement: implementation and local validation by Grok 4.6
9+
10+
## Intent
11+
12+
The unpacked macOS arm64 app is about 518MB. Chromium is most of that, but the
13+
staged Node runtime still ships debug metadata, the full Phosphor icon package,
14+
and both PDF.js builds. Phosphor icons are already bundled into Studio's
15+
browser assets, and the Node PDF adapter imports only the legacy PDF.js entry.
16+
Staging should keep the runtime those two facts require and drop the rest
17+
before electron-builder packs `dist/app`.
18+
19+
## Acceptance Scenarios
20+
21+
- **AC-1** Given a staged desktop `node_modules` tree, when staging finishes,
22+
then `*.map`, `*.d.ts` / `*.d.mts` / `*.d.cts`, and package README files are
23+
gone, while runtime JavaScript, LICENSE files, native addons, and WASM remain.
24+
- **AC-2** Given `@phosphor-icons/react` in the production closure, when staging
25+
finishes, then no `@phosphor-icons` package remains under staged
26+
`node_modules`. The Studio browser bundle already inlines the icons it uses.
27+
- **AC-3** Given `pdfjs-dist` in the production closure, when staging finishes,
28+
then the modern `build/` tree, viewer `web/` trees, types, webpack helper,
29+
minified copies, and source maps are gone, while `legacy/build/pdf.mjs`,
30+
`legacy/build/pdf.worker.mjs`, and the runtime support trees (`cmaps`,
31+
`wasm`, `standard_fonts`, `iccs`, `image_decoders`) remain. The Node adapter
32+
can still load `pdfjs-dist/legacy/build/pdf.mjs`.
33+
- **AC-4** Given the prune owner, when its tests run, then they assert keep and
34+
drop behaviour on a fixture tree rather than grepping packaged markup.
35+
36+
## Non-goals
37+
38+
- Shrinking Electron Framework / Chromium.
39+
- Shiki language subsetting or dropping `esbuild-wasm`.
40+
- Changing Studio's PDF adapter to the modern `pdfjs-dist/build` entry. That
41+
build loads in Node but warns to use `legacy`; this change keeps the working
42+
Node path.
43+
- Moving Phosphor to `devDependencies` in the npm package. Desktop staging is
44+
the pack-time owner; the Studio CLI package layout stays unchanged.
45+
- Signing, notarization, or a smaller published npm tarball.
46+
47+
## Plan and Tasks
48+
49+
1. Add `packages/better-harness-desktop/scripts/prune-desktop-runtime.mjs` as
50+
the keep/drop owner. Walk with `node:path` / `readdir`; do not split native
51+
paths on `/`.
52+
2. Call it from `scripts/stage.mjs` after the production `npm install`.
53+
3. Cover AC-1..AC-4 with `node:test` fixtures next to the existing desktop
54+
protocol tests.
55+
4. Restage and confirm Phosphor is absent, PDF.js legacy entry still imports,
56+
and staged `node_modules` is smaller.
57+
58+
## Test and Review Evidence
59+
60+
- `npm test -w @qoder-ai/better-harness-desktop`: 10 passed, including the three
61+
prune keep/drop tests (AC-1, AC-2, AC-3, AC-4).
62+
- `npm exec -w @qoder-ai/harness-studio -- vitest run test/pdf-artifact-adapter.test.ts`:
63+
4 passed against the workspace PDF.js install.
64+
- `npm run stage -w @qoder-ai/better-harness-desktop` pruned 5394 files. Staged
65+
`node_modules` went from 285MB to 141MB. `@phosphor-icons` is absent.
66+
`pdfjs-dist` is 7.8MB and still exports `getDocument` from
67+
`legacy/build/pdf.mjs`. Zero leftover `*.map`, `*.d.ts`, or README files.

‎packages/better-harness-desktop/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ npm run better-harness-desktop:dev
1111
```
1212

1313
`npm run better-harness-desktop:pack` builds an unpacked application under
14-
`packages/better-harness-desktop/dist/installers`. Run it on the target OS/architecture
14+
`packages/better-harness-desktop/dist/installers`. Staging drops source maps,
15+
TypeScript declarations, package READMEs, the unused `@phosphor-icons` package
16+
(icons are already bundled into Studio's browser assets), and PDF.js trees the
17+
Node adapter does not import. Run the pack command on the target OS/architecture
1518
so Cargo produces the matching Rust executable. `npm run dist -w
1619
@qoder-ai/better-harness-desktop` creates the configured ZIP (macOS), NSIS (Windows),
1720
or AppImage (Linux). These are development packaging targets; signing,
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { readdir, rm } from "node:fs/promises";
2+
import { basename, dirname, join, relative } from "node:path";
3+
4+
const PHOSPHOR_SCOPE = "@phosphor-icons";
5+
const PDFJS_PACKAGE = "pdfjs-dist";
6+
const PDFJS_SUPPORT_TREES = new Set(["cmaps", "wasm", "standard_fonts", "iccs", "image_decoders"]);
7+
const PDFJS_LEGACY_RUNTIME = new Set(["pdf.mjs", "pdf.worker.mjs"]);
8+
const README_NAMES = new Set(["readme", "readme.md", "readme.txt", "readme.markdown", "readme.rst"]);
9+
10+
/** Debug metadata is never required to start the packaged Studio runtime. */
11+
export function isDebugMetadataFile(name) {
12+
const lower = name.toLowerCase();
13+
if (lower.endsWith(".map")) return true;
14+
if (lower.endsWith(".d.ts") || lower.endsWith(".d.mts") || lower.endsWith(".d.cts")) return true;
15+
return README_NAMES.has(lower);
16+
}
17+
18+
function relativeSegments(from, to) {
19+
const rel = relative(from, to);
20+
if (rel === "") return [];
21+
const segments = [];
22+
let current = rel;
23+
while (current && current !== ".") {
24+
segments.unshift(basename(current));
25+
const parent = dirname(current);
26+
if (parent === current) break;
27+
current = parent;
28+
}
29+
return segments;
30+
}
31+
32+
/** Keep only the Node PDF.js entry Studio imports, plus its support trees. */
33+
export function isKeptPdfjsPath(packageRoot, filePath) {
34+
const segments = relativeSegments(packageRoot, filePath);
35+
if (segments.length === 1 && (segments[0] === "package.json" || segments[0] === "LICENSE")) return true;
36+
if (segments[0] === "legacy" && segments[1] === "build" && segments.length === 3) {
37+
return PDFJS_LEGACY_RUNTIME.has(segments[2]);
38+
}
39+
if (PDFJS_SUPPORT_TREES.has(segments[0])) return true;
40+
if (segments[0] === "legacy" && segments[1] === "image_decoders") return true;
41+
return false;
42+
}
43+
44+
async function removeEmptyDirectories(root) {
45+
let entries;
46+
try {
47+
entries = await readdir(root, { withFileTypes: true });
48+
} catch (error) {
49+
if (error && error.code === "ENOENT") return;
50+
throw error;
51+
}
52+
for (const entry of entries) {
53+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
54+
const full = join(root, entry.name);
55+
await removeEmptyDirectories(full);
56+
const leftover = await readdir(full);
57+
if (leftover.length === 0) await rm(full, { recursive: true, force: true });
58+
}
59+
}
60+
61+
async function pruneDebugMetadata(root, stats) {
62+
let entries;
63+
try {
64+
entries = await readdir(root, { withFileTypes: true });
65+
} catch (error) {
66+
if (error && error.code === "ENOENT") return;
67+
throw error;
68+
}
69+
for (const entry of entries) {
70+
const full = join(root, entry.name);
71+
if (entry.isSymbolicLink()) {
72+
if (isDebugMetadataFile(entry.name)) {
73+
await rm(full, { force: true });
74+
stats.removed += 1;
75+
}
76+
continue;
77+
}
78+
if (entry.isDirectory()) {
79+
await pruneDebugMetadata(full, stats);
80+
continue;
81+
}
82+
if (entry.isFile() && isDebugMetadataFile(entry.name)) {
83+
await rm(full, { force: true });
84+
stats.removed += 1;
85+
}
86+
}
87+
}
88+
89+
function isKeptPdfjsPrefix(packageRoot, directoryPath) {
90+
const segments = relativeSegments(packageRoot, directoryPath);
91+
if (segments.length === 0) return true;
92+
if (PDFJS_SUPPORT_TREES.has(segments[0])) return true;
93+
if (segments[0] !== "legacy") return false;
94+
if (segments.length === 1) return true;
95+
if (segments[1] === "build" && segments.length === 2) return true;
96+
return segments[1] === "image_decoders";
97+
}
98+
99+
async function prunePdfjsPackage(packageRoot, current, stats) {
100+
let entries;
101+
try {
102+
entries = await readdir(current, { withFileTypes: true });
103+
} catch (error) {
104+
if (error && error.code === "ENOENT") return;
105+
throw error;
106+
}
107+
for (const entry of entries) {
108+
const full = join(current, entry.name);
109+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
110+
if (!isKeptPdfjsPrefix(packageRoot, full)) {
111+
await rm(full, { recursive: true, force: true });
112+
stats.removed += 1;
113+
continue;
114+
}
115+
await prunePdfjsPackage(packageRoot, full, stats);
116+
continue;
117+
}
118+
if (!isKeptPdfjsPath(packageRoot, full)) {
119+
await rm(full, { force: true });
120+
stats.removed += 1;
121+
}
122+
}
123+
}
124+
125+
async function visitNodeModules(dir, stats) {
126+
let entries;
127+
try {
128+
entries = await readdir(dir, { withFileTypes: true });
129+
} catch (error) {
130+
if (error && error.code === "ENOENT") return;
131+
throw error;
132+
}
133+
for (const entry of entries) {
134+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
135+
const full = join(dir, entry.name);
136+
if (entry.name === PHOSPHOR_SCOPE) {
137+
await rm(full, { recursive: true, force: true });
138+
stats.removed += 1;
139+
stats.removedPhosphor = true;
140+
continue;
141+
}
142+
if (entry.name === PDFJS_PACKAGE) {
143+
await prunePdfjsPackage(full, full, stats);
144+
continue;
145+
}
146+
if (entry.name === "node_modules" || entry.name.startsWith("@")) {
147+
await visitNodeModules(full, stats);
148+
continue;
149+
}
150+
await visitNodeModules(join(full, "node_modules"), stats);
151+
}
152+
}
153+
154+
/** Drop pack-only weight from a staged Electron app root. */
155+
export async function pruneDesktopRuntime(appRoot) {
156+
const nodeModules = join(appRoot, "node_modules");
157+
const stats = { removed: 0, removedPhosphor: false };
158+
await visitNodeModules(nodeModules, stats);
159+
await pruneDebugMetadata(nodeModules, stats);
160+
await removeEmptyDirectories(nodeModules);
161+
return stats;
162+
}

‎packages/better-harness-desktop/scripts/stage.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
22
import { execFileSync } from 'node:child_process';
33
import { dirname, join, resolve } from 'node:path';
44
import { fileURLToPath } from 'node:url';
5+
import { pruneDesktopRuntime } from './prune-desktop-runtime.mjs';
56

67
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
78
const repository = resolve(root, '../..');
@@ -31,4 +32,6 @@ await cp(join(root, 'src'), join(staging, 'src'), { recursive: true });
3132
// Install only the two local public artifacts and their production closure.
3233
// This avoids copying repository dev dependencies into the desktop distribution.
3334
process.stdout.write(run(['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', ...archives], staging));
35+
const pruned = await pruneDesktopRuntime(staging);
3436
console.log(`Staged desktop runtime: ${staging}`);
37+
console.log(`Pruned ${pruned.removed} desktop runtime files`);
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2+
import assert from "node:assert/strict";
3+
import { tmpdir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { test } from "node:test";
6+
import {
7+
isDebugMetadataFile,
8+
isKeptPdfjsPath,
9+
pruneDesktopRuntime,
10+
} from "../scripts/prune-desktop-runtime.mjs";
11+
12+
async function writeTree(root, files) {
13+
for (const [relativePath, content] of Object.entries(files)) {
14+
const full = join(root, relativePath);
15+
await mkdir(dirname(full), { recursive: true });
16+
await writeFile(full, content);
17+
}
18+
}
19+
20+
async function exists(path) {
21+
try {
22+
await stat(path);
23+
return true;
24+
} catch (error) {
25+
if (error && error.code === "ENOENT") return false;
26+
throw error;
27+
}
28+
}
29+
30+
test("debug metadata is README, source maps, and declaration files only", () => {
31+
for (const name of ["README.md", "readme", "index.js.map", "index.d.ts", "pdf.d.mts", "view.d.cts"]) {
32+
assert.equal(isDebugMetadataFile(name), true, name);
33+
}
34+
for (const name of ["index.js", "LICENSE", "package.json", "skia.darwin-arm64.node", "esbuild.wasm", "readme.js"]) {
35+
assert.equal(isDebugMetadataFile(name), false, name);
36+
}
37+
});
38+
39+
test("PDF.js keep list is the legacy Node entry plus support trees", () => {
40+
const root = join("app", "node_modules", "pdfjs-dist");
41+
assert.equal(isKeptPdfjsPath(root, join(root, "package.json")), true);
42+
assert.equal(isKeptPdfjsPath(root, join(root, "LICENSE")), true);
43+
assert.equal(isKeptPdfjsPath(root, join(root, "legacy", "build", "pdf.mjs")), true);
44+
assert.equal(isKeptPdfjsPath(root, join(root, "legacy", "build", "pdf.worker.mjs")), true);
45+
assert.equal(isKeptPdfjsPath(root, join(root, "cmaps", "A.bcmap")), true);
46+
assert.equal(isKeptPdfjsPath(root, join(root, "wasm", "jbig2.wasm")), true);
47+
assert.equal(isKeptPdfjsPath(root, join(root, "legacy", "build", "pdf.min.mjs")), false);
48+
assert.equal(isKeptPdfjsPath(root, join(root, "legacy", "build", "pdf.sandbox.mjs")), false);
49+
assert.equal(isKeptPdfjsPath(root, join(root, "build", "pdf.mjs")), false);
50+
assert.equal(isKeptPdfjsPath(root, join(root, "web", "viewer.mjs")), false);
51+
assert.equal(isKeptPdfjsPath(root, join(root, "types", "src", "pdf.d.ts")), false);
52+
});
53+
54+
test("staging prune drops debug metadata, Phosphor, and unused PDF.js trees", async () => {
55+
const appRoot = await mkdtemp(join(tmpdir(), "desktop-runtime-prune-"));
56+
try {
57+
await writeTree(appRoot, {
58+
"src/main.mjs": "export {}\n",
59+
"node_modules/keep-pkg/index.js": "export const ok = true;\n",
60+
"node_modules/keep-pkg/LICENSE": "MIT\n",
61+
"node_modules/keep-pkg/README.md": "docs\n",
62+
"node_modules/keep-pkg/index.d.ts": "export const ok: boolean;\n",
63+
"node_modules/keep-pkg/index.js.map": "{\"version\":3}\n",
64+
"node_modules/@phosphor-icons/react/dist/csr/X.js": "export const X = () => null;\n",
65+
"node_modules/@phosphor-icons/react/README.md": "icons\n",
66+
"node_modules/pdfjs-dist/package.json": "{\"name\":\"pdfjs-dist\"}\n",
67+
"node_modules/pdfjs-dist/LICENSE": "Apache-2.0\n",
68+
"node_modules/pdfjs-dist/README.md": "pdfjs\n",
69+
"node_modules/pdfjs-dist/webpack.mjs": "export {}\n",
70+
"node_modules/pdfjs-dist/build/pdf.mjs": "export function getDocument() {}\n",
71+
"node_modules/pdfjs-dist/build/pdf.mjs.map": "{\"version\":3}\n",
72+
"node_modules/pdfjs-dist/web/viewer.mjs": "export {}\n",
73+
"node_modules/pdfjs-dist/types/src/pdf.d.ts": "export {}\n",
74+
"node_modules/pdfjs-dist/legacy/build/pdf.mjs": "export function getDocument() { return true; }\n",
75+
"node_modules/pdfjs-dist/legacy/build/pdf.mjs.map": "{\"version\":3}\n",
76+
"node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs": "export {}\n",
77+
"node_modules/pdfjs-dist/legacy/build/pdf.min.mjs": "export {}\n",
78+
"node_modules/pdfjs-dist/legacy/build/pdf.sandbox.mjs": "export {}\n",
79+
"node_modules/pdfjs-dist/legacy/web/viewer.mjs": "export {}\n",
80+
"node_modules/pdfjs-dist/cmaps/A.bcmap": "cmap\n",
81+
"node_modules/pdfjs-dist/wasm/jbig2.wasm": "wasm\n",
82+
"node_modules/pdfjs-dist/standard_fonts/Fox.pfb": "font\n",
83+
"node_modules/pdfjs-dist/iccs/x.icc": "icc\n",
84+
"node_modules/pdfjs-dist/image_decoders/pdf.image_decoders.mjs": "export {}\n",
85+
"node_modules/pdfjs-dist/image_decoders/pdf.image_decoders.mjs.map": "{\"version\":3}\n",
86+
"node_modules/@qoder-ai/harness-studio/dist/app/assets/app.js": "console.log(1);\n",
87+
"node_modules/@qoder-ai/harness-studio/dist/app/assets/app.js.map": "{\"version\":3}\n",
88+
});
89+
90+
const stats = await pruneDesktopRuntime(appRoot);
91+
assert.equal(stats.removedPhosphor, true);
92+
assert.equal(stats.removed > 0, true);
93+
94+
assert.equal(await exists(join(appRoot, "src", "main.mjs")), true);
95+
assert.equal(await exists(join(appRoot, "node_modules", "keep-pkg", "index.js")), true);
96+
assert.equal(await exists(join(appRoot, "node_modules", "keep-pkg", "LICENSE")), true);
97+
assert.equal(await exists(join(appRoot, "node_modules", "keep-pkg", "README.md")), false);
98+
assert.equal(await exists(join(appRoot, "node_modules", "keep-pkg", "index.d.ts")), false);
99+
assert.equal(await exists(join(appRoot, "node_modules", "keep-pkg", "index.js.map")), false);
100+
assert.equal(await exists(join(appRoot, "node_modules", "@phosphor-icons")), false);
101+
102+
const pdfjs = join(appRoot, "node_modules", "pdfjs-dist");
103+
assert.equal(await exists(join(pdfjs, "package.json")), true);
104+
assert.equal(await exists(join(pdfjs, "LICENSE")), true);
105+
assert.equal(await exists(join(pdfjs, "legacy", "build", "pdf.mjs")), true);
106+
assert.equal(await exists(join(pdfjs, "legacy", "build", "pdf.worker.mjs")), true);
107+
assert.equal(await exists(join(pdfjs, "cmaps", "A.bcmap")), true);
108+
assert.equal(await exists(join(pdfjs, "wasm", "jbig2.wasm")), true);
109+
assert.equal(await exists(join(pdfjs, "standard_fonts", "Fox.pfb")), true);
110+
assert.equal(await exists(join(pdfjs, "iccs", "x.icc")), true);
111+
assert.equal(await exists(join(pdfjs, "image_decoders", "pdf.image_decoders.mjs")), true);
112+
assert.equal(await exists(join(pdfjs, "README.md")), false);
113+
assert.equal(await exists(join(pdfjs, "webpack.mjs")), false);
114+
assert.equal(await exists(join(pdfjs, "build")), false);
115+
assert.equal(await exists(join(pdfjs, "web")), false);
116+
assert.equal(await exists(join(pdfjs, "types")), false);
117+
assert.equal(await exists(join(pdfjs, "legacy", "build", "pdf.mjs.map")), false);
118+
assert.equal(await exists(join(pdfjs, "legacy", "build", "pdf.min.mjs")), false);
119+
assert.equal(await exists(join(pdfjs, "legacy", "web")), false);
120+
assert.equal(await exists(join(pdfjs, "image_decoders", "pdf.image_decoders.mjs.map")), false);
121+
122+
assert.equal(await exists(join(appRoot, "node_modules", "@qoder-ai", "harness-studio", "dist", "app", "assets", "app.js")), true);
123+
assert.equal(await exists(join(appRoot, "node_modules", "@qoder-ai", "harness-studio", "dist", "app", "assets", "app.js.map")), false);
124+
125+
const runtime = await readFile(join(pdfjs, "legacy", "build", "pdf.mjs"), "utf8");
126+
assert.match(runtime, /export function getDocument/);
127+
} finally {
128+
await rm(appRoot, { recursive: true, force: true });
129+
}
130+
});

0 commit comments

Comments
 (0)