Skip to content

Commit 83f2a2c

Browse files
gitcommit90claude
andauthored
fix: ship Windows app code in asar to fit legacy Squirrel path limits (#55)
Squirrel 1.x expands every package file through 260-character .NET path APIs, during releasify and again on end-user machines under %LOCALAPPDATA%. Loose node_modules paths (deepest: nested OpenTelemetry copies under spectrum-ts at 166 characters) exceed that budget, so 0.0.36 Windows packaging failed with PathTooLongException. Windows packages now ship application code inside app.asar (a few hundred short paths instead of tens of thousands of loose files) and unpack only what must exist on real disk: scripts, container image, deploy config, public assets, the desktop directory, node-pty, and native modules. The desktop shell translates HELM_APP_ROOT, the working directory, and the Squirrel uninstall helper to the unpacked tree so Python, PowerShell, WSL, and plain-Node consumers keep reading real files, while the server itself is imported from inside the archive so dependency resolution stays there. The photon sidecar, a plain Node child process that cannot open asar archives, is bundled self-contained during npm run build and started from the unpacked tree in packaged builds. macOS, Linux, and development startup paths are unchanged. Verified by packaging the real app for Linux with the identical asar configuration: 581 unpacked files with a longest relative path of 118 characters, and the packaged app booted and served /api/setup/status. Release 0.0.37: version, changelog, and channel-machine pins. Co-authored-by: Joseph Yaksich <gitcommit90@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3b7ebd7 commit 83f2a2c

12 files changed

Lines changed: 149 additions & 24 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ data-*/
44
public/bundle.js
55
public/bundle.css
66
public/app.css
7+
desktop/photon-sidecar.bundle.mjs
78
public/excalidraw/
89
*.log
910
.DS_Store

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.37] - 2026-08-02
11+
12+
### Fixed
13+
14+
- Fixed Windows packaging failing with `PathTooLongException` during Squirrel
15+
releasify. Application code now ships inside `app.asar`, so legacy Squirrel
16+
handles a few hundred short paths instead of tens of thousands of deeply
17+
nested loose dependency files — both while building the installer and while
18+
installing or updating on end-user machines, where the same 260-character
19+
.NET path limit applies under `%LOCALAPPDATA%` regardless of username
20+
length.
21+
- Assets consumed by external processes stay on real disk next to the archive:
22+
the WSL setup script, OCI runtime and container image, deploy configuration,
23+
public assets, the Squirrel uninstall helper, and native terminal modules.
24+
- The Photon sidecar, which runs as a plain Node child process and cannot read
25+
modules inside an asar archive, is now built as a single self-contained
26+
bundle during `npm run build` and started from the unpacked tree in packaged
27+
Windows builds. macOS, Linux, and development startup paths are unchanged.
28+
1029
## [0.0.36] - 2026-08-02
1130

1231
### Fixed
@@ -1001,6 +1020,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10011020
Application Support, and isolated Apple container machines.
10021021

10031022
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.36...HEAD
1023+
[0.0.37]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.37
10041024
[0.0.36]: https://github.com/gitcommit90/1Helm/compare/v0.0.35...v0.0.36
10051025
[0.0.35]: https://github.com/gitcommit90/1Helm/compare/v0.0.34...v0.0.35
10061026
[0.0.34]: https://github.com/gitcommit90/1Helm/compare/v0.0.33...v0.0.34

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
313313
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
314314
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. |
315315
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. |
316-
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.36` | Versioned channel-machine image contract. |
316+
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.37` | Versioned channel-machine image contract. |
317317

318318
### Agent-first JSON CLI
319319

desktop/main.cjs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ function rememberDesktopMode(mode) {
4141
fs.writeFileSync(desktopModePath(), `${mode}\n`, { mode: 0o600 });
4242
}
4343

