feat(desktop): add native shell capabilities (#1002)

* feat(desktop): add native shell capabilities

* refactor: pr feedback
This commit is contained in:
Nico
2026-06-22 14:55:12 +02:00
committed by GitHub
parent f26ed7b5fd
commit 22fe4fa0a5
4 changed files with 151 additions and 2 deletions

View File

@@ -1,5 +1,7 @@
type ZerobyteDesktopApi = {
chooseFolder: () => Promise<string | null>;
openMainWindow: (path?: string) => Promise<void>;
quit: () => void;
setTheme: (theme: "light" | "dark") => void;
};

View File

@@ -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;
}

View File

@@ -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),
});

View File

@@ -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<string, () => void>();
const getStorePath = () => path.join(app.getPath("userData"), "data", "security-scoped-bookmarks.json");
const readGrants = async (): Promise<SecurityScopedBookmarkGrant[]> => {
try {
const file = await fs.readFile(getStorePath(), "utf-8");
return safeJsonParse<SecurityScopedBookmarkGrant[]>(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();
};
};