enchancements
This commit is contained in:
parent
aa3a30b6ad
commit
e610d4e551
|
|
@ -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 PORT = Number(process.env.OPENPENCIL_WEB_PORT ?? 3100)
|
||||||
const HOST = process.env.OPENPENCIL_WEB_HOST ?? '0.0.0.0'
|
const HOST = process.env.OPENPENCIL_WEB_HOST ?? '0.0.0.0'
|
||||||
|
|
||||||
// W4C fork delta: same-origin proxy for the CORS-hostile online-font catalog
|
// W4C fork delta: same-origin proxy for CORS-hostile online-font endpoints
|
||||||
// (https://fonts.google.com/metadata/fonts). The browser app (which is a W4C
|
// (https://fonts.google.com/metadata/fonts and https://fonts.googleapis.com/css2).
|
||||||
// fork) rewrites those requests to /__font-proxy?url=... so they stay
|
// The browser app (which is a W4C fork) rewrites those requests to
|
||||||
// same-origin; we forward them upstream here. Mirrors vite/font-proxy.ts.
|
// /__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 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 =
|
const FONT_PROXY_USER_AGENT =
|
||||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'
|
||||||
|
|
||||||
async function handleFontProxy(req, res, hostHeader) {
|
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) {
|
if (!target) {
|
||||||
res.writeHead(400)
|
res.writeHead(400)
|
||||||
res.end('missing url')
|
res.end('missing url')
|
||||||
|
|
@ -47,7 +52,7 @@ async function handleFontProxy(req, res, hostHeader) {
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(targetUrl, {
|
const upstream = await fetch(targetUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'user-agent': FONT_PROXY_USER_AGENT,
|
'user-agent': ua ?? FONT_PROXY_USER_AGENT,
|
||||||
accept: 'application/json, text/plain, */*'
|
accept: 'application/json, text/plain, */*'
|
||||||
},
|
},
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
import { renderNodesToImage } from '@open-pencil/core/io/formats/raster'
|
import { renderNodesToImage } from '@open-pencil/core/io/formats/raster'
|
||||||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||||
|
|
||||||
|
import { isCrossOriginSubFrame } from '@/app/document/io/browser'
|
||||||
import type { ExportOptions } from '@/app/document/export/types'
|
import type { ExportOptions } from '@/app/document/export/types'
|
||||||
import { isTauri } from '@/app/tauri/env'
|
import { isTauri } from '@/app/tauri/env'
|
||||||
|
|
||||||
|
|
@ -158,7 +159,7 @@ export async function saveExportedFile(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.showSaveFilePicker) {
|
if (window.showSaveFilePicker && !isCrossOriginSubFrame()) {
|
||||||
try {
|
try {
|
||||||
const handle = await window.showSaveFilePicker({
|
const handle = await window.showSaveFilePicker({
|
||||||
suggestedName: fileName,
|
suggestedName: fileName,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,24 @@ export function createDocumentViewportActions(editor: ViewportEditor, viewportSi
|
||||||
return { setViewportSize, fitCurrentPageToViewport }
|
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) {
|
export function downloadBlob(data: Uint8Array, filename: string, mime: string) {
|
||||||
const blob = new Blob([data.buffer as ArrayBuffer], { type: mime })
|
const blob = new Blob([data.buffer as ArrayBuffer], { type: mime })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { EditorState } from '@open-pencil/core/editor'
|
import type { EditorState } from '@open-pencil/core/editor'
|
||||||
import { dialogMessages } from '@open-pencil/vue'
|
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 { documentNameFromFigPath } from '@/app/document/io/names'
|
||||||
import { chooseBrowserFigSaveHandle, chooseTauriFigSavePath } from '@/app/document/io/save-targets'
|
import { chooseBrowserFigSaveHandle, chooseTauriFigSavePath } from '@/app/document/io/save-targets'
|
||||||
import type { DocumentSourceAccess } from '@/app/document/io/types'
|
import type { DocumentSourceAccess } from '@/app/document/io/types'
|
||||||
|
|
@ -84,7 +84,9 @@ export function createSaveActions({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.showSaveFilePicker) {
|
const crossOrigin = isCrossOriginSubFrame()
|
||||||
|
|
||||||
|
if (!crossOrigin && window.showSaveFilePicker) {
|
||||||
const handle = await chooseBrowserFigSaveHandle()
|
const handle = await chooseBrowserFigSaveHandle()
|
||||||
if (!handle) return
|
if (!handle) return
|
||||||
setStorageBinding(null)
|
setStorageBinding(null)
|
||||||
|
|
@ -96,7 +98,12 @@ export function createSaveActions({
|
||||||
return
|
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
|
if (!filename) return
|
||||||
setStorageBinding(null)
|
setStorageBinding(null)
|
||||||
setDownloadName(filename)
|
setDownloadName(filename)
|
||||||
|
|
|
||||||
|
|
@ -71,17 +71,21 @@ function configureTauriFontCache() {
|
||||||
fontManager.setHostFontLoader(loadSystemFont)
|
fontManager.setHostFontLoader(loadSystemFont)
|
||||||
}
|
}
|
||||||
|
|
||||||
// W4C fork delta: relay CORS-hostile online-font catalog requests through a
|
// W4C fork delta: relay CORS-hostile online-font requests through a same-origin
|
||||||
// same-origin endpoint so the browser app can build the Google Fonts catalog.
|
// endpoint so the browser app can build the Google Fonts catalog and resolve
|
||||||
// unifont's `google` provider reads `https://fonts.google.com/metadata/fonts`,
|
// font faces. Two Google hosts are CORS-hostile from the browser:
|
||||||
// which never sends `Access-Control-Allow-Origin` and sets `cross-origin-resource-policy:
|
// - `https://fonts.google.com/metadata/fonts` — never sends
|
||||||
// same-site`, so it cannot be read from any non-Google origin. The browser here
|
// `Access-Control-Allow-Origin` and sets `cross-origin-resource-policy:
|
||||||
// rewrites those to `/__font-proxy?url=...` (served by a Vite dev plugin and by
|
// same-site`, so it cannot be read from any non-Google origin.
|
||||||
// docker/static-server.mjs), which keeps the request same-origin while the server
|
// - `https://fonts.googleapis.com/css2` — its OPTIONS preflight response
|
||||||
// forwards it upstream. Glyph files (fonts.gstatic.com) already send CORS headers
|
// carries no CORS headers, so some browsers (notably Firefox) block the
|
||||||
// and are fetched directly.
|
// 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 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
|
// Capture the genuine native fetch once, at module load. `withFetchProxy` in
|
||||||
// the core font resolver temporarily swaps `globalThis.fetch` for a wrapper
|
// 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}`)
|
console.warn(`[fonts] invalid URL skipped for font proxy: ${url}`)
|
||||||
}
|
}
|
||||||
if (PROXIED_FONT_HOSTS.has(host)) {
|
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)
|
return nativeFetch(url, init)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useFileDialog } from '@vueuse/core'
|
import { useFileDialog } from '@vueuse/core'
|
||||||
|
|
||||||
import { setOpenPencilOpenFileHandler } from '@/app/browser-bridge'
|
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 { notificationMessages } from '@/app/i18n/notifications'
|
||||||
import { toast } from '@/app/shell/ui'
|
import { toast } from '@/app/shell/ui'
|
||||||
import { openFileInNewTab } from '@/app/tabs'
|
import { openFileInNewTab } from '@/app/tabs'
|
||||||
|
|
@ -80,7 +80,7 @@ export async function openFileDialog() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.showOpenFilePicker) {
|
if (window.showOpenFilePicker && !isCrossOriginSubFrame()) {
|
||||||
try {
|
try {
|
||||||
const handles = await window.showOpenFilePicker({
|
const handles = await window.showOpenFilePicker({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,10 @@ onMounted(async () => {
|
||||||
// unsaved work comes back on every open without an extra prompt.
|
// unsaved work comes back on every open without an extra prompt.
|
||||||
const autoRestore = route.query.recover === 'auto'
|
const autoRestore = route.query.recover === 'auto'
|
||||||
if (autoRestore && snapshots.value.length > 0) {
|
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)
|
await restore(snapshot)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,17 @@ async function installGoogleFontsMock(page: Page, families = ['Inter', 'OpenPenc
|
||||||
if (typeof input === 'string') url = input
|
if (typeof input === 'string') url = input
|
||||||
else if (input instanceof URL) url = input.href
|
else if (input instanceof URL) url = input.href
|
||||||
else url = input.url
|
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/')) {
|
if (url.startsWith('https://fonts.openpencil.test/')) {
|
||||||
win.__googleFontPreviewFetchCount = (win.__googleFontPreviewFetchCount ?? 0) + 1
|
win.__googleFontPreviewFetchCount = (win.__googleFontPreviewFetchCount ?? 0) + 1
|
||||||
return new Response(new ArrayBuffer(8), { status: 200 })
|
return new Response(new ArrayBuffer(8), { status: 200 })
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,33 @@
|
||||||
import type { Plugin } from 'vite'
|
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
|
// 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
|
// 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
|
// its own origin (any origin that is not Google) cannot read it directly — the
|
||||||
// provider fails to initialize with "Could not initialize provider `google`".
|
// 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
|
// 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
|
// 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'
|
export const FONT_PROXY_PREFIX = '/__font-proxy'
|
||||||
|
|
||||||
// Only these hosts are CORS-hostile from the browser; everything else
|
// Only these hosts are CORS-hostile from the browser; everything else
|
||||||
// (notably fonts.gstatic.com glyph files) already sends CORS headers and is
|
// (notably fonts.gstatic.com glyph files) already sends CORS headers and is
|
||||||
// fetched directly by the browser.
|
// 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 {
|
export function createDevFontProxyPlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
|
|
@ -43,6 +52,9 @@ export function createDevFontProxyPlugin(): Plugin {
|
||||||
res.end('missing url')
|
res.end('missing url')
|
||||||
return
|
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
|
let targetURL: URL
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,8 +73,7 @@ export function createDevFontProxyPlugin(): Plugin {
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(targetURL, {
|
const upstream = await fetch(targetURL, {
|
||||||
headers: {
|
headers: {
|
||||||
'user-agent':
|
'user-agent': ua ?? FONT_PROXY_USER_AGENT,
|
||||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36',
|
|
||||||
accept: 'application/json, text/plain, */*',
|
accept: 'application/json, text/plain, */*',
|
||||||
},
|
},
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue