enchancements

This commit is contained in:
Vitali sharp8n 2026-09-05 11:25:33 +03:00
parent aa3a30b6ad
commit e610d4e551
9 changed files with 105 additions and 32 deletions

View file

@ -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'

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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,

View file

@ -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

View file

@ -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 })

View file

@ -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',