Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.");
Expand Down
2 changes: 2 additions & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
68 changes: 45 additions & 23 deletions site/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -53,6 +59,10 @@ export async function pickFolder(): Promise<{ name: string; files: Picked[] } |
}

export async function pickFiles(): Promise<Picked[] | null> {
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; }
Expand All @@ -78,6 +88,9 @@ interface DesktopBridge {
readFile?(path: string): Promise<{ data: Uint8Array; modified: number }>;
readRange?(path: string, start: number, end: number): Promise<Uint8Array>;
writeFile(path: string, data: string | Uint8Array): Promise<void>;
/** Absent in desktop builds before 0.21.0, which used the page's pickers. */
pick?(folder: boolean): Promise<DesktopFile[] | null>;
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;
Expand All @@ -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<Picked[]> {
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<File> => {
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<File> {
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<void> {
const permissioned = handle as FileSystemFileHandle & Partial<Permissioned>;
Expand All @@ -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<FileSystemFileHandle | null> {
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; }
}
Expand Down
45 changes: 45 additions & 0 deletions site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }).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<string, string> = {};
const bytes = (text: string) => new TextEncoder().encode(text);
const disk: Record<string, string> = { "/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<string, string> }).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<string, string> }).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(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading