From 32ff75bc72859851a6ddae118ebfecf078814b05 Mon Sep 17 00:00:00 2001 From: agentfleet Date: Sun, 19 Jul 2026 18:17:35 -0700 Subject: [PATCH] fix(desktop/read): reconcile keep-cursor, AuthorNav watch, table grid nav, frame check (#322) --- desktop/src-tauri/src/lib.rs | 50 +++++++++++ desktop/src/i18n/index.ts | 4 +- desktop/src/platform.ts | 15 ++++ desktop/src/styles/partials/03-pdf.css | 15 +++- desktop/src/surfaces/AuthorNav.tsx | 21 +++++ desktop/src/surfaces/BrowserView.tsx | 52 ++++++++--- desktop/src/surfaces/CompareSurface.tsx | 12 +-- desktop/src/surfaces/ReadSurface.tsx | 14 +-- desktop/src/ui/ChartView.tsx | 8 +- desktop/src/ui/MarkdownEditor.tsx | 16 +++- desktop/src/ui/MarkdownOutline.tsx | 111 ++++++++++++++++++++++++ desktop/src/ui/MarkdownReader.tsx | 96 ++------------------ desktop/src/ui/NoteTab.tsx | 67 ++------------ desktop/src/ui/PdfCanvas.tsx | 15 ++-- desktop/src/ui/TableEditor.tsx | 105 +++++++++++++++++++--- desktop/src/ui/WysiwygEditor.tsx | 33 ++++++- desktop/src/ui/useNotesMode.ts | 24 +++++ 17 files changed, 440 insertions(+), 218 deletions(-) create mode 100644 desktop/src/ui/MarkdownOutline.tsx create mode 100644 desktop/src/ui/useNotesMode.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8cde3c84..b83e19f2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -576,6 +576,55 @@ async fn open_browser_window(app: AppHandle, url: String) -> Result<(), String> Ok(()) } +/// Detect that a page REFUSES to be framed — `X-Frame-Options: DENY/SAMEORIGIN`, +/// or a CSP `frame-ancestors` our custom tauri:// origin can't match — so the +/// in-app browser tab can show an actionable error instead of a silently blank +/// iframe (#322). The webview's own fetch can't read cross-origin headers +/// (CORS), so the preflight runs here through reqwest. Best-effort: an +/// unreachable site answers `false` (not refused) and the iframe gets its chance. +#[tauri::command] +async fn frame_check(url: String) -> Result { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err("unsupported url scheme".into()); + } + let client = net::client_builder(None) + .redirect(reqwest::redirect::Policy::limited(5)) + .timeout(std::time::Duration::from_secs(10)) + // A browser-ish UA: some CDNs only emit the framing headers to those. + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15") + .build() + .map_err(|e| e.to_string())?; + // Headers only — the response body is dropped unread, so this stays cheap. + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(_) => return Ok(false), // unreachable ≠ refused — let the iframe try + }; + let headers = resp.headers(); + if let Some(xfo) = headers.get("x-frame-options").and_then(|v| v.to_str().ok()) { + let v = xfo.trim(); + // SAMEORIGIN never matches our tauri:// origin. ALLOW-FROM is obsolete + // and ignored by modern engines, so it is NOT a refusal. + if v.eq_ignore_ascii_case("deny") || v.eq_ignore_ascii_case("sameorigin") { + return Ok(true); + } + } + for value in headers.get_all("content-security-policy").iter() { + let Ok(csp) = value.to_str() else { continue }; + for directive in csp.split(';') { + let mut parts = directive.trim().split_whitespace(); + let Some(name) = parts.next() else { continue }; + if !name.eq_ignore_ascii_case("frame-ancestors") { + continue; + } + // Only a bare `*` lets a tauri://localhost ancestor through. + if !parts.any(|s| s == "*") { + return Ok(true); + } + } + } + Ok(false) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Register the OS credential store before any keychain command can run — @@ -616,6 +665,7 @@ pub fn run() { open_external, reveal_path, open_browser_window, + frame_check, storage::storage_pick_folder, storage::storage_reindex, storage::storage_read, diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 8099d967..421fa4a0 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1046,7 +1046,7 @@ const en: Dict = { 'read.browserForward': 'Forward', 'read.browserReload': 'Reload', 'read.openInWindow': 'Open in browser window (loads any site)', - 'read.browserFrameHint': 'Some sites (arXiv, Google Scholar, publishers) block embedding — use ⤢ to open them in a real browser window.', + 'read.browserFrameRefused': '{host} refuses to load in an embedded frame (X-Frame-Options / frame-ancestors).', 'read.collapse': 'Collapse', 'read.showSidebar': 'Show collections', 'read.showDetails': 'Show details', @@ -2254,7 +2254,7 @@ const zh: Dict = { 'read.browserForward': '前进', 'read.browserReload': '重新加载', 'read.openInWindow': '在浏览器窗口中打开(可加载任何网站)', - 'read.browserFrameHint': '部分网站(arXiv、Google Scholar、出版商)禁止内嵌 — 用 ⤢ 在真正的浏览器窗口中打开。', + 'read.browserFrameRefused': '{host} 拒绝在内嵌框架中加载(X-Frame-Options / frame-ancestors)。', 'read.collapse': '折叠', 'read.showSidebar': '显示分组', 'read.showDetails': '显示详情', diff --git a/desktop/src/platform.ts b/desktop/src/platform.ts index 05d5b604..bf55e57e 100644 --- a/desktop/src/platform.ts +++ b/desktop/src/platform.ts @@ -91,3 +91,18 @@ export function hostOf(url: string): string { return url; } } + +/// Whether the page at `url` refuses to be embedded in an iframe +/// (`X-Frame-Options` / CSP `frame-ancestors`) — the Rust `frame_check` command +/// preflights the response headers, which the webview's own fetch can't read +/// (CORS). Drives the in-app browser tab's refused-frame error (#322). The +/// browser build has no way to check, so it answers false and lets the frame try. +export async function frameCheck(url: string): Promise { + if (url === '' || !isTauri()) return false; + try { + const { invoke } = await import('@tauri-apps/api/core'); + return await invoke('frame_check', { url }); + } catch { + return false; // unreachable / unsupported scheme — not a refusal + } +} diff --git a/desktop/src/styles/partials/03-pdf.css b/desktop/src/styles/partials/03-pdf.css index 937f485d..3905d5e9 100644 --- a/desktop/src/styles/partials/03-pdf.css +++ b/desktop/src/styles/partials/03-pdf.css @@ -1132,10 +1132,17 @@ border: 1px solid var(--border); border-radius: var(--radius-sm); } -.browser-hint { - padding: var(--spacing-s4) var(--spacing-s12); - border-bottom: 1px solid var(--border); - background: var(--surface); +/* Refused-frame error (#322): replaces the iframe when the page's framing + headers deny embedding; offers the system-browser escape hatch. */ +.browser-refused { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-s8); + padding: var(--spacing-s12); + text-align: center; } .browser-frame-wrap { flex: 1; diff --git a/desktop/src/surfaces/AuthorNav.tsx b/desktop/src/surfaces/AuthorNav.tsx index ff6a7a8b..9c57b0f8 100644 --- a/desktop/src/surfaces/AuthorNav.tsx +++ b/desktop/src/surfaces/AuthorNav.tsx @@ -127,6 +127,27 @@ export function AuthorNav(): JSX.Element { void refresh(folder); }, [folder, rev, refresh]); + // Watch for EXTERNAL changes (a file added/edited outside the app never + // appeared until a manual refresh, #322). src-tauri exposes no fs-watch, so + // poll the same depth/entry-capped `workspace_list` on a slow interval and + // swap the tree only when it actually changed — cheap, quiet (no loading + // spinner), and expanded dirs keep their open state since rows key by path. + useEffect(() => { + if (folder === null || !tauri) return; + const id = setInterval(() => { + if (document.visibilityState === 'hidden') return; + void (async () => { + try { + const next = await invoke('workspace_list', { path: folder }); + setNodes((cur) => (JSON.stringify(cur) === JSON.stringify(next) ? cur : next)); + } catch { + /* folder unplugged / transient error — keep showing the last tree */ + } + })(); + }, 4000); + return () => clearInterval(id); + }, [folder, tauri]); + async function pick(): Promise { if (!tauri) return; try { diff --git a/desktop/src/surfaces/BrowserView.tsx b/desktop/src/surfaces/BrowserView.tsx index f70c261a..2aab57b9 100644 --- a/desktop/src/surfaces/BrowserView.tsx +++ b/desktop/src/surfaces/BrowserView.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useT } from '../i18n'; import { Icon } from '../ui/Icon'; -import { hostOf, openBrowserWindow, openExternal } from '../platform'; +import { frameCheck, hostOf, openBrowserWindow, openExternal } from '../platform'; /// A minimal in-app browser tab (director request: external links open in a /// dedicated tab *inside* the app with navigation buttons, not the OS browser). @@ -11,9 +11,11 @@ import { hostOf, openBrowserWindow, openExternal } from '../platform'; /// read its own `history`. That covers app-initiated navigations (a clicked /// reference URL, a typed address) — which is what the reading workflow needs. /// -/// A caveat we surface rather than hide: some sites refuse to be framed -/// (`X-Frame-Options` / `frame-ancestors`) and render blank. The toolbar always -/// offers "open in system browser" as the escape hatch for those. +/// Some sites refuse to be framed (`X-Frame-Options` / `frame-ancestors`) and +/// render blank. Instead of a static caveat, each navigation preflights the +/// page's framing headers (`frameCheck`) and a refusal renders an actionable +/// error with an "open in system browser" escape hatch (#322); the toolbar's +/// browser-window button covers the sites that merely misbehave. function normalizeUrl(raw: string): string { const s = raw.trim(); @@ -31,11 +33,27 @@ export function BrowserView({ initialUrl }: { initialUrl: string }): JSX.Element const [idx, setIdx] = useState(0); const [nonce, setNonce] = useState(0); const [address, setAddress] = useState(initialUrl); + // The URL whose preflight reported a framing refusal (null = load the iframe). + const [refused, setRefused] = useState(null); const current = history[idx] ?? ''; // Reflect back/forward moves in the address bar (a typed edit stays until Enter). useEffect(() => setAddress(current), [current]); + // Preflight every navigation (and every manual reload, so a retry works): + // a refused frame is detected, not guessed (#322). Best-effort — a failed or + // unavailable check leaves the iframe to try. + useEffect(() => { + setRefused(null); + let dead = false; + void frameCheck(current).then((r) => { + if (!dead && r) setRefused(current); + }); + return () => { + dead = true; + }; + }, [current, nonce]); + function navigate(url: string): void { const u = normalizeUrl(url); if (u === '') return; @@ -85,16 +103,24 @@ export function BrowserView({ initialUrl }: { initialUrl: string }): JSX.Element -
{t('read.browserFrameHint')}
-