From a64e9cb06f696e94fe1d099a47855760d2b9ef2c Mon Sep 17 00:00:00 2001 From: nonlooped Date: Fri, 31 Jul 2026 11:50:19 +0300 Subject: [PATCH 1/5] feat: support macOS and Linux path handling, terminal, and editor integration --- apps/desktop/src/main/editors.ts | 175 ++++++++++++++---- apps/desktop/src/main/ipc.ts | 4 +- apps/desktop/src/main/remoteAccess.ts | 48 ++++- apps/desktop/src/main/terminal.ts | 7 +- apps/desktop/src/main/updates.ts | 6 +- .../renderer/components/FileContextMenu.tsx | 4 +- .../src/renderer/components/OpenWith.tsx | 3 +- .../src/renderer/components/SessionMenu.tsx | 5 +- .../src/renderer/components/Sidebar.tsx | 4 +- .../components/settings/AboutSettings.tsx | 9 +- .../components/settings/TerminalSettings.tsx | 2 +- apps/desktop/src/renderer/lib/paths.test.ts | 8 + apps/desktop/src/renderer/lib/paths.ts | 27 ++- 13 files changed, 241 insertions(+), 61 deletions(-) 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..c87a7b1 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 { @@ -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..c601cdd 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"] : []; +} + 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.

: a .exe for Windows, a .dmg for macOS, or - an .AppImage for Linux. Run it and pick an installation - directory when prompted. + an .AppImage for Linux. On Windows, run the installer and + pick an installation directory. On macOS, open the disk image and drag + NativePi to Applications. On Linux, make the AppImage executable and run it.