diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 92c5b60..129186e 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -26,9 +26,15 @@ runs: if: inputs.electron-cache == 'true' uses: actions/cache@v6 with: + # One list works for every runner OS: actions/cache silently skips + # whichever paths don't exist on the current platform. path: | ~/AppData/Local/electron/Cache ~/AppData/Local/electron-builder/Cache + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + ~/.cache/electron + ~/.cache/electron-builder key: ${{ runner.os }}-electron-${{ hashFiles('bun.lock') }} restore-keys: ${{ runner.os }}-electron- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90a98d4..2928b3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,7 @@ jobs: "title=NativePi $version ($shortSha)" >> $env:GITHUB_OUTPUT - name: Publish GitHub Release + if: false uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.release.outputs.tag }} @@ -139,6 +140,144 @@ jobs: draft: false prerelease: ${{ contains(needs.release.outputs.tag, '-') }} + macos-installer: + name: Build macOS Installer & Publish + needs: release + if: needs.release.outputs.publish == 'true' + runs-on: macos-latest + timeout-minutes: 30 + env: + # No Apple Developer ID configured; skip signing and notarization. + CSC_IDENTITY_AUTO_DISCOVERY: "false" + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.sha }} + + - name: Setup + uses: ./.github/actions/setup + with: + electron-cache: "true" + + - name: Build macOS installer + run: bun run dist:mac --publish never + working-directory: apps/desktop + + - name: Upload installer artifact + uses: actions/upload-artifact@v7 + with: + name: nativepi-macos-installer + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.blockmap + apps/desktop/release/latest-mac.yml + if-no-files-found: error + + - name: Prepare release title + id: release + run: | + version="${{ needs.release.outputs.tag }}" + version="${version#v}" + short_sha="${{ needs.release.outputs.sha }}" + short_sha="${short_sha:0:12}" + echo "title=NativePi $version ($short_sha)" >> "$GITHUB_OUTPUT" + + - name: Publish GitHub Release + if: false + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.release.outputs.tag }} + target_commitish: ${{ needs.release.outputs.sha }} + name: ${{ steps.release.outputs.title }} + files: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.blockmap + apps/desktop/release/latest-mac.yml + generate_release_notes: true + previous_tag: ${{ needs.release.outputs.previous_tag }} + draft: false + prerelease: ${{ contains(needs.release.outputs.tag, '-') }} + + linux-installer: + name: Build Linux Installer & Publish + needs: release + if: needs.release.outputs.publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.sha }} + + - name: Setup + uses: ./.github/actions/setup + with: + electron-cache: "true" + + - name: Build Linux installer + run: bun run dist:linux --publish never + working-directory: apps/desktop + + - name: Upload installer artifact + uses: actions/upload-artifact@v7 + with: + name: nativepi-linux-installer + path: | + apps/desktop/release/*.AppImage + apps/desktop/release/*.blockmap + apps/desktop/release/latest-linux.yml + if-no-files-found: error + + - name: Prepare release title + id: release + run: | + version="${{ needs.release.outputs.tag }}" + version="${version#v}" + short_sha="${{ needs.release.outputs.sha }}" + short_sha="${short_sha:0:12}" + echo "title=NativePi $version ($short_sha)" >> "$GITHUB_OUTPUT" + + - name: Publish GitHub Release + if: false + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.release.outputs.tag }} + target_commitish: ${{ needs.release.outputs.sha }} + name: ${{ steps.release.outputs.title }} + files: | + apps/desktop/release/*.AppImage + apps/desktop/release/*.blockmap + apps/desktop/release/latest-linux.yml + generate_release_notes: true + previous_tag: ${{ needs.release.outputs.previous_tag }} + draft: false + prerelease: ${{ contains(needs.release.outputs.tag, '-') }} + + publish-release: + name: Publish GitHub Release + needs: [release, windows-installer, macos-installer, linux-installer] + if: needs.release.outputs.publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v7 + with: + path: release + merge-multiple: true + - uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.release.outputs.tag }} + target_commitish: ${{ needs.release.outputs.sha }} + name: NativePi ${{ needs.release.outputs.tag }} + files: release/* + generate_release_notes: true + previous_tag: ${{ needs.release.outputs.previous_tag }} + draft: false + prerelease: ${{ contains(needs.release.outputs.tag, '-') }} + repair-release-notes: name: Regenerate release notes if: github.event_name == 'workflow_dispatch' diff --git a/AGENTS.md b/AGENTS.md index 3171e77..f0b4dd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ and pull request mechanics. ## Project -NativePi is a small, local, Windows-first desktop interface for the Pi coding -agent. It is a wrapper, not an agent harness. +NativePi is a small, local desktop interface for the Pi coding agent, for +Windows, macOS, and Linux. It is a wrapper, not an agent harness. Read `apps/desktop/PRODUCT.md` before making product-scope decisions and `apps/desktop/DESIGN.md` before changing the interface's visual language. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e41e9d8..d3bd8c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Contributing to NativePi -Thanks for your interest in the project. NativePi is a small Windows-first -desktop interface for the Pi coding agent, and it is deliberately narrow in -scope. Reading this page before you start will save you a rewrite. +Thanks for your interest in the project. NativePi is a small desktop interface +for the Pi coding agent, for Windows, macOS, and Linux, and it is deliberately +narrow in scope. Reading this page before you start will save you a rewrite. ## Scope @@ -28,8 +28,8 @@ people. ## Getting set up -You need [Bun](https://bun.sh/) and Git. Windows is the supported platform; the -app builds elsewhere but is neither tested nor released for other systems. +You need [Bun](https://bun.sh/) and Git. Windows, macOS, and Linux are all +supported and released platforms. ```sh git clone https://github.com/nonlooped/nativepi.git @@ -70,9 +70,9 @@ They are not expected for static rendering, getters, or coverage counts. ## Reporting bugs -Open an issue with the NativePi version, your Windows version, and the steps -that reproduce the problem. Logs and screenshots help. If the problem involves -a specific model or provider, say which one. +Open an issue with the NativePi version, your operating system and its +version, and the steps that reproduce the problem. Logs and screenshots help. +If the problem involves a specific model or provider, say which one. ## Reporting vulnerabilities diff --git a/README.md b/README.md index ae5e611..243c7b6 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@

