diff --git a/.gitignore b/.gitignore index 2142fdd..1e800bf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ data-*/ public/bundle.js public/bundle.css public/app.css +desktop/photon-sidecar.bundle.mjs public/excalidraw/ *.log .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index babbebf..e15c780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.37] - 2026-08-02 + +### Fixed + +- Fixed Windows packaging failing with `PathTooLongException` during Squirrel + releasify. Application code now ships inside `app.asar`, so legacy Squirrel + handles a few hundred short paths instead of tens of thousands of deeply + nested loose dependency files — both while building the installer and while + installing or updating on end-user machines, where the same 260-character + .NET path limit applies under `%LOCALAPPDATA%` regardless of username + length. +- Assets consumed by external processes stay on real disk next to the archive: + the WSL setup script, OCI runtime and container image, deploy configuration, + public assets, the Squirrel uninstall helper, and native terminal modules. +- The Photon sidecar, which runs as a plain Node child process and cannot read + modules inside an asar archive, is now built as a single self-contained + bundle during `npm run build` and started from the unpacked tree in packaged + Windows builds. macOS, Linux, and development startup paths are unchanged. + ## [0.0.36] - 2026-08-02 ### Fixed @@ -1001,6 +1020,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Application Support, and isolated Apple container machines. [Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.36...HEAD +[0.0.37]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.37 [0.0.36]: https://github.com/gitcommit90/1Helm/compare/v0.0.35...v0.0.36 [0.0.35]: https://github.com/gitcommit90/1Helm/compare/v0.0.34...v0.0.35 [0.0.34]: https://github.com/gitcommit90/1Helm/compare/v0.0.33...v0.0.34 diff --git a/README.md b/README.md index 8a616fd..8e4dd1c 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.36` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.37` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/desktop/main.cjs b/desktop/main.cjs index c052e7d..62a5618 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -41,6 +41,14 @@ function rememberDesktopMode(mode) { fs.writeFileSync(desktopModePath(), `${mode}\n`, { mode: 0o600 }); } +// Windows packages ship application code inside app.asar; assets consumed by +// external processes (Python, PowerShell, WSL, plain-Node sidecars) are +// unpacked beside the archive. Translate paths for those consumers. Loose +// packages (macOS, Linux, development) pass through unchanged. +function unpackedPath(target) { + return String(target).replace(/app\.asar(?=[\\/]|$)/, "app.asar.unpacked"); +} + function handleSquirrelEvent() { if (process.platform !== "win32") return false; const event = process.argv[1]; @@ -53,7 +61,7 @@ function handleSquirrelEvent() { } else if (event === "--squirrel-uninstall") { const dataRoot = app.getPath("userData"); const wslRoot = path.join(String(process.env.LOCALAPPDATA || ""), "1Helm-Runtime"); - const cleanup = path.resolve(__dirname, "..", "scripts", "windows-removal.cjs"); + const cleanup = unpackedPath(path.resolve(__dirname, "..", "scripts", "windows-removal.cjs")); spawnSync(process.execPath, [cleanup, dataRoot, wslRoot], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: "ignore", windowsHide: true, timeout: 10 * 60_000 }); spawnSync(updateExe, ["--removeShortcut", exe], { stdio: "ignore", windowsHide: true }); } @@ -114,16 +122,21 @@ async function waitForServer(origin, timeoutMs = SERVER_READY_TIMEOUT_MS) { async function startLocalRuntime() { const appRoot = app.getAppPath(); + // Server code is imported from inside app.asar so dependency resolution + // stays within the archive, but HELM_APP_ROOT and the working directory + // must be the real on-disk root: scripts, container assets, deploy config, + // and public files are read by external processes that cannot open asar. + const assetRoot = unpackedPath(appRoot); const port = await freePort(); process.env.HELM_DESKTOP = "1"; - process.env.HELM_APP_ROOT = appRoot; + process.env.HELM_APP_ROOT = assetRoot; process.env.HELM_RESOURCES_PATH = process.resourcesPath; process.env.HELM_HOST = LOOPBACK; process.env.PORT = String(port); process.env.CTRL_DATA_DIR = app.getPath("userData"); if (process.platform !== "win32") process.env.SHELL ||= "/bin/zsh"; process.env.HELM_INTERNAL_WAKE_TOKEN ||= crypto.randomBytes(32).toString("hex"); - process.chdir(appRoot); + process.chdir(assetRoot); localOrigin = `http://${LOOPBACK}:${port}`; await import(pathToFileURL(path.join(appRoot, "src", "server", "index.ts")).href); await waitForServer(localOrigin); diff --git a/package-lock.json b/package-lock.json index 6ecdcd0..3802d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.36", + "version": "0.0.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.36", + "version": "0.0.37", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 4ba988b..e3c456c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.36", + "version": "0.0.37", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -21,7 +21,8 @@ "build:css": "tailwindcss -i src/client/styles.css -o public/app.css --minify", "build:stamp": "node src/build/stamp.mjs", "build:excalidraw": "node scripts/copy-excalidraw-assets.mjs", - "build": "npm run build:excalidraw && npm run build:js && npm run build:css && npm run build:stamp", + "build:sidecar": "esbuild src/server/photon-sidecar.mjs --bundle --platform=node --format=esm --outfile=desktop/photon-sidecar.bundle.mjs --minify --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\"", + "build": "npm run build:excalidraw && npm run build:js && npm run build:css && npm run build:sidecar && npm run build:stamp", "mobile:sync": "node scripts/sync-mobile-version.mjs && npm run assets:mobile && npm run build && cap sync", "mobile:check": "npm run typecheck && node --test test/mobile.mjs && cap doctor android", "start": "node --disable-warning=ExperimentalWarning src/server/index.ts", diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index da17c47..9ae662b 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -80,13 +80,18 @@ function prepareCloudflared(destination) { } async function main() { + // The photon sidecar bundle is produced by `npm run build`. Fail before + // packaging rather than shipping an app whose sidecar cannot start. + if (!fs.existsSync(path.join(ROOT, "desktop", "photon-sidecar.bundle.mjs"))) throw new Error("desktop/photon-sidecar.bundle.mjs is missing; run `npm run build` first."); fs.mkdirSync(DIST, { recursive: true }); const iconRoot = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-win-icon-")); // Squirrel 1.x expands every package file through legacy .NET APIs limited - // to 260-character paths. A short release directory alone is insufficient: - // its packages/app-version staging tree still includes the packaged app's - // full relative paths. Build both the app and installer in one fresh, - // drive-root scratch directory, then retain only canonical artifacts. + // to 260-character paths, both while releasifying here and while installing + // or updating on end-user machines under %LOCALAPPDATA%. Loose node_modules + // exceed that budget, so application code ships inside app.asar and only + // assets consumed by external processes are unpacked (see the asar option + // below). The drive-root scratch directory still keeps Squirrel's own + // staging prefixes short for the unpacked remainder. const windowsScratch = fs.mkdtempSync(path.join(path.parse(ROOT).root, "1hw-")); const ico = path.join(iconRoot, "1Helm.ico"); try { @@ -100,7 +105,16 @@ async function main() { const [appDir] = await packager({ dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich", win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT }, - platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, asar: false, icon: ico, + platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, icon: ico, + // Everything importable stays inside app.asar so legacy Squirrel sees a + // handful of short paths instead of tens of thousands of loose files. + // Unpacked: assets read by external processes (PowerShell, Python, WSL, + // the plain-Node photon sidecar, Squirrel's uninstall hook) plus native + // modules that must exist on disk to load. + asar: { + unpack: "**/*.node", + unpackDir: "{scripts,container,deploy,public,desktop,node_modules/node-pty}", + }, ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, IGNORE_INSTRUCTION_FILES, /\.DS_Store$/, /\.log$/], }); const appExe = path.join(appDir, "1Helm.exe"); @@ -109,21 +123,28 @@ async function main() { prepareCloudflared(cloudflared); if (!fs.existsSync(cloudflared)) throw new Error("Packaged Windows app is missing cloudflared.exe."); signPackagedExecutables(appDir); - const pty = path.join(appDir, "resources", "app", "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node"); + const asarArchive = path.join(appDir, "resources", "app.asar"); + if (!fs.existsSync(asarArchive)) throw new Error("Packaged Windows app is missing app.asar."); + const unpackedRoot = path.join(appDir, "resources", "app.asar.unpacked"); + const pty = path.join(unpackedRoot, "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node"); if (!fs.existsSync(pty)) throw new Error("Packaged Windows app is missing the x64 terminal module."); + const sidecarBundle = path.join(unpackedRoot, "desktop", "photon-sidecar.bundle.mjs"); + if (!fs.existsSync(sidecarBundle)) throw new Error("Packaged Windows app is missing the self-contained photon sidecar bundle."); if (capture("where.exe", ["dumpbin.exe"])) { const headers = capture("dumpbin.exe", ["/headers", appExe]); if (!/machine \(x64\)/i.test(headers)) throw new Error("Packaged Windows application is not x64."); } if (capture("where.exe", ["powershell.exe"])) { - const script = path.join(appDir, "resources", "app", "scripts", "install-wsl-runtime.ps1"); + const script = path.join(unpackedRoot, "scripts", "install-wsl-runtime.ps1"); if (!fs.existsSync(script)) throw new Error("Packaged Windows app is missing its WSL setup script."); for (const required of [ - path.join(appDir, "resources", "app", "scripts", "1helm-oci-runtime"), - path.join(appDir, "resources", "app", "deploy", "1helm-oci-runtime-v1.conf"), - path.join(appDir, "resources", "app", "container", "Containerfile.oci"), - path.join(appDir, "resources", "app", "container", "channel-machine.oci.tar"), - path.join(appDir, "resources", "app", "container", "channel-machine.oci.sha256"), + path.join(unpackedRoot, "scripts", "1helm-oci-runtime"), + path.join(unpackedRoot, "scripts", "mnemosyne-bridge.py"), + path.join(unpackedRoot, "scripts", "windows-removal.cjs"), + path.join(unpackedRoot, "deploy", "1helm-oci-runtime-v1.conf"), + path.join(unpackedRoot, "container", "Containerfile.oci"), + path.join(unpackedRoot, "container", "channel-machine.oci.tar"), + path.join(unpackedRoot, "container", "channel-machine.oci.sha256"), ]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`); } diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index a40c16c..158bf3c 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.36"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.37"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1"; const OCI_HELPER_CANDIDATES = [ diff --git a/src/server/db.ts b/src/server/db.ts index 56d4c0c..067cf48 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -939,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : "oci"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.36"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.37"); for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id WHERE c.kind='channel' AND c.status<>'deleted'`)) { const channelId = Number(channel.id); diff --git a/src/server/photon.ts b/src/server/photon.ts index fa512a0..80e9bb5 100644 --- a/src/server/photon.ts +++ b/src/server/photon.ts @@ -12,6 +12,17 @@ import { agentForChannel, ensureThread, refreshThreadSummary } from "./agents.ts const CREDENTIAL_FILE = join(DATA_DIR, "photon-credentials.json"); const SIDECAR = new URL("./photon-sidecar.mjs", import.meta.url); const E164 = /^\+\d{6,15}$/; + +// The sidecar runs as a plain Node child process, which cannot read modules +// packed inside app.asar. Packaged asar builds ship a self-contained bundle +// under the unpacked desktop directory; loose packages use the source module. +function sidecarEntry(): string { + const override = String(process.env.PHOTON_SIDECAR_PATH || ""); + if (override) return override; + const source = fileURLToPath(SIDECAR); + if (!/app\.asar[\\/]/.test(source)) return source; + return join(String(process.env.HELM_APP_ROOT || process.cwd()), "desktop", "photon-sidecar.bundle.mjs"); +} type PhotonCredential = { project_id: string; project_secret: string; operator_phone: string; assigned_phone: string; dashboard_token?: string; configured_at: number }; type PhotonEvent = { id: string; space_id: string; space_type: string; sender: string; text: string; timestamp: string }; type PhotonDispatch = (bot: Row, channelId: number, triggerId: number, threadRootId: number) => Promise; @@ -317,7 +328,7 @@ export async function startPhotonConnector(): Promise { const value = credentials(); if (!value?.project_id || !value.project_secret || child?.exitCode == null && child) return; const port = await freePort(); token = randomBytes(32).toString("hex"); base = `http://127.0.0.1:${port}`; - const sidecarPath = String(process.env.PHOTON_SIDECAR_PATH || fileURLToPath(SIDECAR)); + const sidecarPath = sidecarEntry(); const sidecarProcess: ChildProcess = spawn(process.execPath, [sidecarPath], { stdio: ["pipe", "ignore", "pipe"], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", PHOTON_PROJECT_ID: value.project_id, PHOTON_PROJECT_SECRET: value.project_secret, PHOTON_SIDECAR_TOKEN: token, PHOTON_SIDECAR_PORT: String(port) }, diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 8c21a13..ef8b6a6 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -181,7 +181,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.36"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.37"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/desktop.mjs b/test/desktop.mjs index 0bb29ac..89144a1 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -319,3 +319,61 @@ test("release packaging is fail-closed and records stable product identity", asy assert.match(source, /Cloudflared binary checksum does not match the release pin/, "release packaging verifies the exact upstream tunnel connector before signing"); assert.match(source, /codesign", \["--verify", "--strict", "--verbose=2", cloudflared\]/, "release packaging verifies the bundled connector's post-signing seal"); }); + +test("Windows packaging ships app code in asar and keeps external consumers on real files", async (t) => { + const windowsPackager = await readFile(join(root, "scripts", "package-windows.cjs"), "utf8"); + const mainCjs = await readFile(join(root, "desktop", "main.cjs"), "utf8"); + const photon = await readFile(join(root, "src", "server", "photon.ts"), "utf8"); + const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")); + + // Legacy Squirrel expands every loose package file through 260-character + // .NET path APIs while releasifying and again on end-user machines during + // install/update. Application code must ship inside app.asar. + assert.doesNotMatch(windowsPackager, /asar: false/, "loose-file Windows packaging exceeds legacy Squirrel's path budget"); + assert.match(windowsPackager, /unpack: "\*\*\/\*\.node"/, "native modules are unpacked so they can load from disk"); + assert.match(windowsPackager, /unpackDir: "\{scripts,container,deploy,public,desktop,node_modules\/node-pty\}"/, "assets read by external processes stay on real disk"); + assert.match(windowsPackager, /missing app\.asar/, "packaging proves the archive was actually created"); + assert.match(windowsPackager, /app\.asar\.unpacked/, "packaging verifies required assets in the unpacked tree"); + assert.match(windowsPackager, /photon-sidecar\.bundle\.mjs is missing/, "packaging refuses to run without the sidecar bundle"); + assert.ok(String(pkg.scripts.build).includes("build:sidecar"), "the standard build produces the sidecar bundle"); + assert.ok(String(pkg.scripts["build:sidecar"]).includes("--bundle"), "the sidecar bundle is self-contained"); + + assert.match(mainCjs, /function unpackedPath\(/, "the desktop shell can translate asar paths for external consumers"); + assert.match(mainCjs, /const assetRoot = unpackedPath\(appRoot\)/, "external-asset root leaves the asar in packaged builds"); + assert.match(mainCjs, /process\.env\.HELM_APP_ROOT = assetRoot/, "server asset consumers receive the real on-disk root"); + assert.match(mainCjs, /process\.chdir\(assetRoot\)/, "chdir into a virtual asar path would throw at startup"); + assert.match(mainCjs, /unpackedPath\(path\.resolve\(__dirname, "\.\.", "scripts", "windows-removal\.cjs"\)\)/, "the Squirrel uninstall hook runs the removal script from real disk"); + assert.ok(photon.includes('join(String(process.env.HELM_APP_ROOT || process.cwd()), "desktop", "photon-sidecar.bundle.mjs")'), "asar builds start the sidecar from the unpacked self-contained bundle"); + + if (process.platform === "win32") return; + // Prove the sidecar bundle actually builds and resolves every module on its + // own: run it from a foreign directory with no credentials and require an + // application-level outcome, never a module-resolution failure. + const bundleDir = await mkdtemp(join(tmpdir(), "1helm-sidecar-bundle-")); + t.after(() => rm(bundleDir, { recursive: true, force: true })); + const outfile = join(bundleDir, "photon-sidecar.bundle.mjs"); + await new Promise((resolveExit, rejectExit) => { + const build = spawn(join(root, "node_modules", ".bin", "esbuild"), [ + "src/server/photon-sidecar.mjs", "--bundle", "--platform=node", "--format=esm", `--outfile=${outfile}`, + "--banner:js=import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + ], { cwd: root, stdio: ["ignore", "ignore", "pipe"] }); + let errors = ""; + build.stderr.on("data", (chunk) => { errors += chunk; }); + build.once("exit", (code) => code === 0 ? resolveExit(null) : rejectExit(new Error(`sidecar bundle failed: ${errors.slice(-2000)}`))); + }); + const result = await new Promise((resolveExit) => { + const child = spawn(process.execPath, [outfile], { + cwd: bundleDir, + env: { ...process.env, PHOTON_SIDECAR_PORT: "0", PHOTON_SIDECAR_TOKEN: "test-token", PHOTON_PROJECT_ID: "", PHOTON_PROJECT_SECRET: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + const timer = setTimeout(() => { child.kill("SIGKILL"); resolveExit(output); }, 8_000); + child.once("exit", () => { clearTimeout(timer); resolveExit(output); }); + }); + for (const signature of ["ERR_MODULE_NOT_FOUND", "Cannot find module", "Cannot find package", "Dynamic require of"]) { + assert.ok(!result.includes(signature), `sidecar bundle is not self-contained: ${result.slice(0, 400)}`); + } +});