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
50 changes: 50 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, String> {
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 —
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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': '显示详情',
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
if (url === '' || !isTauri()) return false;
try {
const { invoke } = await import('@tauri-apps/api/core');
return await invoke<boolean>('frame_check', { url });
} catch {
return false; // unreachable / unsupported scheme — not a refusal
}
}
15 changes: 11 additions & 4 deletions desktop/src/styles/partials/03-pdf.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/surfaces/AuthorNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileNode[]>('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<void> {
if (!tauri) return;
try {
Expand Down
52 changes: 39 additions & 13 deletions desktop/src/surfaces/BrowserView.tsx
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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();
Expand All @@ -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<string | null>(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;
Expand Down Expand Up @@ -85,16 +103,24 @@ export function BrowserView({ initialUrl }: { initialUrl: string }): JSX.Element
<Icon name="external" />
</button>
</div>
<div className="browser-hint muted small">{t('read.browserFrameHint')}</div>
<div className="browser-frame-wrap">
<iframe
key={`${idx}:${nonce}`}
className="browser-frame"
title={hostOf(current)}
src={current}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
referrerPolicy="no-referrer"
/>
{refused !== null && refused === current ? (
<div className="browser-refused">
<p className="muted">{t('read.browserFrameRefused').replace('{host}', hostOf(current))}</p>
<button className="browser-nav" onClick={() => openExternal(current)}>
<Icon name="external" /> {t('read.openExternal')}
</button>
</div>
) : (
<iframe
key={`${idx}:${nonce}`}
className="browser-frame"
title={hostOf(current)}
src={current}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
referrerPolicy="no-referrer"
/>
)}
</div>
</div>
);
Expand Down
12 changes: 4 additions & 8 deletions desktop/src/surfaces/CompareSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { num, str, type Entity } from '../hub/types';
import { useT } from '../i18n';
import { useSession } from '../state/session';
import { parsePoints } from '../ui/Sparkline';
import { ChartView, type ChartSeries } from '../ui/ChartView';
import { ChartView, CHART_PALETTE, type ChartSeries } from '../ui/ChartView';
import { WorkbenchSurface } from '../ui/WorkbenchSurface';

/// J5 — Compare many runs. The headline BUILD from `research-tooling-landscape.md`
Expand All @@ -16,13 +16,9 @@ import { WorkbenchSurface } from '../ui/WorkbenchSurface';
/// is intrinsically wide-screen (the job the phone can't do). Next rounds add the
/// config-diff panel and the optuna-dashboard sweep EMBED.

const SWATCHES = [
'var(--color-primary)',
'var(--color-terminal-cyan)',
'var(--color-terminal-yellow)',
'var(--color-secondary)',
'var(--color-terminal-green)',
];
// Run swatches share the chart renderer's palette (single source, #322) so a
// run's swatch always matches its overlay curve.
const SWATCHES = CHART_PALETTE;

function runLabel(r: Entity): string {
const id = str(r, 'id') ?? '';
Expand Down
14 changes: 3 additions & 11 deletions desktop/src/surfaces/ReadSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { Markdown } from '../ui/Markdown';
import { MarkdownReader } from '../ui/MarkdownReader';
import { NoteTab } from '../ui/NoteTab';
import { MarkdownEditor } from '../ui/MarkdownEditor';
import { useNotesMode } from '../ui/useNotesMode';
import { Icon, type IconName } from '../ui/Icon';
import { PasswordInput } from '../ui/PasswordInput';
import { OpenLinkContext, useOpenLink } from '../ui/OpenLinkContext';
Expand Down Expand Up @@ -820,17 +821,8 @@ function Inspector({
const removeAttachmentFromRef = useLibrary((s) => s.removeAttachment);
const [attBusy, setAttBusy] = useState(false);
const [attErr, setAttErr] = useState<string | null>(null);
const [notesMode, setNotesMode] = useState<'wysiwyg' | 'source' | 'preview'>(
() => (localStorage.getItem('termipod.read.notesMode') as 'wysiwyg' | 'source' | 'preview') || 'wysiwyg',
);
function pickNotesMode(m: 'wysiwyg' | 'source' | 'preview'): void {
setNotesMode(m);
try {
localStorage.setItem('termipod.read.notesMode', m);
} catch {
/* ignore */
}
}
// Shared with the full-width note tab (single persisted `notesMode` key, #322).
const [notesMode, pickNotesMode] = useNotesMode();

async function exportNotes(): Promise<void> {
if (ref === undefined || !isTauri()) return;
Expand Down
8 changes: 5 additions & 3 deletions desktop/src/ui/ChartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ export function chartFromJson(data: unknown): ChartData | null {
return null;
}

const PALETTE = [
// The one chart palette for the whole app — CompareSurface's run swatches use
// the same source so an overlay curve and its table swatch never drift (#322).
export const CHART_PALETTE = [
'var(--color-primary)',
'var(--color-terminal-cyan)',
'var(--color-terminal-yellow)',
Expand Down Expand Up @@ -223,7 +225,7 @@ export function ChartView({ chart }: { chart: ChartData }): JSX.Element {
})
) : (
chart.series.map((s, si) => {
const color = PALETTE[si % PALETTE.length];
const color = CHART_PALETTE[si % CHART_PALETTE.length];
const pts = s.points
.map((p, i) => `${idxToPx(i, s.points.length).toFixed(1)},${yToPx(p.y).toFixed(1)}`)
.join(' ');
Expand Down Expand Up @@ -258,7 +260,7 @@ export function ChartView({ chart }: { chart: ChartData }): JSX.Element {
<div className="chart-legend">
{chart.series.map((s, si) => (
<span key={`l${si}`} className="chart-legend-item">
<span className="chart-swatch" style={{ background: PALETTE[si % PALETTE.length] }} />
<span className="chart-swatch" style={{ background: CHART_PALETTE[si % CHART_PALETTE.length] }} />
{s.name ?? `series ${si + 1}`}
</span>
))}
Expand Down
16 changes: 14 additions & 2 deletions desktop/src/ui/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, {

// Reconcile an external value change (agent insert, undo from elsewhere) into
// the doc. The editor's own edits set value === current, so this is a no-op then.
// Dispatch only the minimal changed span (common prefix/suffix trimmed): a
// full-document replace maps the cursor to the doc end and records one giant
// inverse in the undo history (#322), while a targeted change lets CodeMirror
// map the selection across it and keeps undo granular.
useEffect(() => {
const view = viewRef.current;
if (view === null) return;
const cur = view.state.doc.toString();
if (value !== cur) {
view.dispatch({ changes: { from: 0, to: cur.length, insert: value } });
if (value === cur) return;
let from = 0;
const minLen = Math.min(cur.length, value.length);
while (from < minLen && cur[from] === value[from]) from++;
let toCur = cur.length;
let toNew = value.length;
while (toCur > from && toNew > from && cur[toCur - 1] === value[toNew - 1]) {
toCur--;
toNew--;
}
view.dispatch({ changes: { from, to: toCur, insert: value.slice(from, toNew) } });
}, [value]);

return <div className="md-editor" ref={hostRef} />;
Expand Down
Loading
Loading