From e610d4e5519dabfea8f0602b0fdaf4f7e2c92ea2 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Sat, 5 Sep 2026 11:25:33 +0300 Subject: [PATCH] enchancements --- docker/static-server.mjs | 19 +++++++---- src/app/document/export/files.ts | 3 +- src/app/document/io/browser.ts | 18 ++++++++++ src/app/document/io/save.ts | 13 ++++++-- src/app/editor/fonts/index.ts | 39 ++++++++++++++++------ src/app/shell/menu/files.ts | 4 +-- src/components/recovery/RecoveryDialog.vue | 5 ++- tests/e2e/fonts/picker.spec.ts | 11 ++++++ vite/font-proxy.ts | 25 ++++++++++---- 9 files changed, 105 insertions(+), 32 deletions(-) diff --git a/docker/static-server.mjs b/docker/static-server.mjs index 9e44a96b1..ccc6194a9 100644 --- a/docker/static-server.mjs +++ b/docker/static-server.mjs @@ -15,17 +15,22 @@ const ROOT = process.env.OPENPENCIL_WEB_ROOT ?? join(import.meta.dir, 'dist') const PORT = Number(process.env.OPENPENCIL_WEB_PORT ?? 3100) const HOST = process.env.OPENPENCIL_WEB_HOST ?? '0.0.0.0' -// W4C fork delta: same-origin proxy for the CORS-hostile online-font catalog -// (https://fonts.google.com/metadata/fonts). The browser app (which is a W4C -// fork) rewrites those requests to /__font-proxy?url=... so they stay -// same-origin; we forward them upstream here. Mirrors vite/font-proxy.ts. +// W4C fork delta: same-origin proxy for CORS-hostile online-font endpoints +// (https://fonts.google.com/metadata/fonts and https://fonts.googleapis.com/css2). +// The browser app (which is a W4C fork) rewrites those requests to +// /__font-proxy?url=... so they stay same-origin; we forward them upstream here. +// Mirrors vite/font-proxy.ts. const FONT_PROXY_PREFIX = '/__font-proxy' -const PROXIED_FONT_HOSTS = ['fonts.google.com'] +const PROXIED_FONT_HOSTS = ['fonts.google.com', 'fonts.googleapis.com'] const FONT_PROXY_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36' async function handleFontProxy(req, res, hostHeader) { - const target = new URL(req.url ?? '/', `http://${hostHeader}`).searchParams.get('url') + const url = new URL(req.url ?? '/', `http://${hostHeader}`) + const target = url.searchParams.get('url') + // The browser forwards the original `user-agent` here because it cannot send + // one cross-origin; unifont uses it to pick the glyph format (ttf/otf/woff2). + const ua = url.searchParams.get('ua') if (!target) { res.writeHead(400) res.end('missing url') @@ -47,7 +52,7 @@ async function handleFontProxy(req, res, hostHeader) { try { const upstream = await fetch(targetUrl, { headers: { - 'user-agent': FONT_PROXY_USER_AGENT, + 'user-agent': ua ?? FONT_PROXY_USER_AGENT, accept: 'application/json, text/plain, */*' }, redirect: 'follow' diff --git a/src/app/document/export/files.ts b/src/app/document/export/files.ts index f459f67a8..e5e11f584 100644 --- a/src/app/document/export/files.ts +++ b/src/app/document/export/files.ts @@ -10,6 +10,7 @@ import type { import { renderNodesToImage } from '@open-pencil/core/io/formats/raster' import type { SceneGraph } from '@open-pencil/scene-graph' +import { isCrossOriginSubFrame } from '@/app/document/io/browser' import type { ExportOptions } from '@/app/document/export/types' import { isTauri } from '@/app/tauri/env' @@ -158,7 +159,7 @@ export async function saveExportedFile( return } - if (window.showSaveFilePicker) { + if (window.showSaveFilePicker && !isCrossOriginSubFrame()) { try { const handle = await window.showSaveFilePicker({ suggestedName: fileName, diff --git a/src/app/document/io/browser.ts b/src/app/document/io/browser.ts index 3d09761ee..2cf7a92df 100644 --- a/src/app/document/io/browser.ts +++ b/src/app/document/io/browser.ts @@ -30,6 +30,24 @@ export function createDocumentViewportActions(editor: ViewportEditor, viewportSi return { setViewportSize, fitCurrentPageToViewport } } +// The File System Access pickers (showSaveFilePicker/showOpenFilePicker) and +// window.prompt are blocked by browsers inside cross-origin sub-frames +// (Chrome: "Cross origin sub frames aren't allowed to show a file picker"). +// Detect that context so callers can fall back to a plain download / file +// input instead of surfacing the picker error. +export function isCrossOriginSubFrame(): boolean { + const top = window.top + if (!top || top === window) return false + try { + // Same-origin sub-frame: the top window is reachable and pickers are allowed. + void top.location.href + return false + } catch { + // Cross-origin sub-frame: top navigation is blocked, pickers are not allowed. + return true + } +} + export function downloadBlob(data: Uint8Array, filename: string, mime: string) { const blob = new Blob([data.buffer as ArrayBuffer], { type: mime }) const url = URL.createObjectURL(blob) diff --git a/src/app/document/io/save.ts b/src/app/document/io/save.ts index 8d4966a44..29427844a 100644 --- a/src/app/document/io/save.ts +++ b/src/app/document/io/save.ts @@ -1,7 +1,7 @@ import type { EditorState } from '@open-pencil/core/editor' import { dialogMessages } from '@open-pencil/vue' -import { downloadBlob } from '@/app/document/io/browser' +import { downloadBlob, isCrossOriginSubFrame } from '@/app/document/io/browser' import { documentNameFromFigPath } from '@/app/document/io/names' import { chooseBrowserFigSaveHandle, chooseTauriFigSavePath } from '@/app/document/io/save-targets' import type { DocumentSourceAccess } from '@/app/document/io/types' @@ -84,7 +84,9 @@ export function createSaveActions({ return } - if (window.showSaveFilePicker) { + const crossOrigin = isCrossOriginSubFrame() + + if (!crossOrigin && window.showSaveFilePicker) { const handle = await chooseBrowserFigSaveHandle() if (!handle) return setStorageBinding(null) @@ -96,7 +98,12 @@ export function createSaveActions({ return } - const filename = prompt(dialogMessages.get().saveAsPrompt, getDownloadName() ?? 'Untitled.fig') + // Browsers without a usable File System Access picker fall back to a name + // prompt + download. In a cross-origin sub-frame window.prompt is also + // blocked, so download with the current/default name instead. + const filename = crossOrigin + ? getDownloadName() ?? 'Untitled.fig' + : prompt(dialogMessages.get().saveAsPrompt, getDownloadName() ?? 'Untitled.fig') if (!filename) return setStorageBinding(null) setDownloadName(filename) diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index 175ad1c5c..d3688b695 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -71,17 +71,21 @@ function configureTauriFontCache() { fontManager.setHostFontLoader(loadSystemFont) } -// W4C fork delta: relay CORS-hostile online-font catalog requests through a -// same-origin endpoint so the browser app can build the Google Fonts catalog. -// unifont's `google` provider reads `https://fonts.google.com/metadata/fonts`, -// which never sends `Access-Control-Allow-Origin` and sets `cross-origin-resource-policy: -// same-site`, so it cannot be read from any non-Google origin. The browser here -// rewrites those to `/__font-proxy?url=...` (served by a Vite dev plugin and by -// docker/static-server.mjs), which keeps the request same-origin while the server -// forwards it upstream. Glyph files (fonts.gstatic.com) already send CORS headers -// and are fetched directly. +// W4C fork delta: relay CORS-hostile online-font requests through a same-origin +// endpoint so the browser app can build the Google Fonts catalog and resolve +// font faces. Two Google hosts are CORS-hostile from the browser: +// - `https://fonts.google.com/metadata/fonts` — never sends +// `Access-Control-Allow-Origin` and sets `cross-origin-resource-policy: +// same-site`, so it cannot be read from any non-Google origin. +// - `https://fonts.googleapis.com/css2` — its OPTIONS preflight response +// carries no CORS headers, so some browsers (notably Firefox) block the +// font-discovery fetch outright. +// The browser here rewrites both to `/__font-proxy?url=...` (served by the Vite +// dev plugin and by docker/static-server.mjs), which keeps the request +// same-origin while the server forwards it upstream. Glyph files +// (fonts.gstatic.com) already send CORS headers and are fetched directly. const FONT_PROXY_PREFIX = '/__font-proxy' -const PROXIED_FONT_HOSTS = new Set(['fonts.google.com']) +const PROXIED_FONT_HOSTS = new Set(['fonts.google.com', 'fonts.googleapis.com']) // Capture the genuine native fetch once, at module load. `withFetchProxy` in // the core font resolver temporarily swaps `globalThis.fetch` for a wrapper @@ -100,7 +104,20 @@ function createBrowserFontFetch(): (url: string, init?: RequestInit) => Promise< console.warn(`[fonts] invalid URL skipped for font proxy: ${url}`) } if (PROXIED_FONT_HOSTS.has(host)) { - return nativeFetch(`${FONT_PROXY_PREFIX}?url=${encodeURIComponent(url)}`, init) + // Browsers forbid sending a `user-agent` request header, but the font + // provider (unifont) uses it to choose the glyph format (ttf/otf/woff2). + // Surface it as a query param so the proxy can forward the original UA and + // the provider still gets the format it asked for (CanvasKit is tested on + // ttf/otf). The static/vite proxy reads it back and falls back to a default. + let ua = '' + try { + ua = new Headers(init?.headers).get('user-agent') ?? '' + } catch { + // Invalid/omitted headers — no UA to forward. + console.warn(`[fonts] could not read user-agent for font proxy: ${url}`) + } + const uaQuery = ua ? `&ua=${encodeURIComponent(ua)}` : '' + return nativeFetch(`${FONT_PROXY_PREFIX}?url=${encodeURIComponent(url)}${uaQuery}`, init) } return nativeFetch(url, init) } diff --git a/src/app/shell/menu/files.ts b/src/app/shell/menu/files.ts index bacea5547..556bca85c 100644 --- a/src/app/shell/menu/files.ts +++ b/src/app/shell/menu/files.ts @@ -1,7 +1,7 @@ import { useFileDialog } from '@vueuse/core' import { setOpenPencilOpenFileHandler } from '@/app/browser-bridge' -import { resolveBrowserFileURL } from '@/app/document/io/browser' +import { resolveBrowserFileURL, isCrossOriginSubFrame } from '@/app/document/io/browser' import { notificationMessages } from '@/app/i18n/notifications' import { toast } from '@/app/shell/ui' import { openFileInNewTab } from '@/app/tabs' @@ -80,7 +80,7 @@ export async function openFileDialog() { return } - if (window.showOpenFilePicker) { + if (window.showOpenFilePicker && !isCrossOriginSubFrame()) { try { const handles = await window.showOpenFilePicker({ multiple: true, diff --git a/src/components/recovery/RecoveryDialog.vue b/src/components/recovery/RecoveryDialog.vue index 4bb46da03..0ebef6dcb 100644 --- a/src/components/recovery/RecoveryDialog.vue +++ b/src/components/recovery/RecoveryDialog.vue @@ -62,7 +62,10 @@ onMounted(async () => { // unsaved work comes back on every open without an extra prompt. const autoRestore = route.query.recover === 'auto' if (autoRestore && snapshots.value.length > 0) { - for (const snapshot of [...snapshots.value]) { + // restore() reassigns snapshots.value, so iterate the array as it was + // when listed — the original reference is not mutated. + const pending = snapshots.value + for (const snapshot of pending) { await restore(snapshot) } return diff --git a/tests/e2e/fonts/picker.spec.ts b/tests/e2e/fonts/picker.spec.ts index ab5d27678..91f336ea8 100644 --- a/tests/e2e/fonts/picker.spec.ts +++ b/tests/e2e/fonts/picker.spec.ts @@ -35,6 +35,17 @@ async function installGoogleFontsMock(page: Page, families = ['Inter', 'OpenPenc if (typeof input === 'string') url = input else if (input instanceof URL) url = input.href else url = input.url + // css2 font discovery is relayed through the same-origin /__font-proxy in + // the browser fork. Decode the upstream target so the mock stays hermetic; + // deliberately leave the metadata proxy untouched so the fetch counter + // below keeps meaning "the google catalog was loaded from a mock". + if (url.startsWith('/__font-proxy?url=')) { + const target = new URL(url, 'http://local').searchParams.get('url') + if (target) { + const decoded = new URL(decodeURIComponent(target), 'http://local').href + if (decoded.startsWith('https://fonts.googleapis.com/css2')) url = decoded + } + } if (url.startsWith('https://fonts.openpencil.test/')) { win.__googleFontPreviewFetchCount = (win.__googleFontPreviewFetchCount ?? 0) + 1 return new Response(new ArrayBuffer(8), { status: 200 }) diff --git a/vite/font-proxy.ts b/vite/font-proxy.ts index 8ab591931..c07fe6de3 100644 --- a/vite/font-proxy.ts +++ b/vite/font-proxy.ts @@ -1,24 +1,33 @@ import type { Plugin } from 'vite' -// Dev-only same-origin proxy for CORS-hostile online-font catalog endpoints. +// Dev-only same-origin proxy for CORS-hostile online-font endpoints. // // unifont's `google` provider reads `https://fonts.google.com/metadata/fonts` to -// build its family catalog. That endpoint never sends `Access-Control-Allow-Origin` +// build its family catalog and `https://fonts.googleapis.com/css2` to resolve +// font faces. The metadata endpoint never sends `Access-Control-Allow-Origin` // and sets `cross-origin-resource-policy: same-site`, so a browser app served on // its own origin (any origin that is not Google) cannot read it directly — the // provider fails to initialize with "Could not initialize provider `google`". +// The css2 endpoint's OPTIONS preflight carries no CORS headers either, so some +// browsers (notably Firefox) block the font-discovery fetch. // -// The browser relays such requests here instead (see `createFontProxyFetch()` in +// The browser relays such requests here instead (see `createBrowserFontFetch()` in // src/app/editor/fonts), which keeps the request same-origin and forwards it // server-side to Google. This is a W4C fork delta; upstream openpencil does not -// ship it because Tauri/goes through the Rust command that is not CORS-bound. +// ship it because Tauri goes through the Rust command that is not CORS-bound. export const FONT_PROXY_PREFIX = '/__font-proxy' // Only these hosts are CORS-hostile from the browser; everything else // (notably fonts.gstatic.com glyph files) already sends CORS headers and is // fetched directly by the browser. -const PROXIED_HOSTS = new Set(['fonts.google.com']) +// - fonts.google.com — metadata never sends CORS headers. +// - fonts.googleapis.com — css2 font-discovery OPTIONS preflight returns no +// CORS headers, so Firefox blocks the discovery fetch unless it is proxied. +const PROXIED_HOSTS = new Set(['fonts.google.com', 'fonts.googleapis.com']) + +const FONT_PROXY_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36' export function createDevFontProxyPlugin(): Plugin { return { @@ -43,6 +52,9 @@ export function createDevFontProxyPlugin(): Plugin { res.end('missing url') return } + // The browser forwards the original `user-agent` here because it cannot + // send one cross-origin; unifont uses it to pick the glyph format. + const ua = new URL(req.url ?? '/', 'http://dev').searchParams.get('ua') let targetURL: URL try { @@ -61,8 +73,7 @@ export function createDevFontProxyPlugin(): Plugin { try { const upstream = await fetch(targetURL, { headers: { - 'user-agent': - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36', + 'user-agent': ua ?? FONT_PROXY_USER_AGENT, accept: 'application/json, text/plain, */*', }, redirect: 'follow',