diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 48ee150a9f6..bd9bcdeca8b 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -1,7 +1,15 @@ import { expect, test } from "bun:test" +import { statSync } from "node:fs" +import { cp, mkdtemp, rm } from "node:fs/promises" +import { createRequire } from "node:module" +import os from "node:os" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import type { Configuration } from "electron-builder" const legacyDesktopEntry = "resources/linux/opencode-desktop.desktop" +// Use electron-builder's matcher so the tests also cover its glob and directory traversal semantics. +const { FileMatcher } = createRequire(import.meta.resolve("electron-builder"))("app-builder-lib/out/fileMatcher") const channels = [ { channel: "dev", appId: "ai.opencode.desktop.dev" }, @@ -27,8 +35,105 @@ for (const channel of channels) { expect(config.deb?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`)) expect(config.rpm?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`)) }) + + test(`trims external dependencies without excluding runtime files for ${channel.channel}`, async () => { + const config = (await import(`./electron-builder.config.ts?channel=${channel.channel}`)).default as Configuration + const filter = new FileMatcher(import.meta.dirname, "", (value: string) => value, [ + "**/*", + ...(Array.isArray(config.files) ? config.files : []).filter( + (value): value is string => typeof value === "string" && value.startsWith("!"), + ), + ]).createFilter() + for (const prefix of ["node_modules/", "node_modules/parent/node_modules/"]) { + for (const file of [ + "@zip.js/zip.js/dist/zip.js", + "@zip.js/zip.js/dist/z-worker.js", + "@zip.js/zip.js/index.cjs", + "@zip.js/zip.js/index.min.js", + "@zip.js/zip.js/index-fflate.js", + "@zip.js/zip.js/deno.json", + "@zip.js/zip.js/eslint.config.mjs", + "electron-updater/out/main.js.map", + "electron-updater/out/providers/GitHubProvider.js.map", + "builder-util-runtime/out/httpExecutor.js.map", + "lazy-val/out/main.js.map", + "ajv/lib/core.ts", + "ajv/dist/compile/index.js.map", + "ajv-formats/src/formats.ts", + "ajv-formats/dist/formats.js.map", + "js-yaml/dist/js-yaml.js", + "js-yaml/dist/js-yaml.min.js", + "js-yaml/dist/js-yaml.mjs.map", + "js-yaml/bin/js-yaml.js", + ]) { + expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(false) + } + for (const file of [ + "@zip.js/zip.js/index.js", + "@zip.js/zip.js/lib/zip-fs.js", + "@zip.js/zip.js/lib/z-worker-inline.js", + "@zip.js/zip.js/lib/core/streams/codecs/deflate.js", + "electron-updater/out/main.js", + "electron-updater/out/MacUpdater.js", + "electron-updater/out/NsisUpdater.js", + "electron-updater/out/providers/GitHubProvider.js", + "builder-util-runtime/out/httpExecutor.js", + "lazy-val/out/main.js", + "ajv/dist/ajv.js", + "ajv/dist/refs/json-schema-draft-07.json", + "ajv-formats/dist/formats.js", + "js-yaml/index.js", + "js-yaml/lib/loader.js", + "js-yaml/dist/js-yaml.mjs", + "debug/src/index.js", + "unrelated/dist/index.js.map", + ...["@zip.js/zip.js", "electron-updater", "builder-util-runtime", "ajv", "ajv-formats", "js-yaml"].flatMap( + (name) => [`${name}/package.json`, `${name}/LICENSE`], + ), + ]) { + expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(true) + } + expect(filter(path.join(import.meta.dirname, prefix, "@zip.js/zip.js/dist"), statSync(import.meta.dirname))).toBe( + false, + ) + expect(filter(path.join(import.meta.dirname, prefix, "@zip.js/zip.js/lib"), statSync(import.meta.dirname))).toBe( + true, + ) + } + }) } +test("the trimmed Zip.js package can still export compressed logs", async () => { + const config = (await import("./electron-builder.config.ts")).default + const dir = await mkdtemp(path.join(os.tmpdir(), "opencode-zip-package-")) + const source = path.dirname(fileURLToPath(import.meta.resolve("@zip.js/zip.js/package.json"))) + const filter = new FileMatcher(dir, "", (value: string) => value, [ + "**/*", + ...(Array.isArray(config.files) ? config.files : []).filter( + (value): value is string => typeof value === "string" && value.startsWith("!"), + ), + ]).createFilter() + try { + await cp(source, dir, { + recursive: true, + filter: (file) => + filter(path.join(dir, "node_modules/@zip.js/zip.js", path.relative(source, file)), statSync(file)), + }) + const zip = await import(pathToFileURL(path.join(dir, "index.js")).href) + const writer = new zip.ZipWriter(new zip.BlobWriter("application/zip")) + await writer.add("desktop.log", new zip.BlobReader(new Blob(["diagnostic log\n".repeat(100)]))) + const reader = new zip.ZipReader(new zip.BlobReader(await writer.close())) + const entries = await reader.getEntries() + expect(entries.map((entry: { filename: string }) => entry.filename)).toEqual(["desktop.log"]) + expect(entries[0].compressionMethod).toBe(8) + expect(await entries[0].getData(new zip.TextWriter())).toBe("diagnostic log\n".repeat(100)) + await reader.close() + await zip.terminateWorkers() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + test("keeps a hidden prod launcher for old Linux pins", async () => { const previous = process.env.OPENCODE_CHANNEL process.env.OPENCODE_CHANNEL = "prod" diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index de59c2910e0..de7aa34495f 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -55,7 +55,22 @@ const getBase = (appId: string): Configuration => ({ extraMetadata: { desktopName: `${appId}.desktop`, }, - files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], + files: [ + "out/**/*", + "resources/**/*", + "!resources/opencode-cli*", + // Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker. + "!**/node_modules/@zip.js/zip.js/dist{,/**/*}", + "!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}", + // These packages execute compiled JavaScript, not their sources or source maps. + "!**/node_modules/{electron-updater,builder-util-runtime,lazy-val}/out/**/*.js.map", + "!**/node_modules/ajv/lib{,/**/*}", + "!**/node_modules/ajv-formats/src{,/**/*}", + "!**/node_modules/{ajv,ajv-formats}/dist/**/*.js.map", + // Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI. + "!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}", + "!**/node_modules/js-yaml/bin{,/**/*}", + ], extraResources: channel !== "prod" ? [ diff --git a/packages/desktop/electron.vite.config.test.ts b/packages/desktop/electron.vite.config.test.ts index ca154b79c57..0abdff38f49 100644 --- a/packages/desktop/electron.vite.config.test.ts +++ b/packages/desktop/electron.vite.config.test.ts @@ -72,6 +72,7 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e expect(new Set(effect).size).toBe(effect.length) expect(imports).toContain("electron") expect(imports).toContain("node:sqlite") + expect(chunks.some((chunk) => chunk.dynamicImports.includes("@zip.js/zip.js"))).toBe(true) expect(imports).toContain(`@lydell/node-pty-${process.platform}-${process.arch}`) expect(modules.some((id) => id.includes("/node_modules/msgpackr-extract/"))).toBe(false) expect(chunks.some((chunk) => chunk.code.includes("msgpackr-extract"))).toBe(true)