diff --git a/README.md b/README.md index deb630ad..1c28af0d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## What is this? -**Paperclip Desktop** is an unofficial [Electron](https://www.electronjs.org/) wrapper around [Paperclip](https://github.com/paperclipai/paperclip). It bundles Paperclip (via the official `@paperclipai/server` npm package) inside a native desktop app for macOS. Windows and Linux builds are coming soon. +**Paperclip Desktop** is an unofficial [Electron](https://www.electronjs.org/) wrapper around [Paperclip](https://github.com/paperclipai/paperclip). It bundles Paperclip (via the official `@paperclipai/server` npm package) inside a native desktop app for macOS, Windows, and Linux. The goal is simple: **make running Paperclip as easy as opening an app.** @@ -27,8 +27,7 @@ The goal is simple: **make running Paperclip as easy as opening an app.** - 📦 **Paperclip inside** — ships an unmodified build of the upstream Paperclip server and UI. What you get in the app is exactly what you'd get by cloning and running the main repo. - 🔄 **Auto-updates** — pulls new desktop releases automatically via GitHub Releases. - 🖥 **Native menus, windowing, and system tray** — the Paperclip UI, but as a real desktop app. - -Under the hood, the app can either: +- 🪟 **Cross-platform** — macOS (Apple Silicon + Intel), Windows (x64), and Linux (x64). 1. Run the embedded local server flow 2. Or verify a remote Paperclip origin via `/api/health` and `/api/auth/get-session` before loading it in a restricted remote-safe Electron window @@ -59,15 +58,11 @@ This is an **independent community distribution**. It is not an official Papercl Download the latest installer for your platform from the [Releases page](https://github.com/aronprins/paperclip-desktop/releases): - **macOS** — `.dmg` (Apple Silicon and Intel) -- **Windows** — _coming soon_ -- **Linux** — _coming soon_ +- **Windows** — `.exe` NSIS installer (x64) +- **Linux** — `.AppImage` or `.deb` (x64) Open the app. Paperclip starts automatically and the dashboard opens in the app window. That's it. -### Prefer the original? - -If you'd rather run Paperclip yourself from source (no desktop wrapper), follow the upstream instructions: - ```bash npx paperclipai onboard --yes ``` @@ -99,6 +94,8 @@ pnpm build-ui # Stage the Paperclip UI pnpm pack # Build an unpacked app directory (no installer) pnpm dist # Build full installers for the current platform pnpm dist:mac # macOS (.dmg + .zip, signed/notarized via local script) +pnpm dist:win # Windows (.exe NSIS installer + portable) +pnpm dist:linux # Linux (.AppImage + .deb) ``` Key files: @@ -108,8 +105,8 @@ Key files: - `src/launcher-html.ts` — Internal launcher UI for the chooser, remote connect flow, saved connections, and local boot states - `src/preload.ts` — Preload script for the renderer - `src/updater.ts` — Auto-update wiring (`electron-updater` against GitHub Releases) -- `electron-builder.yml` — Packaging config; bundles the `@paperclipai/server` npm package plus a platform-specific Node.js binary into `Resources/app-server/` -- `scripts/` — Build and release automation (server staging, macOS notarization, etc.) +- `electron-builder.yml` — Packaging config; bundles the `@paperclipai/server` npm package plus a platform-specific Node.js binary +- `scripts/` — Build and release automation (server staging, packaging, notarization)
diff --git a/package.json b/package.json index 781a24e7..cad6a0e5 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "onlyBuiltDependencies": [ "@embedded-postgres/darwin-arm64", "@embedded-postgres/darwin-x64", + "@embedded-postgres/linux-x64", + "@embedded-postgres/win32-x64", "electron", "electron-winstaller", "esbuild" diff --git a/scripts/build-ui.mjs b/scripts/build-ui.mjs index d359c0da..93c94258 100644 --- a/scripts/build-ui.mjs +++ b/scripts/build-ui.mjs @@ -11,7 +11,7 @@ * be simplified to just copy from node_modules. */ -import { execFileSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, rmSync, cpSync, readdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -127,10 +127,19 @@ if (!cloneSuccess) { // ── Install dependencies and build UI ─────────────────────────────────────── console.log("[build-ui] Installing upstream dependencies..."); -execFileSync("pnpm", ["install", "--frozen-lockfile"], { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +const isWindows = process.platform === "win32"; +if (isWindows) { + execSync("pnpm install --frozen-lockfile", { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +} else { + execFileSync("pnpm", ["install", "--frozen-lockfile"], { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +} console.log("[build-ui] Building UI..."); -execFileSync("pnpm", ["--filter", "@paperclipai/ui", "build"], { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +if (isWindows) { + execSync("pnpm --filter @paperclipai/ui build", { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +} else { + execFileSync("pnpm", ["--filter", "@paperclipai/ui", "build"], { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +} // ── Copy UI dist to server bundle ─────────────────────────────────────────── diff --git a/scripts/prepare-server.mjs b/scripts/prepare-server.mjs index 37147175..58c02a5e 100644 --- a/scripts/prepare-server.mjs +++ b/scripts/prepare-server.mjs @@ -6,7 +6,7 @@ * into the app. */ -import { execFileSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { cpSync, existsSync, @@ -183,20 +183,14 @@ for (const arch of targetArches) { ), ); - execFileSync( - "npm", - ["install", "--production", `--os=${nodePlatform}`, `--cpu=${arch}`, `--arch=${arch}`], - { - cwd: stagingDir, - stdio: "inherit", - env: { - ...process.env, - npm_config_arch: arch, - npm_config_platform: nodePlatform, - npm_config_target_arch: arch, - }, - }, - ); + const npmInstallCmd = `npm install --production --os=${nodePlatform} --cpu=${arch} --arch=${arch}`; + if (process.platform === "win32") { + execSync(npmInstallCmd, { cwd: stagingDir, stdio: "inherit", env: { ...process.env, npm_config_arch: arch, npm_config_platform: nodePlatform, npm_config_target_arch: arch } }); + } else { + execFileSync("npm", ["install", "--production", `--os=${nodePlatform}`, `--cpu=${arch}`, `--arch=${arch}`], { + cwd: stagingDir, stdio: "inherit", env: { ...process.env, npm_config_arch: arch, npm_config_platform: nodePlatform, npm_config_target_arch: arch }, + }); + } console.log(`[prepare-server] Assembling server bundle for ${variant}...`); mkdirSync(bundleServerDir, { recursive: true }); @@ -255,21 +249,13 @@ for (const arch of arches) { console.log(`[prepare-server] Downloading Node ${NODE_VERSION} for ${nodeDownloadPlatform}-${arch}...`); if (platform === "win32") { - execFileSync( - "powershell", - ["-NoProfile", "-Command", "Invoke-WebRequest -Uri $args[0] -OutFile $args[1]", url, archivePath], - { stdio: "inherit" }, - ); + execSync(`powershell -NoProfile -Command "Invoke-WebRequest -Uri '${url}' -OutFile '${archivePath}'"`, { stdio: "inherit" }); } else { execFileSync("curl", ["-fsSL", "-o", archivePath, url], { stdio: "inherit" }); } if (platform === "win32") { - execFileSync( - "powershell", - ["-NoProfile", "-Command", "Expand-Archive -Path $args[0] -DestinationPath $args[1] -Force", archivePath, destDir], - { stdio: "inherit" }, - ); + execSync(`powershell -NoProfile -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { stdio: "inherit" }); cpSync(path.join(destDir, archiveName, "node.exe"), destBin); rmSync(path.join(destDir, archiveName), { recursive: true, force: true }); } else { diff --git a/src/main.ts b/src/main.ts index d59a6ea3..f112f49a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -261,6 +261,8 @@ function findNodeBinary(): string { // ignore } + + // macOS / Linux fallbacks const candidates: string[] = []; const home = process.env.HOME ?? ""; const nvmDir = process.env.NVM_DIR ?? path.join(home, ".nvm"); @@ -287,6 +289,11 @@ function findNodeBinary(): string { } function resolveShellPath(): string { + // ponytail: on Windows, process.env.PATH is authoritative; no login shell needed. + if (process.platform === "win32") { + return process.env.PATH ?? ""; + } + const fallbackDirs = [ "/usr/local/bin", "/opt/homebrew/bin", @@ -333,6 +340,7 @@ function resolveShellPath(): string { : basePath; } + function getPidFilePath(): string { return path.join(app.getPath("userData"), PID_FILE_NAME); } @@ -475,7 +483,7 @@ function startServer(port: number): ChildProcess { stdio: ["ignore", "pipe", "pipe"], detached: !isWindows, }) - : spawn("node", [path.join(root, "node_modules", "@paperclipai", "server", "dist", "index.js")], { + : spawn("node", [path.join(root, "build", "server-bundle", `${process.platform === "win32" ? "win" : process.platform}-${process.arch}`, "server", "dist", "index.js")], { cwd: root, env: { ...process.env, @@ -1521,6 +1529,7 @@ function registerLauncherIpc(): void { // --------------------------------------------------------------------------- function rebuildAppMenu(): void { + if (process.platform !== "darwin") return; const template: MenuItemConstructorOptions[] = [ { label: "Paperclip", @@ -1676,7 +1685,11 @@ app.whenReady().then(async () => { killOrphanedServer(); connectionStore = new ConnectionStore(getConnectionsFilePath(app.getPath("userData"))); registerLauncherIpc(); - rebuildAppMenu(); + if (process.platform === "darwin") { + rebuildAppMenu(); + } else { + Menu.setApplicationMenu(null); + } const startupProfileId = connectionStore.getStartupProfileId(); if (startupProfileId) { @@ -1752,7 +1765,11 @@ app.on("before-quit", async (event) => { app.quit(); }); -for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { +const shutdownSignals: NodeJS.Signals[] = ["SIGTERM", "SIGINT"]; +if (process.platform !== "win32") { + shutdownSignals.push("SIGHUP"); +} +for (const signal of shutdownSignals) { process.on(signal, () => { isQuitting = true; void killServer().then(() => app.quit());