From 58c05645f31b98160aec9cab4efb3aac3f2b3a05 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 15:26:08 +0000 Subject: [PATCH] Desktop: Sign in with your browser, and Open in app from readm3.com (v0.15.0) The desktop window has no password manager, and an emailed sign-in link opens in the browser, so signing in inside the app did not work. The Account page in the app now offers Sign in with your browser: OAuth (new readm3-desktop client, loopback redirect, PKCE) in the system browser, then POST /oauth/desktop-session trades that token once for a session cookie set in the app's window. Only desktop-client tokens are accepted. readm3.com/viewer gets Open in app: a readm3://open?url= link the app now registers (electron-builder protocols, installer desktop entry %U + xdg-mime); links off readm3.com are ignored. Falls back to Get the app when nothing answers; hidden on phones and inside the app. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++ desktop/main.cjs | 86 +++++++++++++++++++++++++++++++++++++-- desktop/package.json | 3 +- desktop/preload.cjs | 1 + package.json | 2 +- server/accounts.ts | 2 +- server/oauth.ts | 27 +++++++++++- server/test/oauth.test.ts | 41 +++++++++++++++++++ site/account.ts | 20 ++++++++- site/assets/account.html | 1 + site/assets/install.sh | 6 ++- site/assets/viewer.html | 1 + site/viewer.ts | 28 +++++++++++++ src/cli.ts | 2 +- 14 files changed, 217 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ed464be..9026bb8 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,16 @@ or lands in History when you have unsaved edits. There is no `--wait`: a second hands its files to the window already open and exits, so `readm3 edit` is not a `core.editor` for git. +**Signing in** in the app is **Sign in with your browser** on the Account page: your +browser opens readm3.com, where your password manager and email are, you approve the +desktop app, and the app is signed in (OAuth with a loopback redirect, then one exchange +of that token for the window's session at `POST /oauth/desktop-session`). An emailed link +or CoinPay page inside the app would sign in the browser instead, so the app does not offer them. + +**Open in app** on readm3.com/viewer opens the same page in the desktop app through a +`readm3://open?url=…` link the app registers (macOS, Windows, and Linux through the +installer's desktop entry). When nothing answers, the button becomes **Get the app**. + Files opened with "Open with" (Finder and Explorer included) go straight into the editor. The window loads readm3.com, so sign-in, sync and sharing work as on the web; a copy of the editor ships inside the app for a first launch with no connection. The diff --git a/desktop/main.cjs b/desktop/main.cjs index d16ba4d..4987cbb 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -9,11 +9,18 @@ * Files and folders named on the command line (`readm3 edit .`, like `subl .`), or * handed over by the desktop's "Open with", are read here and given to the page * through preload.cjs, which can write back only to files this process gave it. + * + * readm3:// links ("Open in app" on readm3.com) open a readm3.com page in this window, and + * "Sign in with your browser" signs the window in through the system browser, where the + * 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, shell } = require("electron"); +const { app, BrowserWindow, Menu, ipcMain, net, protocol, session, shell } = require("electron"); +const { createHash, randomBytes } = require("node:crypto"); +const { createServer } = require("node:http"); const { readFile, readdir, writeFile, stat } = require("node:fs/promises"); const { basename, join, resolve } = require("node:path"); -const { pathToFileURL } = require("node:url"); +const { fileURLToPath, pathToFileURL } = require("node:url"); const ORIGIN = (process.env.READM3_URL || "https://readm3.com").replace(/\/$/, ""); const LOCAL = "app://readm3"; @@ -69,7 +76,68 @@ async function load(paths, cwd = process.cwd()) { } /** Arguments that name files, not switches; argv[0] is the binary and, unpackaged, argv[1] is the app. */ -const fileArgs = (argv) => argv.slice(app.isPackaged ? 1 : 2).filter((arg) => !arg.startsWith("-")); +// A desktop entry with %U hands local files over as file:// URLs. +const fileArgs = (argv) => argv.slice(app.isPackaged ? 1 : 2).filter((arg) => !arg.startsWith("-") && !arg.startsWith("readm3:")) + .map((arg) => (arg.startsWith("file://") ? fileURLToPath(arg) : arg)); +const linkArg = (argv) => argv.find((arg) => arg.startsWith("readm3:")); + +/** The readm3.com page a readm3://open?url=… link names; anything off that origin is ignored. */ +function linkTarget(link) { + try { + const target = new URL(link).searchParams.get("url"); + return target && target.startsWith(`${ORIGIN}/`) ? target : undefined; + } catch { return undefined; } +} +let launchLink; +function openLink(link) { + const target = link && linkTarget(link); + if (!window) { launchLink = target; return; } + if (window.isMinimized()) window.restore(); + window.focus(); + if (target) void window.loadURL(target); +} + +const base64url = (bytes) => Buffer.from(bytes).toString("base64url"); +/** Sign this window in through the system browser; resolves once the session cookie is set. */ +async function signInWithBrowser() { + const verifier = base64url(randomBytes(32)); + const state = base64url(randomBytes(16)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + const server = createServer(); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const redirect = `http://127.0.0.1:${server.address().port}/callback`; + try { + const code = await new Promise((done, fail) => { + const timer = setTimeout(() => fail(new Error("Sign-in timed out. Try again.")), 10 * 60 * 1000); + server.on("request", (request, response) => { + const url = new URL(request.url, redirect); + if (url.pathname !== "/callback") { response.writeHead(404).end(); return; } + const ok = url.searchParams.get("state") === state && url.searchParams.get("code"); + response.writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" }); + response.end(`readm3

${ok ? "You are signed in to the readm3 desktop app. You can close this tab." : "Sign-in was cancelled. Nothing was shared."}

`); + clearTimeout(timer); + if (ok) done(url.searchParams.get("code")); else fail(new Error("Sign-in was cancelled.")); + }); + const authorize = new URL(`${ORIGIN}/oauth/authorize`); + for (const [key, value] of Object.entries({ response_type: "code", client_id: "readm3-desktop", redirect_uri: redirect, code_challenge: challenge, code_challenge_method: "S256", state })) + authorize.searchParams.set(key, value); + void shell.openExternal(authorize.toString()); + }); + const tokens = await (await fetch(`${ORIGIN}/oauth/token`, { + method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "authorization_code", client_id: "readm3-desktop", code, redirect_uri: redirect, code_verifier: verifier }), + })).json(); + if (!tokens.access_token) throw new Error(tokens.error_description || "Sign-in failed. Try again."); + const response = await fetch(`${ORIGIN}/oauth/desktop-session`, { method: "POST", headers: { authorization: `Bearer ${tokens.access_token}` } }); + const cookie = await response.json(); + if (!response.ok) throw new Error(cookie.error_description || "Sign-in failed. Try again."); + await session.defaultSession.cookies.set({ + url: ORIGIN, name: cookie.name, value: cookie.value, path: "/", httpOnly: true, + secure: ORIGIN.startsWith("https:"), sameSite: "lax", expirationDate: Date.now() / 1000 + cookie.maxAge, + }); + window?.focus(); + } finally { server.close(); } +} async function openPaths(paths, cwd) { const files = await load(paths, cwd); @@ -95,7 +163,8 @@ function createWindow() { window.webContents.on("will-navigate", (event, url) => { if (!/^https:/.test(url) && !trusted(url)) { event.preventDefault(); } }); - window.loadURL(`${ORIGIN}/viewer`).catch(() => window.loadURL(`${LOCAL}/viewer`)); + window.loadURL(launchLink ?? `${ORIGIN}/viewer`).catch(() => window.loadURL(`${LOCAL}/viewer`)); + launchLink = undefined; } /** Menu items run the page's own commands by name, the same ones the command palette lists. */ @@ -145,7 +214,10 @@ else { app.on("second-instance", (_event, argv, workingDirectory) => { if (window) { if (window.isMinimized()) window.restore(); window.focus(); } void openPaths(fileArgs(argv), workingDirectory); + if (linkArg(argv)) openLink(linkArg(argv)); }); + // macOS delivers readm3:// links as events; Windows and Linux put them in argv. + app.on("open-url", (event, link) => { event.preventDefault(); openLink(link); }); // macOS hands files over as events (Finder, `open -a readm3`), before and after launch. app.on("open-file", (event, path) => { event.preventDefault(); void openPaths([path]); }); app.whenReady().then(async () => { @@ -156,7 +228,12 @@ else { if (!file.startsWith(SITE)) return new Response("Not found", { status: 404 }); return net.fetch(pathToFileURL(file).toString()); }); + if (app.isPackaged) app.setAsDefaultProtocolClient("readm3"); ipcMain.handle("launch-files", () => { const files = pending; pending = []; return files; }); + ipcMain.handle("sign-in-with-browser", async (event) => { + if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); + await signInWithBrowser(); + }); // The page re-reads a file to notice it changing on disk (a git checkout, a build). ipcMain.handle("read-file", async (event, path) => { if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); @@ -171,6 +248,7 @@ else { }); Menu.setApplicationMenu(menu()); pending.push(...await load(fileArgs(process.argv))); + if (linkArg(process.argv)) launchLink = linkTarget(linkArg(process.argv)); createWindow(); }); app.on("window-all-closed", () => app.quit()); diff --git a/desktop/package.json b/desktop/package.json index 83f1150..3e44184 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.14.1", + "version": "0.15.0", "private": true, "description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.", "author": "Profullstack, Inc. ", @@ -28,6 +28,7 @@ "to": "site", "filter": ["viewer/**", "viewer-assets/**", "viewer.webmanifest", "favicon.svg"] }], + "protocols": [{ "name": "readm3", "schemes": ["readm3"] }], "fileAssociations": [ { "ext": ["md", "markdown"], "name": "Markdown", "role": "Editor" }, { "ext": ["txt", "json", "yaml", "yml", "toml", "js", "ts", "py", "sh", "css", "html"], "name": "Text", "role": "Editor" } diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 295d3fa..3f79bd8 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -10,6 +10,7 @@ const custom = typeof process !== "undefined" ? process.env?.READM3_URL?.replace if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && origin === custom)) { contextBridge.exposeInMainWorld("readm3Desktop", { takeLaunchFiles: () => ipcRenderer.invoke("launch-files"), + signInWithBrowser: () => ipcRenderer.invoke("sign-in-with-browser"), onOpenFiles: (callback) => { ipcRenderer.on("open-files", (_event, files) => callback(files)); }, readFile: (path) => ipcRenderer.invoke("read-file", path), writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data), diff --git a/package.json b/package.json index 206f882..20a1c8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.14.1", + "version": "0.15.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/server/accounts.ts b/server/accounts.ts index 3522c5c..6544b8c 100644 --- a/server/accounts.ts +++ b/server/accounts.ts @@ -7,7 +7,7 @@ const secret = () => randomBytes(32).toString("base64url"); const id = () => randomBytes(12).toString("base64url"); const hash = (value: string) => createHash("sha256").update(value).digest("hex"); const stamp = () => new Date().toISOString(); -const SESSION_SECONDS = 30 * 86400; +export const SESSION_SECONDS = 30 * 86400; /** `email` is null for an account that signs in only through a linked identity (CoinPay). */ export type Account = { id: string; username: string; displayName: string; admin: number; createdAt: string; email: string | null; emailVerifiedAt: string | null }; export type Mail = { to: string; url: string }; diff --git a/server/oauth.ts b/server/oauth.ts index 9eb5e6c..7aa6c32 100644 --- a/server/oauth.ts +++ b/server/oauth.ts @@ -12,12 +12,18 @@ * * The CLI uses a loopback redirect when it can open a browser on the same machine, * and /oauth/cli otherwise, so a login started over SSH never lands on localhost. + * + * The desktop app signs in the same way (its window has no password manager, and an + * emailed link opens in the browser), then trades its access token once for a + * browser session in its own window: + * - POST /oauth/desktop-session Bearer readm3-desktop token → session cookie value */ import type { Database } from "bun:sqlite"; import { createOAuthServer, OAuthError, type OAuthStore, type TokenRow } from "@profullstack/auth-system/oauth2"; -import type { Accounts } from "./accounts.ts"; +import { SESSION_SECONDS, type Accounts } from "./accounts.ts"; export const CLI_CLIENT_ID = "readm3-cli"; +export const DESKTOP_CLIENT_ID = "readm3-desktop"; const TOKEN_PREFIX = "r3"; // r3_at_…, r3_rt_… (OAUTH_ACCESS_PREFIX in accounts.ts) const iso = (value: Date | string) => new Date(value).toISOString(); @@ -108,6 +114,10 @@ export function createOAuth(accounts: Accounts) { // Any port on 127.0.0.1 (RFC 8252), or the paste-a-code page for SSH sessions. redirectUris: ["http://127.0.0.1/callback", `${origin}/oauth/cli`], }, + [DESKTOP_CLIENT_ID]: { + name: "the readm3 desktop app", + redirectUris: ["http://127.0.0.1/callback"], + }, }, }); @@ -133,6 +143,19 @@ export function createOAuth(accounts: Accounts) { } } + if (path === "/oauth/desktop-session") { + if (request.method !== "POST") return json({ error: "invalid_request", error_description: "Use POST." }, 405); + const bearer = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1] ?? ""; + // Only a token issued to the desktop app becomes a browser session; a CLI token never does. + const grant = bearer ? await oauth.verifyAccessToken(bearer) : null; + const user = grant?.clientId === DESKTOP_CLIENT_ID ? accounts.authenticate(bearer) : null; + if (!user) return json({ error: "invalid_token", error_description: "Sign in again from the desktop app." }, 401); + const session = accounts.startSession(user.id, "", "Desktop app"); + // The token has done its one job; the session cookie is what the window keeps. + await oauth.revoke(bearer); + return json({ name: accounts.cookieName, value: session, maxAge: SESSION_SECONDS }); + } + if (path === "/oauth/cli" && request.method === "GET") { const error = url.searchParams.get("error"); const code = url.searchParams.get("code"); @@ -169,7 +192,7 @@ export function createOAuth(accounts: Accounts) { return page( `Sign in to ${params.clientName}?`, `

${escape(params.clientName)} will be able to read and edit your readm3 documents as ${escape(who)}.

-

Only approve if you just ran readm3 login yourself.

+

Only approve if you just ${params.clientId === DESKTOP_CLIENT_ID ? "chose Sign in with your browser in the readm3 desktop app" : "ran readm3 login"} yourself.

${hidden}
`, ); } diff --git a/server/test/oauth.test.ts b/server/test/oauth.test.ts index ca04887..1c1e60a 100644 --- a/server/test/oauth.test.ts +++ b/server/test/oauth.test.ts @@ -144,3 +144,44 @@ test("an unregistered redirect_uri is refused on the page, never redirected to", expect(response.status).toBe(400); expect(response.headers.get("location")).toBeNull(); }); + +/** Approve a loopback sign-in for a client as the signed-in browser, and return its access token. */ +async function loopbackToken(url: string, cookie: string, clientId: string) { + const verifier = "d".repeat(64); + const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url"); + const params = { response_type: "code", client_id: clientId, redirect_uri: "http://127.0.0.1:9/callback", code_challenge: challenge, code_challenge_method: "S256", state: "s" }; + const approved = await fetch(`${url}/oauth/authorize`, { + method: "POST", redirect: "manual", + headers: { cookie, origin: url, "content-type": "application/x-www-form-urlencoded" }, + body: form({ ...params, decision: "approve" }), + }); + const code = new URL(approved.headers.get("location")!).searchParams.get("code")!; + const tokens = await (await fetch(`${url}/oauth/token`, { + method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, + body: form({ client_id: clientId, grant_type: "authorization_code", code, redirect_uri: params.redirect_uri, code_verifier: verifier }), + })).json(); + return tokens.access_token as string; +} + +test("the desktop app signs in through the browser and trades its token once for a session", async () => { + const { url, user, cookie } = await site(); + const consent = await (await fetch(`${url}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: "readm3-desktop", redirect_uri: "http://127.0.0.1:9/callback", code_challenge: "x".repeat(43), code_challenge_method: "S256", state: "s" })}`, { headers: { cookie } })).text(); + expect(consent).toContain("Sign in with your browser"); + + const access = await loopbackToken(url, cookie, "readm3-desktop"); + const exchange = () => fetch(`${url}/oauth/desktop-session`, { method: "POST", headers: { authorization: `Bearer ${access}` } }); + const granted = await exchange(); + expect(granted.status).toBe(200); + const session = await granted.json(); + expect(session).toMatchObject({ name: "readm3_session", maxAge: 30 * 86400 }); + // The value is an ordinary browser session for the same person. + const me = await (await fetch(`${url}/api/auth/session`, { headers: { cookie: `${session.name}=${session.value}` } })).json(); + expect(me.user.id).toBe(user.id); + // It works once: the token is revoked by the exchange. + expect((await exchange()).status).toBe(401); + + // A CLI token is never turned into a browser session. + const cli = await loopbackToken(url, cookie, "readm3-cli"); + expect((await fetch(`${url}/oauth/desktop-session`, { method: "POST", headers: { authorization: `Bearer ${cli}` } })).status).toBe(401); + expect((await fetch(`${url}/oauth/desktop-session`, { method: "POST" })).status).toBe(401); +}); diff --git a/site/account.ts b/site/account.ts index d14d5c7..59aba0a 100644 --- a/site/account.ts +++ b/site/account.ts @@ -19,6 +19,9 @@ if (coinpayNotice) { returned.delete("coinpay_error"); returned.delete("coinpay"); history.replaceState(null, "", location.pathname + (returned.size ? `?${returned}` : "") + location.hash); } +/** Inside the desktop app (desktop/preload.cjs): sign in through the system browser, where the password manager is. */ +const desktop = (window as unknown as { readm3Desktop?: { signInWithBrowser?: () => Promise } }).readm3Desktop; +const inDesktop = typeof desktop?.signInWithBrowser === "function"; let timer: ReturnType | undefined; let busy = false; @@ -36,7 +39,8 @@ async function coinpay(panel: string) { if (!response.ok) return; } catch { return; } const button = el("coinpay-sign-in"); - button.hidden = !status.enabled || panel !== "sign-in"; + // In the desktop app CoinPay's page would open in this window, with no password manager. + button.hidden = !status.enabled || panel !== "sign-in" || inDesktop; button.href = `/api/v1/coinpay/oauth/start${next ? `?next=${encodeURIComponent(next)}` : ""}`; const linked = status.identities[0]; el("coinpay-link").hidden = panel !== "profile" || (!status.enabled && !linked); @@ -125,6 +129,20 @@ el("coinpay-unlink").addEventListener("click", () => void action(el("#sign-in .fine-print"); + if (fine) fine.textContent = "Your browser opens readm3.com. Sign in there with your email or CoinPay, approve the desktop app, and come back."; + el("desktop-sign-in").addEventListener("click", () => void action(el("desktop-sign-in"), async () => { + message("status", "Finish signing in in your browser, then come back here."); + // Electron prefixes errors from the main process; show only the reason. + try { await desktop!.signInWithBrowser!(); } + catch (error) { throw new Error(String((error as Error).message).replace(/^Error invoking remote method '[^']+': (?:Error: )?/, "")); } + location.assign(next ?? "/viewer"); + })); +} async function initialize() { try { if (token) { diff --git a/site/assets/account.html b/site/assets/account.html index 379afd3..0fd9b80 100644 --- a/site/assets/account.html +++ b/site/assets/account.html @@ -37,6 +37,7 @@

Make yourself at home.

+

No password to remember. Every sign-in link is private, expires in 15 minutes, and works once.

diff --git a/site/assets/install.sh b/site/assets/install.sh index 341ed84..3fe2eec 100755 --- a/site/assets/install.sh +++ b/site/assets/install.sh @@ -164,10 +164,12 @@ $BIN/$name" mkdir -p "$APPS" "$ICONS" fetch "https://readm3.com/viewer-assets/icon-512.png" "$ICONS/readm3.png" || true printf '%s\n' '[Desktop Entry]' 'Type=Application' 'Name=readm3' 'GenericName=Text Editor' \ - "Exec=$BIN/readm3-desktop %F" 'Icon=readm3' 'Categories=Development;TextEditor;Utility;' \ - 'MimeType=text/plain;text/markdown;application/json;text/x-python;text/javascript;text/css;text/html;application/xml;text/x-shellscript;' \ + "Exec=$BIN/readm3-desktop %U" 'Icon=readm3' 'Categories=Development;TextEditor;Utility;' \ + 'MimeType=text/plain;text/markdown;application/json;text/x-python;text/javascript;text/css;text/html;application/xml;text/x-shellscript;x-scheme-handler/readm3;' \ 'StartupWMClass=readm3' 'Terminal=false' > "$APPS/readm3.desktop" command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$APPS" >/dev/null 2>&1 || true + # readm3:// links ("Open in app" on readm3.com) start the desktop app. + command -v xdg-mime >/dev/null 2>&1 && xdg-mime default readm3.desktop x-scheme-handler/readm3 >/dev/null 2>&1 || true PATHS="$PATHS $BIN/readm3-desktop $APPS/readm3.desktop diff --git a/site/assets/viewer.html b/site/assets/viewer.html index 563cb81..d589516 100644 --- a/site/assets/viewer.html +++ b/site/assets/viewer.html @@ -26,6 +26,7 @@ Browser workspace + diff --git a/site/viewer.ts b/site/viewer.ts index 68e590f..8dcc39c 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -929,6 +929,33 @@ function receiveLaunches() { }; } +/** + * "Open in app": a readm3:// link the desktop app registers, naming this page so the app + * opens the same document, share or paste. When nothing answers (the page keeps focus), + * the button turns into the download link. + */ +function setupOpenInDesktop() { + // Not inside the app itself, and not on a phone or tablet, which have no desktop app. + if (disk.desktop || /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) || matchMedia("(max-width: 720px)").matches) return; + const button = element("open-desktop"); + button.hidden = false; + let missing = false; + button.onclick = () => { + if (missing) { window.open("https://github.com/profullstack/readm3/releases/latest", "_blank", "noopener"); return; } + let left = false; + const away = () => { left = true; }; + window.addEventListener("blur", away, { once: true }); + location.href = `readm3://open?url=${encodeURIComponent(location.href)}`; + setTimeout(() => { + window.removeEventListener("blur", away); + if (left || document.hidden) return; + missing = true; + button.textContent = "Get the app"; + notice("The readm3 desktop app did not open. Get the app to install it, or run: readm3 desktop install"); + }, 1500); + }; +} + async function setupPwa() { const connection = element("connection"); const connectionStatus = () => { connection.textContent = navigator.onLine ? "Browser workspace" : "Offline · local workspace"; }; @@ -1024,6 +1051,7 @@ async function start() { const topup = params.get("topup"); if (topup) { history.replaceState(null, "", "/viewer"); void awaitTopUp(topup, notice); } void setupPwa(); + setupOpenInDesktop(); } start().catch((error) => { saveStatus.textContent = error instanceof Error ? error.message : "Unable to open the workspace. Reload to try again."; }); diff --git a/src/cli.ts b/src/cli.ts index 8b38eb6..56b91bf 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.14.1"; +export const VERSION = "0.15.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