Pi, at home on your desktop.
- A free, open-source Windows desktop interface for the Pi coding agent. + A free, open-source desktop interface for the Pi coding agent, for Windows, macOS, and Linux.

License: MIT - Platform: Windows + Platform: Windows, macOS, Linux Built with Electron + React

@@ -63,9 +63,11 @@ storage, their own login. NativePi takes the opposite approach. ## Install -Download the latest Windows installer from -[GitHub Releases](https://github.com/nonlooped/nativepi/releases). Releases are -currently unsigned, so Windows SmartScreen will warn on first launch. +Download the latest installer for your platform from +[GitHub Releases](https://github.com/nonlooped/nativepi/releases): an `.exe` +for Windows, a `.dmg` for macOS, or an `.AppImage` for Linux. Releases are +currently unsigned (and, on macOS, not notarized), so Windows SmartScreen and +macOS Gatekeeper will warn on first launch. NativePi bundles Pi. A separate Pi installation is not required, and existing Pi credentials, configuration, and sessions in `~/.pi/agent` are reused. @@ -97,6 +99,8 @@ cd apps/desktop && bun test # run the test suite cd ../.. && bun run build # build the app bun run pack # package without installer bun run dist:win # build the Windows installer +bun run dist:mac # build the macOS installer +bun run dist:linux # build the Linux installer ``` ## Architecture diff --git a/apps/desktop/PRODUCT.md b/apps/desktop/PRODUCT.md index 96610b2..c845cd0 100644 --- a/apps/desktop/PRODUCT.md +++ b/apps/desktop/PRODUCT.md @@ -4,7 +4,7 @@ ## Platform -Windows desktop (Electron) +Windows, macOS, and Linux desktop (Electron) ## Users @@ -27,7 +27,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains ## Operating Context -- NativePi is a Windows desktop application used alongside local code projects, Git repositories, and the existing Pi CLI ecosystem. Its workspace can be shared temporarily to browsers on the same local network. +- NativePi is a Windows, macOS, or Linux desktop application used alongside local code projects, Git repositories, and the existing Pi CLI ecosystem. Its workspace can be shared temporarily to browsers on the same local network. - Users pin project folders; create, discover, import, and manage Pi sessions; inspect streamed messages and tool activity; and review Git state and diffs. - Existing Pi credentials, configuration, sessions, packages, skills, prompts, and extensions remain in Pi's normal storage and remain usable by the Pi CLI. - NativePi keeps at most one Pi process per project. Different projects may run concurrently. @@ -36,7 +36,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains ## Current Capabilities -- Project and chat pinning, date-grouped chat discovery, filtering, title and transcript search, and direct opening in installed editors or Explorer. +- Project and chat pinning, date-grouped chat discovery, filtering, title and transcript search, and direct opening in installed editors or the system file manager. - New, resume, rename, clone, fork, delete, import, HTML export, session-tree, statistics, and compaction workflows backed by Pi. - Streaming text, thinking, tool activity, file-change summaries, rich diffs, retries, steering, follow-ups, queues, and abort controls. - Images attached to a message by paste, drag and drop, or the file picker, resized by Pi before they are sent and shown in the transcript alongside the message they went with. @@ -57,7 +57,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains ## Boundaries and Constraints -- NativePi's host is currently Windows-only, single-window, and dark-only. Browser access requires an explicitly started server, the access token in the link, and the running desktop app. Remote Access is a throwaway Cloudflare quick tunnel created for one session and closed after twelve hours; NativePi does not operate a hosting service, register a permanent address, or hold an account of any kind. +- NativePi's host is currently single-window and dark-only. Browser access requires an explicitly started server, the access token in the link, and the running desktop app. Remote Access is a throwaway Cloudflare quick tunnel created for one session and closed after twelve hours; NativePi does not operate a hosting service, register a permanent address, or hold an account of any kind. - NativePi does not own an agent loop, make its own LLM requests, add agent tools, or support other harnesses. - Pi session files are the durable conversation source of truth. NativePi persists only pinned projects and chats, the last project and chat, text drafts, favorite models, pane state, and its own interface preferences. - Agent configuration is Pi's. NativePi reads and writes it through Pi's own settings manager at user scope, so a change made here is a change the Pi command line sees; NativePi never writes Pi's configuration format itself, and exposes only the settings that have meaning in a desktop window. Project-scope overrides remain the Pi command line's business. @@ -66,7 +66,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains - Normal Pi extensions run unchanged. Optional graphical extensions contribute only through controlled NativePi UI slots and are trusted code, not sandboxed code. - Terminal extension components are drawn by Pi and displayed, not reimplemented: NativePi runs the component in the Pi process and shows what it draws, so it looks as its author wrote it rather than as NativePi would have styled it. Two parts of Pi's terminal UI have no equivalent here and keep Pi's documented no-op: raw terminal input, and replacing the input editor, which in this window is the composer. - NativePi has no cloud sync, collaboration, remote projects, SSH launching, configurable keybindings, product accounts, paid features, or telemetry. -- NativePi is MIT licensed. GitHub publishes Windows installers, currently without code signing, so SmartScreen warnings are expected. +- NativePi is MIT licensed. GitHub publishes Windows, macOS, and Linux installers, currently without code signing or notarization, so SmartScreen warnings on Windows and Gatekeeper warnings on macOS are expected. ## Brand Commitments diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5d7f488..64fa8eb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@nativepi/desktop", "private": true, "version": "0.11.0", - "description": "A Windows-first desktop interface for the Pi coding agent.", + "description": "A desktop interface for the Pi coding agent.", "author": "nonlooped", "type": "module", "main": "./out/main/index.js", @@ -13,7 +13,9 @@ "typecheck": "tsc --noEmit", "pack": "electron-vite build && electron-builder --dir", "dist": "electron-vite build && electron-builder", - "dist:win": "electron-vite build && electron-builder --win" + "dist:win": "electron-vite build && electron-builder --win", + "dist:mac": "electron-vite build && electron-builder --mac", + "dist:linux": "electron-vite build && electron-builder --linux" }, "dependencies": { "@earendil-works/pi-coding-agent": "0.82.1", @@ -103,6 +105,24 @@ "oneClick": false, "allowToChangeInstallationDirectory": true, "installerSidebar": "resources/installer-sidebar.bmp" + }, + "mac": { + "icon": "resources/icon.png", + "identity": null, + "target": [ + "dmg", + "zip" + ] + }, + "dmg": { + "artifactName": "${productName}-${version}.${ext}" + }, + "linux": { + "icon": "resources/icon.png", + "category": "Development", + "target": [ + "AppImage" + ] } } } diff --git a/apps/desktop/src/main/editors.ts b/apps/desktop/src/main/editors.ts index d6c441f..0f1ad7e 100644 --- a/apps/desktop/src/main/editors.ts +++ b/apps/desktop/src/main/editors.ts @@ -1,6 +1,7 @@ -import { execFile, spawn } from "node:child_process"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; import { existsSync, readdirSync, statSync } from "node:fs"; -import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { homedir } from "node:os"; +import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { app, shell } from "electron"; import type { InstalledEditor } from "../shared/rpc-schema.ts"; @@ -14,7 +15,14 @@ type EditorSpec = InstalledEditor & { type ScannedEditor = { editor: InstalledEditor; executable?: string }; -const EXPLORER: InstalledEditor = { id: "explorer", name: "Explorer", icon: "explorer" }; +/** The platform's own file manager, named the way its own users would name it. */ +export function fileManagerName(): string { + if (process.platform === "darwin") return "Finder"; + if (process.platform === "linux") return "Files"; + return "Explorer"; +} + +const FILE_MANAGER: InstalledEditor = { id: "explorer", name: fileManagerName(), icon: "explorer" }; const local = process.env["LOCALAPPDATA"]; const programFiles = process.env["ProgramFiles"]; @@ -24,7 +32,7 @@ function under(root: string | undefined, ...parts: string[]): string[] { return root ? [join(root, ...parts)] : []; } -const editorSpecs: EditorSpec[] = [ +const windowsEditorSpecs: EditorSpec[] = [ { id: "cursor", name: "Cursor", @@ -123,7 +131,6 @@ const jetBrainsEditors = [ ] as const; async function registeredExecutables(): Promise> { - if (process.platform !== "win32") return new Map(); const roots = [ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths", "HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths", @@ -160,17 +167,17 @@ async function registeredExecutables(): Promise> { return registered; } -function addJetBrainsInstallations(found: Map): void { +function addJetBrainsWindowsInstallations(found: Map): void { const roots = [ ...(programFiles ? [join(programFiles, "JetBrains")] : []), ...(local ? [join(local, "JetBrains", "Toolbox", "apps")] : []), ]; for (const root of roots) { - scanJetBrainsDirectory(root, 3, found); + scanJetBrainsWindowsDirectory(root, 3, found); } } -function scanJetBrainsDirectory( +function scanJetBrainsWindowsDirectory( directory: string, depth: number, found: Map, @@ -188,22 +195,14 @@ function scanJetBrainsDirectory( return; } for (const child of children) { - if (child.isDirectory()) scanJetBrainsDirectory(join(directory, child.name), depth - 1, found); + if (child.isDirectory()) scanJetBrainsWindowsDirectory(join(directory, child.name), depth - 1, found); } } -function explorerEntry(): ScannedEditor { - const path = process.env["WINDIR"] ? join(process.env["WINDIR"], "explorer.exe") : undefined; - return { - editor: EXPLORER, - executable: path && existsSync(path) ? path : undefined, - }; -} - -async function scanEditors(): Promise> { +async function scanWindowsEditors(): Promise> { const found = new Map(); // Path checks first — registry scan is best-effort and must not block the list. - for (const spec of editorSpecs) { + for (const spec of windowsEditorSpecs) { const executable = spec.paths.find(existsSync); if (executable) { const { executables: _executables, paths: _paths, ...editor } = spec; @@ -211,13 +210,13 @@ async function scanEditors(): Promise> { } } try { - addJetBrainsInstallations(found); + addJetBrainsWindowsInstallations(found); } catch { // ignore } try { const registered = await registeredExecutables(); - for (const spec of editorSpecs) { + for (const spec of windowsEditorSpecs) { if (found.has(spec.id)) continue; const executable = spec.executables.map((name) => registered.get(name.toLowerCase())).find(Boolean); if (executable) { @@ -233,6 +232,114 @@ async function scanEditors(): Promise> { } catch { // ignore } + return found; +} + +type CrossPlatformSpec = { id: string; name: string; icon: InstalledEditor["icon"] }; + +const crossPlatformEditors: CrossPlatformSpec[] = [ + { id: "cursor", name: "Cursor", icon: "cursor" }, + { id: "vscode", name: "Visual Studio Code", icon: "code" }, + { id: "antigravity", name: "Google Antigravity", icon: "antigravity" }, + { id: "windsurf", name: "Windsurf", icon: "windsurf" }, + { id: "vscode-insiders", name: "Visual Studio Code Insiders", icon: "code" }, + { id: "vscodium", name: "VSCodium", icon: "code" }, + { id: "zed", name: "Zed", icon: "code" }, + { id: "sublime-text", name: "Sublime Text", icon: "code" }, + { id: "intellij-idea", name: "IntelliJ IDEA", icon: "code" }, + { id: "webstorm", name: "WebStorm", icon: "code" }, + { id: "rider", name: "JetBrains Rider", icon: "code" }, + { id: "pycharm", name: "PyCharm", icon: "code" }, + { id: "clion", name: "CLion", icon: "code" }, + { id: "goland", name: "GoLand", icon: "code" }, + { id: "phpstorm", name: "PhpStorm", icon: "code" }, + { id: "rubymine", name: "RubyMine", icon: "code" }, + { id: "rustrover", name: "RustRover", icon: "code" }, +]; + +/** `.app` bundle names to look for under each editor's id, macOS only. */ +const macAppNames: Record = { + cursor: ["Cursor.app"], + vscode: ["Visual Studio Code.app"], + antigravity: ["Antigravity.app"], + windsurf: ["Windsurf.app"], + "vscode-insiders": ["Visual Studio Code - Insiders.app"], + vscodium: ["VSCodium.app"], + zed: ["Zed.app"], + "sublime-text": ["Sublime Text.app"], + "intellij-idea": ["IntelliJ IDEA.app", "IntelliJ IDEA CE.app"], + webstorm: ["WebStorm.app"], + rider: ["Rider.app"], + pycharm: ["PyCharm.app", "PyCharm CE.app"], + clion: ["CLion.app"], + goland: ["GoLand.app"], + phpstorm: ["PhpStorm.app"], + rubymine: ["RubyMine.app"], + rustrover: ["RustRover.app"], +}; + +function scanMacEditors(): Map { + const found = new Map(); + const roots = ["/Applications", join(homedir(), "Applications")]; + for (const spec of crossPlatformEditors) { + const appNames = macAppNames[spec.id] ?? []; + const appPath = roots.flatMap((root) => appNames.map((name) => join(root, name))).find(existsSync); + if (appPath) found.set(spec.id, { editor: spec, executable: appPath }); + } + return found; +} + +/** Command-line launcher names to look for on `PATH`, Linux only. */ +const linuxExecutableNames: Record = { + cursor: ["cursor"], + vscode: ["code"], + antigravity: ["antigravity"], + windsurf: ["windsurf"], + "vscode-insiders": ["code-insiders"], + vscodium: ["codium", "vscodium"], + zed: ["zed", "zeditor"], + "sublime-text": ["subl", "sublime_text"], + "intellij-idea": ["idea", "idea.sh"], + webstorm: ["webstorm", "webstorm.sh"], + rider: ["rider", "rider.sh"], + pycharm: ["pycharm", "pycharm.sh"], + clion: ["clion", "clion.sh"], + goland: ["goland", "goland.sh"], + phpstorm: ["phpstorm", "phpstorm.sh"], + rubymine: ["rubymine", "rubymine.sh"], + rustrover: ["rustrover", "rustrover.sh"], +}; + +function linuxSearchDirectories(): string[] { + const fromPath = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean); + const extra = ["/usr/local/bin", "/usr/bin", "/snap/bin", join(homedir(), ".local", "bin")]; + return [...new Set([...fromPath, ...extra])]; +} + +function scanLinuxEditors(): Map { + const found = new Map(); + const dirs = linuxSearchDirectories(); + for (const spec of crossPlatformEditors) { + const names = linuxExecutableNames[spec.id] ?? []; + const executable = dirs.flatMap((dir) => names.map((name) => join(dir, name))).find(existsSync); + if (executable) found.set(spec.id, { editor: spec, executable }); + } + return found; +} + +function explorerEntry(): ScannedEditor { + if (process.platform !== "win32") return { editor: FILE_MANAGER }; + const path = process.env["WINDIR"] ? join(process.env["WINDIR"], "explorer.exe") : undefined; + return { editor: FILE_MANAGER, executable: path && existsSync(path) ? path : undefined }; +} + +async function scanEditors(): Promise> { + const found = + process.platform === "darwin" + ? scanMacEditors() + : process.platform === "linux" + ? scanLinuxEditors() + : await scanWindowsEditors(); found.set("explorer", explorerEntry()); return found; } @@ -247,7 +354,7 @@ async function withIcon(editor: InstalledEditor, executable?: string): Promise { let scanned: Map; try { @@ -262,6 +369,18 @@ export async function listInstalledEditors(): Promise { return Promise.all(ordered.map(({ editor, executable }) => withIcon(editor, executable))); } +/** + * macOS app bundles are not directly executable, so they are launched through + * `open -a`, which also hands the app the target path the way double-clicking + * a file in Finder would. Windows and Linux executables are launched directly. + */ +function launchEditor(executable: string, target: string): ChildProcess { + if (process.platform === "darwin" && executable.endsWith(".app")) { + return spawn("open", ["-a", executable, target], { detached: true, stdio: "ignore" }); + } + return spawn(executable, [target], { detached: true, stdio: "ignore", windowsHide: true }); +} + export async function openProjectIn(projectDir: string, editorId: string): Promise { if (!statSync(projectDir).isDirectory()) throw new Error("The project folder no longer exists."); if (editorId === "explorer") { @@ -273,11 +392,7 @@ export async function openProjectIn(projectDir: string, editorId: string): Promi if (!target?.executable) throw new Error("That editor is no longer installed."); const executable = target.executable; await new Promise((resolve, reject) => { - const child = spawn(executable, [projectDir], { - detached: true, - stdio: "ignore", - windowsHide: true, - }); + const child = launchEditor(executable, projectDir); child.once("spawn", resolve); child.once("error", reject); child.unref(); @@ -298,11 +413,7 @@ export async function openFileIn(projectDir: string, file: string, editorId: str if (!target?.executable) throw new Error("That editor is no longer installed."); const executable = target.executable; await new Promise((resolveSpawn, reject) => { - const child = spawn(executable, [targetPath], { - detached: true, - stdio: "ignore", - windowsHide: true, - }); + const child = launchEditor(executable, targetPath); child.once("spawn", resolveSpawn); child.once("error", reject); child.unref(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 2898140..780ec28 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -14,7 +14,7 @@ import { listSkills } from "./skills.ts"; import { listProjectFiles } from "./files.ts"; import { prepareImages } from "./images.ts"; import { loadGraphicalExtensions } from "./extensions.ts"; -import { listInstalledEditors, openFileIn, openProjectIn } from "./editors.ts"; +import { fileManagerName, listInstalledEditors, openFileIn, openProjectIn } from "./editors.ts"; import { liveSettingsFor, piPaths, queuePiSettings, readPiSettings, writePiSettings } from "./piSettings.ts"; import { piSettingsPatchSchema, type PiSettingsPatch } from "../shared/pi-settings.ts"; import { @@ -743,7 +743,7 @@ const handlers: HandlerMap = { }, confirmQuit: () => { quitConfirmed = true; - mainWindow?.close(); + app.quit(); return { ok: true }; }, windowIsMaximized: () => ({ maximized: mainWindow?.isMaximized() ?? false }), @@ -761,7 +761,7 @@ const handlers: HandlerMap = { try { return { editors: await listInstalledEditors() }; } catch { - return { editors: [{ id: "explorer", name: "Explorer", icon: "explorer" }] }; + return { editors: [{ id: "explorer", name: fileManagerName(), icon: "explorer" }] }; } }, openProjectIn: async (params) => { diff --git a/apps/desktop/src/main/remoteAccess.ts b/apps/desktop/src/main/remoteAccess.ts index 3427cc6..06560d0 100644 --- a/apps/desktop/src/main/remoteAccess.ts +++ b/apps/desktop/src/main/remoteAccess.ts @@ -1,11 +1,14 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createWriteStream } from "node:fs"; -import { mkdir, rename, rm, stat } from "node:fs/promises"; +import { chmod, mkdir, rename, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { promisify } from "node:util"; import type { RemoteAccessStatus } from "../shared/rpc-schema.ts"; +const execFileAsync = promisify(execFile); + /** * Remote access, as a Cloudflare quick tunnel. * @@ -21,10 +24,24 @@ import type { RemoteAccessStatus } from "../shared/rpc-schema.ts"; * this on, so it is fetched on first use and cached under user data. */ -const RELEASE_URL = - "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"; +const RELEASE_BASE = "https://github.com/cloudflare/cloudflared/releases/latest/download"; const STARTUP_TIMEOUT_MS = 45_000; +/** + * cloudflared's release assets differ per platform: Windows and Linux publish + * a directly runnable binary, macOS only publishes one wrapped in a `.tgz`. + */ +function cloudflaredAsset(): { url: string; archive: "raw" | "tgz" } { + const arch = process.arch === "arm64" ? "arm64" : "amd64"; + if (process.platform === "darwin") return { url: `${RELEASE_BASE}/cloudflared-darwin-${arch}.tgz`, archive: "tgz" }; + if (process.platform === "linux") return { url: `${RELEASE_BASE}/cloudflared-linux-${arch}`, archive: "raw" }; + return { url: `${RELEASE_BASE}/cloudflared-windows-amd64.exe`, archive: "raw" }; +} + +function cloudflaredBinaryName(): string { + return process.platform === "win32" ? "cloudflared.exe" : "cloudflared"; +} + /** * How long to wait for a freshly minted hostname to start answering. * @@ -291,19 +308,20 @@ export function findTunnelUrl(log: string): string | undefined { * usable binary on the next attempt. */ async function ensureCloudflared(binDir: string, onProgress: (message: string) => void): Promise { - const binary = join(binDir, "cloudflared.exe"); + const binary = join(binDir, cloudflaredBinaryName()); const cached = await stat(binary).catch(() => undefined); if (cached?.isFile() && cached.size > 0) return binary; onProgress("Downloading the tunnel client…"); await mkdir(binDir, { recursive: true }); - const partial = `${binary}.partial`; + const { url, archive } = cloudflaredAsset(); + const downloaded = archive === "tgz" ? `${binary}.tgz.partial` : `${binary}.partial`; const abort = new AbortController(); let stall = setTimeout(() => abort.abort(), DOWNLOAD_STALL_MS); try { - const response = await fetch(RELEASE_URL, { signal: abort.signal }); + const response = await fetch(url, { signal: abort.signal }); if (!response.ok || !response.body) { throw new Error(`Could not download the tunnel client (HTTP ${response.status}).`); } @@ -325,17 +343,27 @@ async function ensureCloudflared(binDir: string, onProgress: (message: string) = onProgress(`Downloading the tunnel client… ${percent}%`); }); - await pipeline(source, createWriteStream(partial)); + await pipeline(source, createWriteStream(downloaded)); // A body that ends early still ends cleanly, so the length is the only // thing separating a whole binary from a half of one. if (total > 0 && received !== total) { throw new Error("The tunnel client download ended before it was complete."); } await rm(binary, { force: true }); - await rename(partial, binary); + if (archive === "tgz") { + // macOS ships no raw binary, so extract straight into place with the + // system `tar` rather than adding a tar-parsing dependency. + await execFileAsync("tar", ["-xzf", downloaded, "-C", binDir, "cloudflared"]); + await rm(downloaded, { force: true }); + } else { + await rename(downloaded, binary); + } + // Extracted and downloaded files do not carry the executable bit; Windows + // has no such concept, so this is a no-op there. + if (process.platform !== "win32") await chmod(binary, 0o755); return binary; } catch (error) { - await rm(partial, { force: true }).catch(() => {}); + await rm(downloaded, { force: true }).catch(() => {}); if (abort.signal.aborted) { throw new Error("The tunnel client download stopped responding. Check this computer's connection and try again."); } diff --git a/apps/desktop/src/main/terminal.ts b/apps/desktop/src/main/terminal.ts index b311523..d402d91 100644 --- a/apps/desktop/src/main/terminal.ts +++ b/apps/desktop/src/main/terminal.ts @@ -17,6 +17,7 @@ type ManagedTerminal = TerminalSession & { const terminals = new Map(); function resolveShell(): string { + if (process.platform !== "win32") return process.env["SHELL"] || "/bin/bash"; const path = process.env["PATH"] ?? ""; for (const dir of path.split(delimiter)) { if (!dir) continue; @@ -26,6 +27,10 @@ function resolveShell(): string { return "powershell.exe"; } +function shellArgs(): string[] { + return process.platform === "win32" ? ["-NoLogo"] : process.platform === "darwin" ? ["-l"] : []; +} + function shellEnv(): Record { const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { @@ -66,7 +71,7 @@ export function createTerminal( onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, ): TerminalSession { const id = randomUUID(); - const pty = spawn(resolveShell(), ["-NoLogo"], { + const pty = spawn(resolveShell(), shellArgs(), { name: "xterm-256color", cols: 100, rows: 24, diff --git a/apps/desktop/src/main/updates.ts b/apps/desktop/src/main/updates.ts index 263efa1..f1db9f1 100644 --- a/apps/desktop/src/main/updates.ts +++ b/apps/desktop/src/main/updates.ts @@ -17,7 +17,11 @@ import type { UpdateState } from "../shared/rpc-schema.ts"; * NativePi's builds are unsigned, so the downloaded installer's Authenticode * signature is not verified; `verifyUpdateCodeSignature` in the desktop package * manifest says so explicitly rather than leaving it to electron-builder to - * infer from the absence of a certificate. + * infer from the absence of a certificate. On an unsigned macOS build, + * Gatekeeper's own code-signature check on the downloaded update is stricter + * still and has no such override, so `update-downloaded` there can surface as + * an `error` instead — the same `checkForUpdate`/`downloadUpdate` failure path + * this module already reports through, not a case this module special-cases. */ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; diff --git a/apps/desktop/src/renderer/components/FileContextMenu.tsx b/apps/desktop/src/renderer/components/FileContextMenu.tsx index 33c2422..20f4afc 100644 --- a/apps/desktop/src/renderer/components/FileContextMenu.tsx +++ b/apps/desktop/src/renderer/components/FileContextMenu.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import { CopyIcon } from "@phosphor-icons/react/Copy"; import { FileArrowUpIcon } from "@phosphor-icons/react/FileArrowUp"; import { FolderOpenIcon } from "@phosphor-icons/react/FolderOpen"; -import { absoluteProjectPath, editorName } from "@/lib/paths.ts"; +import { absoluteProjectPath, editorName, fileManagerName } from "@/lib/paths.ts"; import { rpc } from "@/lib/rpc.ts"; import { useAppStore } from "@/lib/store.ts"; import { @@ -42,7 +42,7 @@ export default function FileContextMenu({ Open in {editorName(editorId)} void rpc.request.showInFolder({ path: absolutePath })}> - Reveal in Explorer + Reveal in {fileManagerName()} void navigator.clipboard.writeText(file)}> diff --git a/apps/desktop/src/renderer/components/OpenWith.tsx b/apps/desktop/src/renderer/components/OpenWith.tsx index 6ce9496..aae149c 100644 --- a/apps/desktop/src/renderer/components/OpenWith.tsx +++ b/apps/desktop/src/renderer/components/OpenWith.tsx @@ -7,12 +7,13 @@ import { toast } from "sonner"; import type { InstalledEditor } from "../../shared/rpc-schema.ts"; import { Button } from "@/components/ui/button.tsx"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu.tsx"; +import { fileManagerName } from "@/lib/paths.ts"; import { rpc } from "@/lib/rpc.ts"; import { useAppStore } from "@/lib/store.ts"; import { cn, NO_DRAG_REGION } from "@/lib/utils.ts"; import BrandIcon from "./BrandIcon.tsx"; -const EXPLORER: InstalledEditor = { id: "explorer", name: "Explorer", icon: "explorer" }; +const EXPLORER: InstalledEditor = { id: "explorer", name: fileManagerName(), icon: "explorer" }; export default function OpenWith({ projectDir }: { projectDir: string }) { const [editors, setEditors] = useState([EXPLORER]); diff --git a/apps/desktop/src/renderer/components/SessionMenu.tsx b/apps/desktop/src/renderer/components/SessionMenu.tsx index 8009b8c..a6a737e 100644 --- a/apps/desktop/src/renderer/components/SessionMenu.tsx +++ b/apps/desktop/src/renderer/components/SessionMenu.tsx @@ -14,6 +14,7 @@ import { TreeStructureIcon } from "@phosphor-icons/react/TreeStructure"; import { useState } from "react"; import type { ForkPoint, SessionStats, SessionSummary, SessionTreeNode } from "../../shared/pi-types.ts"; import { textOf } from "../../shared/messages.ts"; +import { fileManagerName } from "../lib/paths.ts"; import { chatTitle } from "../lib/transcript.ts"; import ConfirmDialog from "./ConfirmDialog.tsx"; import { activeConversation, useAppStore } from "../lib/store.ts"; @@ -533,9 +534,9 @@ function ExportDialog({ path, onClose }: { path: string; onClose: () => void }) {/* The file is often the destination's neighbour, not the destination: an export usually gets attached or moved next, which starts in - Explorer rather than a browser tab. */} + the file manager rather than a browser tab. */} diff --git a/apps/web/components/site/Footer.tsx b/apps/web/components/site/Footer.tsx index 0d93dcf..68cbb7b 100644 --- a/apps/web/components/site/Footer.tsx +++ b/apps/web/components/site/Footer.tsx @@ -41,8 +41,8 @@ export function Footer() {

- A Windows desktop interface for the Pi coding agent. Free, open - source, and local to your machine. + A desktop interface for the Pi coding agent. Free, open source, + and local to your machine.