Skip to content
Open
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
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

Expand All @@ -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
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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:
Expand All @@ -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)

<br/>

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 12 additions & 3 deletions scripts/build-ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ───────────────────────────────────────────

Expand Down
36 changes: 11 additions & 25 deletions scripts/prepare-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* into the app.
*/

import { execFileSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import {
cpSync,
existsSync,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 20 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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",
Expand Down Expand Up @@ -333,6 +340,7 @@ function resolveShellPath(): string {
: basePath;
}


function getPidFilePath(): string {
return path.join(app.getPath("userData"), PID_FILE_NAME);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1521,6 +1529,7 @@ function registerLauncherIpc(): void {
// ---------------------------------------------------------------------------

function rebuildAppMenu(): void {
if (process.platform !== "darwin") return;
const template: MenuItemConstructorOptions[] = [
{
label: "Paperclip",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
Expand Down