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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ data-*/
public/bundle.js
public/bundle.css
public/app.css
desktop/photon-sidecar.bundle.mjs
public/excalidraw/
*.log
.DS_Store
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 16 additions & 3 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
45 changes: 33 additions & 12 deletions scripts/package-windows.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
Expand All @@ -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)}.`);
}

Expand Down
2 changes: 1 addition & 1 deletion src/server/channel-computers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion src/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion src/server/photon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down Expand Up @@ -317,7 +328,7 @@ export async function startPhotonConnector(): Promise<void> {
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) },
Expand Down
2 changes: 1 addition & 1 deletion test/channel-computers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading