From 22fe4fa0a586dbb28eea30de11ecf5bf0406be28 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:55:12 +0200 Subject: [PATCH] feat(desktop): add native shell capabilities (#1002) * feat(desktop): add native shell capabilities * refactor: pr feedback --- app/client/types/desktop.d.ts | 2 + apps/desktop/electron/main.ts | 74 ++++++++++++++++++- apps/desktop/electron/preload.ts | 3 + .../electron/security-scoped-bookmarks.ts | 74 +++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/electron/security-scoped-bookmarks.ts diff --git a/app/client/types/desktop.d.ts b/app/client/types/desktop.d.ts index 34fefcdd..7a4cd03b 100644 --- a/app/client/types/desktop.d.ts +++ b/app/client/types/desktop.d.ts @@ -1,5 +1,7 @@ type ZerobyteDesktopApi = { chooseFolder: () => Promise; + openMainWindow: (path?: string) => Promise; + quit: () => void; setTheme: (theme: "light" | "dark") => void; }; diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 2c3bd83c..173b4d84 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,11 +1,13 @@ -import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type OpenDialogOptions } from "electron"; import { toMessage } from "@zerobyte/core/utils"; import { startDesktopRuntime, type DesktopRuntime } from "./desktop-runtime"; import { createDesktopSession } from "./desktop-session"; import { createDesktopWindow } from "./desktop-window"; +import { saveSecurityScopedBookmark, startAccessingSavedBookmarks } from "./security-scoped-bookmarks"; let mainWindow: BrowserWindow | null = null; let runtime: DesktopRuntime | null = null; +let stopAccessingBookmarks: (() => void) | null = null; let isQuitting = false; const quitApp = () => { @@ -38,6 +40,43 @@ const focusMainWindow = () => { mainWindow.focus(); }; +const isTrustedDesktopSender = (senderUrl?: string) => { + if (!runtime || !senderUrl) { + return false; + } + + try { + return new URL(senderUrl).origin === new URL(runtime.url).origin; + } catch { + return false; + } +}; + +const chooseFolder = async () => { + const dialogOptions: OpenDialogOptions = { + properties: ["openDirectory", "createDirectory"], + securityScopedBookmarks: true, + }; + + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + + if (result.canceled || !result.filePaths[0]) { + return null; + } + + const selectedPath = result.filePaths[0]; + const bookmark = result.bookmarks?.[0]; + if (process.platform === "darwin" && (process as NodeJS.Process & { mas?: boolean }).mas && !bookmark) { + throw new Error("Failed to create security-scoped bookmark"); + } + + await saveSecurityScopedBookmark(selectedPath, bookmark); + + return selectedPath; +}; + if (!app.requestSingleInstanceLock()) { app.quit(); } else { @@ -46,12 +85,15 @@ if (!app.requestSingleInstanceLock()) { void app.whenReady().then(async () => { try { nativeTheme.themeSource = "dark"; + stopAccessingBookmarks = await startAccessingSavedBookmarks(); runtime = await startDesktopRuntime((status) => { dialog.showErrorBox("Zerobyte stopped", `Server process exited with ${status}`); }); await createDesktopSession(runtime.url, runtime.launchSecret); await createWindow(); } catch (error) { + stopAccessingBookmarks?.(); + stopAccessingBookmarks = null; dialog.showErrorBox("Zerobyte failed to start", toMessage(error)); quitApp(); } @@ -62,11 +104,39 @@ app.on("before-quit", () => { isQuitting = true; runtime?.stop(); runtime = null; + stopAccessingBookmarks?.(); + stopAccessingBookmarks = null; }); app.on("window-all-closed", () => {}); -ipcMain.on("desktop:set-theme", (_event, theme) => { +ipcMain.handle("desktop:choose-folder", (event) => { + if (!isTrustedDesktopSender(event.senderFrame?.url)) { + throw new Error("Invalid desktop IPC sender"); + } + + return chooseFolder(); +}); +ipcMain.handle("desktop:open-main-window", (event, appPath?: unknown) => { + if (!isTrustedDesktopSender(event.senderFrame?.url)) { + throw new Error("Invalid desktop IPC sender"); + } + + if ( + appPath !== undefined && + (typeof appPath !== "string" || !appPath.startsWith("/") || appPath.startsWith("//")) + ) { + throw new Error("Invalid app path"); + } + + return createWindow(appPath); +}); +ipcMain.on("desktop:quit", (event) => { + if (!isTrustedDesktopSender(event.senderFrame?.url)) return; + quitApp(); +}); +ipcMain.on("desktop:set-theme", (event, theme) => { + if (!isTrustedDesktopSender(event.senderFrame?.url)) return; if (theme === "light" || theme === "dark") { nativeTheme.themeSource = theme; } diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index ad87cdda..25c3082e 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,5 +1,8 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("zerobyteDesktop", { + chooseFolder: () => ipcRenderer.invoke("desktop:choose-folder"), + openMainWindow: (path?: string) => ipcRenderer.invoke("desktop:open-main-window", path), + quit: () => ipcRenderer.send("desktop:quit"), setTheme: (theme: "light" | "dark") => ipcRenderer.send("desktop:set-theme", theme), }); diff --git a/apps/desktop/electron/security-scoped-bookmarks.ts b/apps/desktop/electron/security-scoped-bookmarks.ts new file mode 100644 index 00000000..38c648af --- /dev/null +++ b/apps/desktop/electron/security-scoped-bookmarks.ts @@ -0,0 +1,74 @@ +import { safeJsonParse } from "@zerobyte/core/utils"; +import { app } from "electron"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type SecurityScopedBookmarkGrant = { + path: string; + bookmark: string; +}; + +const activeAccessorsByPath = new Map void>(); + +const getStorePath = () => path.join(app.getPath("userData"), "data", "security-scoped-bookmarks.json"); + +const readGrants = async (): Promise => { + try { + const file = await fs.readFile(getStorePath(), "utf-8"); + return safeJsonParse(file) ?? []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + + throw error; + } +}; + +const writeGrants = async (grants: SecurityScopedBookmarkGrant[]) => { + const storePath = getStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(grants, null, 2), "utf-8"); +}; + +const startAccessingBookmark = (bookmarkPath: string, bookmark: string) => { + if (process.platform !== "darwin") { + return; + } + + const stopAccessing = app.startAccessingSecurityScopedResource(bookmark) as () => void; + activeAccessorsByPath.get(bookmarkPath)?.(); + activeAccessorsByPath.set(bookmarkPath, stopAccessing); +}; + +export const saveSecurityScopedBookmark = async (selectedPath: string, bookmark?: string) => { + if (!bookmark) return; + + startAccessingBookmark(selectedPath, bookmark); + + const grants = (await readGrants()).filter((grant) => grant.path !== selectedPath); + + grants.push({ path: selectedPath, bookmark }); + + await writeGrants(grants); +}; + +export const startAccessingSavedBookmarks = async () => { + if (process.platform !== "darwin") { + return () => {}; + } + + for (const grant of await readGrants()) { + try { + startAccessingBookmark(grant.path, grant.bookmark); + } catch {} + } + + return () => { + for (const stopAccessing of activeAccessorsByPath.values()) { + stopAccessing(); + } + + activeAccessorsByPath.clear(); + }; +};