From febf8ea3ef6f683086a15eee217edf166b4ad457 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 23 Oct 2026 07:19:09 +0000 Subject: [PATCH 1/2] Desktop: open folders and files with the system dialog, so ~/Desktop opens (v0.21.0) In Electron the page's showDirectoryPicker never returns for a folder Chromium calls sensitive (~/Desktop, ~): Electron emits file-system-access-restricted and waits for an answer nobody gave, so every later click failed with "File picker already active". Open Folder, Open Files and Save As now use Electron's native dialogs; main.cjs reads the files with fs and writes back only paths it opened, the same path as `readm3 edit .`. A listener answers the restricted event too, so a page picker can never hang again. Older desktop builds and browsers keep the page pickers. Co-Authored-By: Claude Opus 5.5 --- desktop/main.cjs | 26 ++++++++++++++- desktop/preload.cjs | 2 ++ package.json | 2 +- site/disk.ts | 68 ++++++++++++++++++++++++++-------------- site/test/viewer.spec.ts | 45 ++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 25 deletions(-) diff --git a/desktop/main.cjs b/desktop/main.cjs index 6100cfd..63aabb6 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -15,7 +15,7 @@ * person's password manager is: OAuth with a loopback redirect, then one exchange of the * token for a session cookie in this window (POST /oauth/desktop-session). */ -const { app, BrowserWindow, Menu, ipcMain, net, protocol, session, shell } = require("electron"); +const { app, BrowserWindow, Menu, dialog, ipcMain, net, protocol, session, shell } = require("electron"); const { createHash, randomBytes } = require("node:crypto"); const { createServer } = require("node:http"); const { open, readFile, readdir, writeFile, stat } = require("node:fs/promises"); @@ -258,6 +258,30 @@ else { return new Uint8Array(buffer.subarray(0, bytesRead)); } finally { await handle.close(); } }); + // Open Folder and Open Files use the system dialog and are read here, like a launch. The + // page's own picker (showDirectoryPicker) never returns in Electron for a folder Chromium + // calls sensitive, such as ~/Desktop, and every later click fails with "picker already active". + ipcMain.handle("pick", async (event, folder) => { + if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); + const { canceled, filePaths } = await dialog.showOpenDialog(window, { + title: folder ? "Open Folder" : "Open Files", + properties: folder ? ["openDirectory"] : ["openFile", "multiSelections"], + }); + return canceled ? null : load(filePaths); + }); + // Save As in the system dialog; the chosen path becomes one this process may write. + ipcMain.handle("save-as", async (event, name) => { + if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); + const { canceled, filePath } = await dialog.showSaveDialog(window, { defaultPath: typeof name === "string" ? basename(name) : undefined }); + if (canceled || !filePath) return null; + opened.add(filePath); + return { path: filePath, name: basename(filePath) }; + }); + // A page picker that lands on a sensitive folder waits for this answer, forever if no one gives it. + // The person chose the folder in the system dialog, so readm3's own pages may have it. + session.defaultSession.on("file-system-access-restricted", (details, callback) => { + callback(trusted(details.origin) ? "allow" : "deny"); + }); ipcMain.handle("write-file", async (event, path, data) => { if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); if (typeof path !== "string" || !opened.has(path)) throw new Error("readm3 can only save files it opened."); diff --git a/desktop/preload.cjs b/desktop/preload.cjs index c6a869b..f5021b1 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -15,5 +15,7 @@ if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && o readFile: (path) => ipcRenderer.invoke("read-file", path), readRange: (path, start, end) => ipcRenderer.invoke("read-range", path, start, end), writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data), + pick: (folder) => ipcRenderer.invoke("pick", folder), + saveAs: (name) => ipcRenderer.invoke("save-as", name), }); } diff --git a/package.json b/package.json index a55e62a..161f3f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.20.0", + "version": "0.21.0", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/site/disk.ts b/site/disk.ts index 7e85705..e623c76 100644 --- a/site/disk.ts +++ b/site/disk.ts @@ -33,6 +33,12 @@ export function fingerprint(text: string): number { const cancelled = (error: unknown) => error instanceof DOMException && error.name === "AbortError"; export async function pickFolder(): Promise<{ name: string; files: Picked[] } | null> { + // The desktop app opens folders with the system dialog and reads them itself (desktop/main.cjs). + if (desktop?.pick) { + const files = await desktop.pick(true); + if (!files) return null; + return { name: files.find((file) => file.relative)?.relative!.split("/")[0] ?? "Your workspace", files: await fromDesktop(files) }; + } let root: Directory; try { root = await picker().showDirectoryPicker({ id: "readm3", mode: "readwrite" }) as Directory; } catch (error) { if (cancelled(error)) return null; throw error; } @@ -53,6 +59,10 @@ export async function pickFolder(): Promise<{ name: string; files: Picked[] } | } export async function pickFiles(): Promise { + if (desktop?.pick) { + const files = await desktop.pick(false); + return files && fromDesktop(files); + } let handles: FileSystemFileHandle[]; try { handles = await picker().showOpenFilePicker({ id: "readm3", multiple: true }); } catch (error) { if (cancelled(error)) return null; throw error; } @@ -78,6 +88,9 @@ interface DesktopBridge { readFile?(path: string): Promise<{ data: Uint8Array; modified: number }>; readRange?(path: string, start: number, end: number): Promise; writeFile(path: string, data: string | Uint8Array): Promise; + /** Absent in desktop builds before 0.21.0, which used the page's pickers. */ + pick?(folder: boolean): Promise; + saveAs?(name: string): Promise<{ path: string; name: string } | null>; } /** Present only inside the readm3 desktop app (desktop/preload.cjs). */ export const desktop = typeof window === "undefined" ? undefined : (window as unknown as { readm3Desktop?: DesktopBridge }).readm3Desktop; @@ -88,38 +101,43 @@ export const desktop = typeof window === "undefined" ? undefined : (window as un * desktop hands the file over again the next time it is opened. */ export async function fromDesktop(files: DesktopFile[]): Promise { - const RANGE = 8 * 1024 * 1024; - /** A large file, read range by range into Blob parts, which the browser keeps on disk. */ - const whole = async (entry: DesktopFile): Promise => { - const parts: Blob[] = []; - for (let start = 0; start < entry.size!; start += RANGE) - parts.push(new Blob([await desktop!.readRange!(entry.path, start, Math.min(entry.size!, start + RANGE)) as BlobPart])); - return new File(parts, entry.name); - }; const picked: Picked[] = []; for (const entry of files) { // A large file from a desktop build without ranges cannot be read; it is left out. if (!entry.data && !desktop!.readRange) continue; const file = entry.data ? new File([entry.data.buffer as ArrayBuffer], entry.name) : await whole(entry); - const handle = { - kind: "file", name: entry.name, - // Read from disk each time, so the editor sees a file change under it and a save is not undone. - getFile: async () => { - if (!entry.data) return whole(entry); - if (!desktop!.readFile) return new File([entry.data.buffer as ArrayBuffer], entry.name); - const { data, modified } = await desktop!.readFile(entry.path); - return new File([data.buffer as ArrayBuffer], entry.name, { lastModified: modified }); - }, - createWritable: async () => { - const parts: BlobPart[] = []; - return { write: async (data: BlobPart) => { parts.push(data); }, close: async () => { await desktop!.writeFile(entry.path, new Uint8Array(await new Blob(parts).arrayBuffer())); } }; - }, - } as unknown as FileSystemFileHandle; - picked.push({ file, path: entry.relative ?? entry.name, handle }); + picked.push({ file, path: entry.relative ?? entry.name, handle: desktopHandle(entry) }); } return picked; } +const RANGE = 8 * 1024 * 1024; +/** A large file, read range by range into Blob parts, which the browser keeps on disk. */ +async function whole(entry: DesktopFile): Promise { + const parts: Blob[] = []; + for (let start = 0; start < entry.size!; start += RANGE) + parts.push(new Blob([await desktop!.readRange!(entry.path, start, Math.min(entry.size!, start + RANGE)) as BlobPart])); + return new File(parts, entry.name); +} + +/** A handle over a path the desktop app may read and write; a Save As path has no size or bytes yet. */ +function desktopHandle(entry: DesktopFile): FileSystemFileHandle { + return { + kind: "file", name: entry.name, + // Read from disk each time, so the editor sees a file change under it and a save is not undone. + getFile: async () => { + if (!entry.data && entry.size !== undefined) return whole(entry); + if (!desktop!.readFile) return new File([entry.data!.buffer as ArrayBuffer], entry.name); + const { data, modified } = await desktop!.readFile(entry.path); + return new File([data.buffer as ArrayBuffer], entry.name, { lastModified: modified }); + }, + createWritable: async () => { + const parts: BlobPart[] = []; + return { write: async (data: BlobPart) => { parts.push(data); }, close: async () => { await desktop!.writeFile(entry.path, new Uint8Array(await new Blob(parts).arrayBuffer())); } }; + }, + } as unknown as FileSystemFileHandle; +} + /** Writes the file, asking for write access first if this session has not been granted it. */ export async function write(handle: FileSystemFileHandle, data: string | Uint8Array): Promise { const permissioned = handle as FileSystemFileHandle & Partial; @@ -134,6 +152,10 @@ export async function write(handle: FileSystemFileHandle, data: string | Uint8Ar /** Save As: a new file chosen by the person, or null if they close the picker. */ export async function saveAs(name: string): Promise { + if (desktop?.saveAs) { + const chosen = await desktop.saveAs(name); + return chosen && desktopHandle(chosen); + } try { return await picker().showSaveFilePicker({ id: "readm3", suggestedName: name }); } catch (error) { if (cancelled(error)) return null; throw error; } } diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 19af1a1..cdb4d01 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -98,6 +98,51 @@ test("opens a folder from disk, marks unsaved files, and writes them back with C expect(await page.evaluate(() => (window as unknown as { written: Record }).written["saved-Welcome.md"])).toContain("Your docs. Your space."); }); +test("in the desktop app, opens and saves through the system dialogs, never the page's pickers", async ({ page }) => { + // The page's pickers hang in Electron on a folder such as ~/Desktop; the desktop bridge must be used instead. + await page.addInitScript(() => { + const written: Record = {}; + const bytes = (text: string) => new TextEncoder().encode(text); + const disk: Record = { "/home/me/Desktop/todo.md": "# Todo\n", "/home/me/Desktop/sub/a.txt": "a\n" }; + Object.assign(window, { + written, + showDirectoryPicker: async () => { throw new Error("page picker used"); }, + showOpenFilePicker: async () => { throw new Error("page picker used"); }, + showSaveFilePicker: async () => { throw new Error("page picker used"); }, + readm3Desktop: { + takeLaunchFiles: async () => [], + onOpenFiles: () => {}, + pick: async (folder: boolean) => folder + ? [{ path: "/home/me/Desktop/todo.md", name: "todo.md", relative: "Desktop/todo.md", size: 7, data: bytes(disk["/home/me/Desktop/todo.md"]) }, + { path: "/home/me/Desktop/sub/a.txt", name: "a.txt", relative: "Desktop/sub/a.txt", size: 2, data: bytes(disk["/home/me/Desktop/sub/a.txt"]) }] + : null, + saveAs: async (name: string) => ({ path: `/home/me/${name}`, name }), + readFile: async (path: string) => ({ data: bytes(written[path] ?? disk[path]), modified: 1 }), + readRange: async () => new Uint8Array(), + writeFile: async (path: string, data: string | Uint8Array) => { written[path] = typeof data === "string" ? data : new TextDecoder().decode(data); }, + }, + }); + }); + await page.reload(); + await page.locator("#open-folder").click(); + await expect(page.locator("#workspace-name")).toHaveText("Desktop"); + await expect(page.locator('#file-list button[title="Desktop/sub/a.txt"]')).toBeVisible(); + await page.locator('#file-list button[title="Desktop/todo.md"]').click(); + await expect(page.locator("#save-status")).toHaveText("Saved to disk"); + await page.locator("#reader").press("e"); + await editorInput(page).fill("# Todo\n\n- done\n"); + await page.keyboard.press("Control+s"); + await expect(page.locator("#save-status")).toHaveText("Saved to disk · todo.md"); + expect(await page.evaluate(() => (window as unknown as { written: Record }).written["/home/me/Desktop/todo.md"])).toBe("# Todo\n\n- done\n"); + // Closing the dialog is not an error. + await page.locator("#open-files").click(); + await expect(page.locator("#notice")).toBeHidden(); + await page.locator('#file-list button[title="Welcome.md"]').click(); + await page.locator("#reader").press("Control+s"); + await expect(page.locator("#save-status")).toHaveText("Saved to disk · Welcome.md"); + expect(await page.evaluate(() => (window as unknown as { written: Record }).written["/home/me/Welcome.md"])).toContain("Your docs. Your space."); +}); + test("keeps a rolling history: opened, saved, changed on disk, with snippets and restore", async ({ page }) => { // A fake disk whose file can change under the editor, as a regenerated output.json does. await page.addInitScript(() => { From 648c39d543859de8e979e23221fc9e0c37658181 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 23 Oct 2026 07:21:48 +0000 Subject: [PATCH 2/2] Bump the CLI's reported version to 0.21.0 Co-Authored-By: Claude Opus 5.5 --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 728bdd9..51844b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.20.0"; +export const VERSION = "0.21.0"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code