44+
// Windows packages ship application code inside app.asar; assets consumed by
45+
// external processes (Python, PowerShell, WSL, plain-Node sidecars) are
46+
// unpacked beside the archive. Translate paths for those consumers. Loose
47+
// packages (macOS, Linux, development) pass through unchanged.
48+
function unpackedPath(target) {
49+
return String(target).replace(/app\.asar(?=[\\/]|$)/, "app.asar.unpacked");
50+
}
51+
4452
function handleSquirrelEvent() {
4553
if (process.platform !== "win32") return false;
4654
const event = process.argv[1];
@@ -53,7 +61,7 @@ function handleSquirrelEvent() {
5361
} else if (event === "--squirrel-uninstall") {
5462
const dataRoot = app.getPath("userData");
5563
const wslRoot = path.join(String(process.env.LOCALAPPDATA || ""), "1Helm-Runtime");
56-
const cleanup = path.resolve(__dirname, "..", "scripts", "windows-removal.cjs");
64+
const cleanup = unpackedPath(path.resolve(__dirname, "..", "scripts", "windows-removal.cjs"));
5765
spawnSync(process.execPath, [cleanup, dataRoot, wslRoot], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: "ignore", windowsHide: true, timeout: 10 * 60_000 });
5866
spawnSync(updateExe, ["--removeShortcut", exe], { stdio: "ignore", windowsHide: true });
5967
}
@@ -114,16 +122,21 @@ async function waitForServer(origin, timeoutMs = SERVER_READY_TIMEOUT_MS) {
114122

115123
async function startLocalRuntime() {
116124
const appRoot = app.getAppPath();
125+
// Server code is imported from inside app.asar so dependency resolution
126+
// stays within the archive, but HELM_APP_ROOT and the working directory
127+
// must be the real on-disk root: scripts, container assets, deploy config,
128+
// and public files are read by external processes that cannot open asar.
129+
const assetRoot = unpackedPath(appRoot);
117130
const port = await freePort();
118131
process.env.HELM_DESKTOP = "1";
119-
process.env.HELM_APP_ROOT = appRoot;
132+
process.env.HELM_APP_ROOT = assetRoot;
120133
process.env.HELM_RESOURCES_PATH = process.resourcesPath;
121134
process.env.HELM_HOST = LOOPBACK;
122135
process.env.PORT = String(port);
123136
process.env.CTRL_DATA_DIR = app.getPath("userData");
124137
if (process.platform !== "win32") process.env.SHELL ||= "/bin/zsh";
125138
process.env.HELM_INTERNAL_WAKE_TOKEN ||= crypto.randomBytes(32).toString("hex");
126-
process.chdir(appRoot);
139+
process.chdir(assetRoot);
127140
localOrigin = `http://${LOOPBACK}:${port}`;
128141
await import(pathToFileURL(path.join(appRoot, "src", "server", "index.ts")).href);
129142
await waitForServer(localOrigin);

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "1helm",
33
"productName": "1Helm",
4-
"version": "0.0.36",
4+
"version": "0.0.37",
55
"private": true,
66
"type": "module",
77
"license": "AGPL-3.0-only",
@@ -21,7 +21,8 @@
2121
"build:css": "tailwindcss -i src/client/styles.css -o public/app.css --minify",
2222
"build:stamp": "node src/build/stamp.mjs",
2323
"build:excalidraw": "node scripts/copy-excalidraw-assets.mjs",
24-
"build": "npm run build:excalidraw && npm run build:js && npm run build:css && npm run build:stamp",
24+
"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);\"",
25+
"build": "npm run build:excalidraw && npm run build:js && npm run build:css && npm run build:sidecar && npm run build:stamp",
2526
"mobile:sync": "node scripts/sync-mobile-version.mjs && npm run assets:mobile && npm run build && cap sync",
2627
"mobile:check": "npm run typecheck && node --test test/mobile.mjs && cap doctor android",
2728
"start": "node --disable-warning=ExperimentalWarning src/server/index.ts",

scripts/package-windows.cjs

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,18 @@ function prepareCloudflared(destination) {
8080
}
8181

8282
async function main() {
83+
// The photon sidecar bundle is produced by `npm run build`. Fail before
84+
// packaging rather than shipping an app whose sidecar cannot start.
85+
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.");
8386
fs.mkdirSync(DIST, { recursive: true });
8487
const iconRoot = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-win-icon-"));
8588
// Squirrel 1.x expands every package file through legacy .NET APIs limited
86-
// to 260-character paths. A short release directory alone is insufficient:
87-
// its packages/app-version staging tree still includes the packaged app's
88-
// full relative paths. Build both the app and installer in one fresh,
89-
// drive-root scratch directory, then retain only canonical artifacts.
89+
// to 260-character paths, both while releasifying here and while installing
90+
// or updating on end-user machines under %LOCALAPPDATA%. Loose node_modules
91+
// exceed that budget, so application code ships inside app.asar and only
92+
// assets consumed by external processes are unpacked (see the asar option
93+
// below). The drive-root scratch directory still keeps Squirrel's own
94+
// staging prefixes short for the unpacked remainder.
9095
const windowsScratch = fs.mkdtempSync(path.join(path.parse(ROOT).root, "1hw-"));
9196
const ico = path.join(iconRoot, "1Helm.ico");
9297
try {
@@ -100,7 +105,16 @@ async function main() {
100105
const [appDir] = await packager({
101106
dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich",
102107
win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT },
103-
platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, asar: false, icon: ico,
108+
platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, icon: ico,
109+
// Everything importable stays inside app.asar so legacy Squirrel sees a
110+
// handful of short paths instead of tens of thousands of loose files.
111+
// Unpacked: assets read by external processes (PowerShell, Python, WSL,
112+
// the plain-Node photon sidecar, Squirrel's uninstall hook) plus native
113+
// modules that must exist on disk to load.
114+
asar: {
115+
unpack: "**/*.node",
116+
unpackDir: "{scripts,container,deploy,public,desktop,node_modules/node-pty}",
117+
},
104118
ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, IGNORE_INSTRUCTION_FILES, /\.DS_Store$/, /\.log$/],
105119
});
106120
const appExe = path.join(appDir, "1Helm.exe");
@@ -109,21 +123,28 @@ async function main() {
109123
prepareCloudflared(cloudflared);
110124
if (!fs.existsSync(cloudflared)) throw new Error("Packaged Windows app is missing cloudflared.exe.");
111125
signPackagedExecutables(appDir);
112-
const pty = path.join(appDir, "resources", "app", "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node");
126+
const asarArchive = path.join(appDir, "resources", "app.asar");
127+
if (!fs.existsSync(asarArchive)) throw new Error("Packaged Windows app is missing app.asar.");
128+
const unpackedRoot = path.join(appDir, "resources", "app.asar.unpacked");
129+
const pty = path.join(unpackedRoot, "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node");
113130
if (!fs.existsSync(pty)) throw new Error("Packaged Windows app is missing the x64 terminal module.");
131+
const sidecarBundle = path.join(unpackedRoot, "desktop", "photon-sidecar.bundle.mjs");
132+
if (!fs.existsSync(sidecarBundle)) throw new Error("Packaged Windows app is missing the self-contained photon sidecar bundle.");
114133
if (capture("where.exe", ["dumpbin.exe"])) {
115134
const headers = capture("dumpbin.exe", ["/headers", appExe]);
116135
if (!/machine \(x64\)/i.test(headers)) throw new Error("Packaged Windows application is not x64.");
117136
}
118137
if (capture("where.exe", ["powershell.exe"])) {
119-
const script = path.join(appDir, "resources", "app", "scripts", "install-wsl-runtime.ps1");
138+
const script = path.join(unpackedRoot, "scripts", "install-wsl-runtime.ps1");
120139
if (!fs.existsSync(script)) throw new Error("Packaged Windows app is missing its WSL setup script.");
121140
for (const required of [
122-
path.join(appDir, "resources", "app", "scripts", "1helm-oci-runtime"),
123-
path.join(appDir, "resources", "app", "deploy", "1helm-oci-runtime-v1.conf"),
124-
path.join(appDir, "resources", "app", "container", "Containerfile.oci"),
125-
path.join(appDir, "resources", "app", "container", "channel-machine.oci.tar"),
126-
path.join(appDir, "resources", "app", "container", "channel-machine.oci.sha256"),
141+
path.join(unpackedRoot, "scripts", "1helm-oci-runtime"),
142+
path.join(unpackedRoot, "scripts", "mnemosyne-bridge.py"),
143+
path.join(unpackedRoot, "scripts", "windows-removal.cjs"),
144+
path.join(unpackedRoot, "deploy", "1helm-oci-runtime-v1.conf"),
145+
path.join(unpackedRoot, "container", "Containerfile.oci"),
146+
path.join(unpackedRoot, "container", "channel-machine.oci.tar"),
147+
path.join(unpackedRoot, "container", "channel-machine.oci.sha256"),
127148
]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`);
128149
}
129150

src/server/channel-computers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0";
6868
export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`;
6969
export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`;
7070
export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714";
71-
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.36";
71+
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.37";
7272
const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[];
7373
const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1";
7474
const OCI_HELPER_CANDIDATES = [

src/server/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,7 @@ export function migrate(): void {
939939
const platformBackend = process.platform === "darwin" ? "apple" : "oci";
940940
const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend);
941941
const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend;
942-
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.36");
942+
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.37");
943943
for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id
944944
WHERE c.kind='channel' AND c.status<>'deleted'`)) {
945945
const channelId = Number(channel.id);

src/server/photon.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ import { agentForChannel, ensureThread, refreshThreadSummary } from "./agents.ts
1212
const CREDENTIAL_FILE = join(DATA_DIR, "photon-credentials.json");
1313
const SIDECAR = new URL("./photon-sidecar.mjs", import.meta.url);
1414
const E164 = /^\+\d{6,15}$/;
15+
16+
// The sidecar runs as a plain Node child process, which cannot read modules
17+
// packed inside app.asar. Packaged asar builds ship a self-contained bundle
18+
// under the unpacked desktop directory; loose packages use the source module.
19+
function sidecarEntry(): string {
20+
const override = String(process.env.PHOTON_SIDECAR_PATH || "");
21+
if (override) return override;
22+
const source = fileURLToPath(SIDECAR);
23+
if (!/app\.asar[\\/]/.test(source)) return source;
24+
return join(String(process.env.HELM_APP_ROOT || process.cwd()), "desktop", "photon-sidecar.bundle.mjs");
25+
}
1526
type PhotonCredential = { project_id: string; project_secret: string; operator_phone: string; assigned_phone: string; dashboard_token?: string; configured_at: number };
1627
type PhotonEvent = { id: string; space_id: string; space_type: string; sender: string; text: string; timestamp: string };
1728
type PhotonDispatch = (bot: Row, channelId: number, triggerId: number, threadRootId: number) => Promise<void>;
@@ -317,7 +328,7 @@ export async function startPhotonConnector(): Promise<void> {
317328
const value = credentials();
318329
if (!value?.project_id || !value.project_secret || child?.exitCode == null && child) return;
319330
const port = await freePort(); token = randomBytes(32).toString("hex"); base = `http://127.0.0.1:${port}`;
320-
const sidecarPath = String(process.env.PHOTON_SIDECAR_PATH || fileURLToPath(SIDECAR));
331+
const sidecarPath = sidecarEntry();
321332
const sidecarProcess: ChildProcess = spawn(process.execPath, [sidecarPath], {
322333
stdio: ["pipe", "ignore", "pipe"],
323334
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) },

0 commit comments

Comments
 (0)