diff --git a/CHANGELOG.md b/CHANGELOG.md index 84806724e..35cd4e6bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ ### Fixed +- Load and browse online font provider catalogs in the browser app, downloading provider fonts over CORS-enabled fetches instead of redirecting users to the desktop app. - Preserve app-created component properties and instance-swap targets across `.fig` save and reload cycles. (#548) - Reconnect desktop automation to an already-running MCP server by allowing access to its discovery file. (#546) - Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text. (#539) diff --git a/Dockerfile.web-vue b/Dockerfile.web-vue new file mode 100644 index 000000000..2e327c090 --- /dev/null +++ b/Dockerfile.web-vue @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1 +# +# Dockerfile.web-vue — container for the VUE editor (master, v0.14.0) + MCP. +# +# Replaces the retired Dockerfile.web-rust (Rust web host, main branch). This +# image serves the static SPA build (the same `dist/` artifact that Cloudflare +# Pages deploys for app.openpencil.dev) on :3100 AND the @open-pencil/mcp +# Streamable-HTTP server on :7600, both from one container via the entrypoint. +# w4c-chatapi talks MCP to http://openpencil:7600/mcp (OpenPencilMcp__HttpEndpoint). +# +# Build: +# docker build -f Dockerfile.web-vue -t w4c-openpencil:latest . +# +# Run: +# docker run -p 3100:3100 -p 7600:7600 \ +# -e OPENPENCIL_MCP_AUTH_TOKEN=... \ +# -e OPENPENCIL_MCP_CORS_ORIGIN=https://openpencil.wiz4chat.com \ +# -v openpencil_data:/data \ +# w4c-openpencil:latest + +# ── Stage 1: builder — bun, install + build the SPA and the MCP packages ───── +FROM oven/bun:1 AS builder + +WORKDIR /app + +# Lockfile + manifests first for layer caching. +COPY package.json bun.lock bunfig.toml ./ +COPY packages/scene-graph/package.json packages/scene-graph/ +COPY packages/pen/package.json packages/pen/ +COPY packages/kiwi/package.json packages/kiwi/ +COPY packages/fig/package.json packages/fig/ +COPY packages/core/package.json packages/core/ +COPY packages/dom-css/package.json packages/dom-css/ +COPY packages/vue/package.json packages/vue/ +COPY packages/cli/package.json packages/cli/ +COPY packages/mcp/package.json packages/mcp/ +COPY packages/harness/package.json packages/harness/ +COPY packages/docs/package.json packages/docs/ +COPY tools/docs/package.json tools/docs/ + +RUN bun install --frozen-lockfile + +# Full source (excludes heavy local build outputs via .dockerignore). +COPY . . + +# Same pipeline as the Cloudflare Pages deploy (app.yml): build:packages → +# lint → vite build. Lint runs on the vendored fork (includes our theme patch). +RUN bun run build + +# Prune to production dependencies so the runtime layer is lean. bun keeps +# workspace symlinks; the MCP server's deps (hono, @modelcontextprotocol/sdk, +# ws, zod + @open-pencil/core) are all in the prod graph. +RUN bun install --production --frozen-lockfile + +# ── Stage 2: runtime — static SPA (:3100) + MCP (:7600) ───────────────────── +FROM oven/bun:1-slim AS runtime + +WORKDIR /app + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/bun.lock ./bun.lock +COPY --from=builder /app/bunfig.toml ./bunfig.toml +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages ./packages +COPY --from=builder /app/docker/static-server.mjs ./static-server.mjs +COPY --from=builder /app/docker/entrypoint.sh /entrypoint.sh + +# Writable document root for MCP file-scoped tools (open_file / new_document / +# save_file). Keep on a volume so documents survive container restarts. +ENV OPENPENCIL_MCP_ROOT=/data + +EXPOSE 3100 7600 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Dockerfile.web-vue.dockerignore b/Dockerfile.web-vue.dockerignore new file mode 100644 index 000000000..d02dfdef6 --- /dev/null +++ b/Dockerfile.web-vue.dockerignore @@ -0,0 +1,27 @@ +# Per-Dockerfile ignore (BuildKit picks up `.dockerignore` in +# preference to the root `.dockerignore` for THIS Dockerfile only). +# +# Keep the build context small: the builder stage runs a clean `bun install` +# and `bun run build` inside the image, so none of the host's build outputs +# are needed. + +.git +.github +.vscode + +# JS build outputs — rebuilt inside the image, never copied in. +node_modules +dist +dist-ssr +*.tsbuildinfo +coverage + +# Desktop + test artifacts. +desktop/target +tests/results +playwright-report + +# Local scratch / docs. +.openpencil-tmp +screenshot +*.md diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..2101cd8eb --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Entrypoint for the OpenPencil Vue container: runs the static SPA server on +# :3100 and the @open-pencil/mcp Streamable-HTTP server on :7600 in the same +# container. Forwards SIGTERM/SIGINT to both so docker stop is clean. +# POSIX sh only (no `wait -n`) — the runtime image is Alpine/dash. + +set -eu + +WEB_PORT="${OPENPENCIL_WEB_PORT:-3100}" +MCP_PORT="${OPENPENCIL_MCP_PORT:-7600}" + +# The MCP server creates a Unix socket + discovery file by default. In the +# container we direct them under /data (writable volume) so the socket never +# collides with /run/user/... and documents land on the persisted volume. +export OPENPENCIL_MCP_ROOT="${OPENPENCIL_MCP_ROOT:-/data}" +export OPENPENCIL_MCP_SOCKET="${OPENPENCIL_MCP_SOCKET:-/data/mcp.sock}" +export OPENPENCIL_MCP_DISCOVERY_PATH="${OPENPENCIL_MCP_DISCOVERY_PATH:-/data/mcp.json}" +export OPENPENCIL_MCP_CORS_ORIGIN="${OPENPENCIL_MCP_CORS_ORIGIN:-}" +# Bind MCP on all interfaces so sibling containers (chatapi) can reach it by +# service name (openpencil:7600), not just via localhost port-forwarding. +export OPENPENCIL_MCP_HOST="${OPENPENCIL_MCP_HOST:-0.0.0.0}" + +mkdir -p "$OPENPENCIL_MCP_ROOT" "$(dirname "$OPENPENCIL_MCP_SOCKET")" "$(dirname "$OPENPENCIL_MCP_DISCOVERY_PATH")" + +echo "[openpencil] starting static SPA on :${WEB_PORT} and MCP on :${MCP_PORT}" + +# Static SPA. +OPENPENCIL_WEB_PORT="$WEB_PORT" bun static-server.mjs & +WEB_PID=$! + +# MCP server (Streamable HTTP at /mcp). Bun runs the node-targeted bin. +PORT="$MCP_PORT" bun packages/mcp/bin/openpencil-mcp-http.js & +MCP_PID=$! + +shutdown() { + echo "[openpencil] shutting down (MCP=$MCP_PID, web=$WEB_PID) ..." + kill -TERM "$MCP_PID" "$WEB_PID" 2>/dev/null || true + exit 0 +} +trap shutdown TERM INT + +# Wait for both processes; stop the container when either exits. +STATUS=0 +while :; do + WEB_ALIVE=0; MCP_ALIVE=0 + kill -0 "$WEB_PID" 2>/dev/null && WEB_ALIVE=1 + kill -0 "$MCP_PID" 2>/dev/null && MCP_ALIVE=1 + if [ "$WEB_ALIVE" = "0" ] || [ "$MCP_ALIVE" = "0" ]; then + STATUS=1 + break + fi + # Poll every second; a SIGTERM/SIGINT wakes the loop via the trap's exit. + sleep 1 +done + +echo "[openpencil] a child process exited; stopping the container" +kill -TERM "$MCP_PID" "$WEB_PID" 2>/dev/null || true +wait "$MCP_PID" 2>/dev/null || true +wait "$WEB_PID" 2>/dev/null || true +exit "$STATUS" diff --git a/docker/static-server.mjs b/docker/static-server.mjs new file mode 100644 index 000000000..9e44a96b1 --- /dev/null +++ b/docker/static-server.mjs @@ -0,0 +1,135 @@ +// Minimal static SPA server for the OpenPencil Vue editor (dist/). +// +// Serves the built SPA from OPENPENCIL_WEB_ROOT (default ./dist) on +// OPENPENCIL_WEB_PORT (default 3100). Implements the SPA fallback that +// Cloudflare Pages provides via dist/_redirects (`/* /index.html 200`), plus +// correct MIME types — notably application/wasm for CanvasKit. +// +// Runs under bun in the container: `bun static-server.mjs`. + +import { createServer } from 'node:http' +import { readFile, stat } from 'node:fs/promises' +import { extname, join, normalize } from 'node:path' + +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. +const FONT_PROXY_PREFIX = '/__font-proxy' +const PROXIED_FONT_HOSTS = ['fonts.google.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') + if (!target) { + res.writeHead(400) + res.end('missing url') + return + } + let targetUrl + try { + targetUrl = new URL(decodeURIComponent(target)) + } catch { + res.writeHead(400) + res.end('invalid url') + return + } + if (targetUrl.protocol !== 'https:' || !PROXIED_FONT_HOSTS.includes(targetUrl.host)) { + res.writeHead(403) + res.end('proxied host denied') + return + } + try { + const upstream = await fetch(targetUrl, { + headers: { + 'user-agent': FONT_PROXY_USER_AGENT, + accept: 'application/json, text/plain, */*' + }, + redirect: 'follow' + }) + const body = Buffer.from(await upstream.arrayBuffer()) + const type = upstream.headers.get('content-type') + res.writeHead(upstream.status, { + 'content-type': type ?? 'application/octet-stream', + 'cache-control': upstream.status === 200 ? 'public, max-age=86400' : 'no-store', + 'access-control-allow-origin': '*' + }) + res.end(body) + } catch (err) { + res.writeHead(502) + res.end(String(err)) + } +} + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json' +} + +const server = createServer(async (req, res) => { + try { + let pathname + try { + pathname = decodeURIComponent(new URL(req.url ?? '/', `http://${req.headers.host}`).pathname) + } catch { + pathname = '/' + } + if (pathname === FONT_PROXY_PREFIX) { + await handleFontProxy(req, res, req.headers.host) + return + } + if (pathname === '/') pathname = '/index.html' + + // Prevent path traversal outside ROOT. + const filePath = normalize(join(ROOT, pathname)) + if (!filePath.startsWith(ROOT)) { + res.writeHead(403) + res.end('Forbidden') + return + } + + let fullPath = filePath + try { + const s = await stat(fullPath) + if (s.isDirectory()) fullPath = join(fullPath, 'index.html') + } catch { + // 404 → SPA fallback to index.html (vite preview behaviour). + fullPath = join(ROOT, 'index.html') + } + + const body = await readFile(fullPath) + res.writeHead(200, { + 'content-type': MIME[extname(fullPath)] ?? 'application/octet-stream', + 'cache-control': extname(fullPath) === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' + }) + res.end(body) + } catch (err) { + res.writeHead(500) + res.end(`Internal Server Error: ${String(err)}`) + } +}) + +server.listen(PORT, HOST, () => { + process.stderr.write(`Static server: http://${HOST}:${PORT} (root ${ROOT})\n`) +}) diff --git a/packages/core/src/text/web-fonts.ts b/packages/core/src/text/web-fonts.ts index 1f243a284..2ce8b4008 100644 --- a/packages/core/src/text/web-fonts.ts +++ b/packages/core/src/text/web-fonts.ts @@ -1,6 +1,5 @@ import type { FontFaceData, RemoteFontSource, ResolveFontResult } from 'unifont' -import { IS_BROWSER } from '#core/constants' import { parseFontStyle } from '#core/text/face' import { createProviderUnifont, @@ -124,7 +123,6 @@ export class WebFontResolver { } preloadFamilies(): void { - if (IS_BROWSER && !this.remoteFetch) return for (const provider of this.enabledProviders()) void this.listFamilies(provider) } @@ -146,7 +144,7 @@ export class WebFontResolver { characters = '' ): Promise { const providers = this.enabledProviders() - if (providers.length === 0 || (IS_BROWSER && !this.remoteFetch)) return null + if (providers.length === 0) return null for (const family of families) { for (const provider of providers) { @@ -202,7 +200,7 @@ export class WebFontResolver { } private async loadFamilies(provider: WebFontProviderId): Promise { - if (typeof fetch === 'undefined' || (IS_BROWSER && !this.remoteFetch)) return [] + if (typeof fetch === 'undefined') return [] try { const unifont = await this.unifont(provider) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index df9527176..8ac66e4d1 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -16,6 +16,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) { ` platform path. Parent dir created 0o700. Mainly for test isolation.\n` + ` OPENPENCIL_MCP_TCP Deprecated — TCP is controlled by PORT (>0 = on, 0 = off)\n` + ` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for MCP and RPC auth\n` + + ` OPENPENCIL_MCP_HOST TCP bind host (default: 127.0.0.1; set 0.0.0.0 in containers)\n` + ` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: current working directory)\n` + ` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` + ` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origin\n` + @@ -69,6 +70,7 @@ const handle = await startServer({ httpPort: withTcp ? port : 0, withTcp, socketPath: process.env.OPENPENCIL_MCP_SOCKET?.trim() || null, + tcpHost: process.env.OPENPENCIL_MCP_HOST?.trim() || '127.0.0.1', enableEval: process.env.OPENPENCIL_MCP_EVAL === '1', mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(), // Auth token: undefined → auto-generate, empty string → disable auth, diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index bb9752a5e..010360b57 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -65,6 +65,8 @@ export interface ServerOptions { socketPath?: string | null /** Whether to also listen on TCP (in addition to the socket). API default is `false`; the CLI passes `true` by default (derived from PORT, default 7600). */ withTcp?: boolean + /** Host to bind the TCP listener to. Defaults to 127.0.0.1 (loopback only). Set to 0.0.0.0 in containers so other services can reach the MCP endpoint by name. */ + tcpHost?: string enableEval?: boolean mcpRoot?: string | null /** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */ @@ -413,7 +415,9 @@ export async function startServer(options: ServerOptions = {}): Promise { - const host = '127.0.0.1' + const host = tcpHost const server = createAppServer(app) wireUpgrade(server, wss) @@ -350,10 +351,11 @@ export async function tryStartTcp( app: Hono, wss: WebSocketServer, httpPort: number, - state: ListenerState + state: ListenerState, + tcpHost: string ): Promise<{ server: HttpServer; port: number } | null> { try { - return await startTcpListener(app, wss, httpPort) + return await startTcpListener(app, wss, httpPort, tcpHost) } catch (err) { // If TCP listener fails after the socket listener succeeded, close the // socket listener and clean up the socket file to avoid a leak. diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 459c84326..6373f082b 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -235,7 +235,7 @@ "requesting": "Wird angefragt…", "onlineFontProviders": "Online-Schriftanbieter", "downloadMissingWebFonts": "Fehlende Web-Schriften über aktivierte Anbieter herunterladen.", - "webFontProvidersRequireDesktopApp": "Online-Schriftanbieter-Kataloge sind in der Web-App nicht verfügbar. Lade die Desktop-App herunter, um Anbieter-Schriften zu durchsuchen und zu laden.", + "webFontLoadFailed": "Fehler beim Laden einer Schrift von einem Online-Anbieter. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "clipboardImageUnavailableWeb": "Das eingefügte Design enthält 1 Bild, das in der Web-App nicht geladen werden kann. Verwende die Desktop-App, um es einzuschließen.", "clipboardImagesUnavailableWeb": "Das eingefügte Design enthält {count} Bilder, die in der Web-App nicht geladen werden können. Verwende die Desktop-App, um sie einzuschließen.", "clipboardImageFetchFailed": "1 Bild konnte nicht aus Figma abgerufen werden. Prüfe, ob die Quelldatei zugänglich ist, und versuche es erneut.", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index 026012e94..bbf0ef0d7 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -235,7 +235,7 @@ "requesting": "Solicitando…", "onlineFontProviders": "Proveedores de fuentes en línea", "downloadMissingWebFonts": "Descarga fuentes web faltantes mediante los proveedores activados.", - "webFontProvidersRequireDesktopApp": "Los catálogos de proveedores de fuentes en línea no están disponibles en la app web. Descarga la app de escritorio para explorar y cargar fuentes de proveedores.", + "webFontLoadFailed": "No se pudo cargar una fuente de un proveedor en línea. Comprueba tu conexión e inténtalo de nuevo.", "clipboardImageUnavailableWeb": "El diseño pegado contiene 1 imagen que no se puede cargar en la aplicación web. Usa la aplicación de escritorio para incluirla.", "clipboardImagesUnavailableWeb": "El diseño pegado contiene {count} imágenes que no se pueden cargar en la aplicación web. Usa la aplicación de escritorio para incluirlas.", "clipboardImageFetchFailed": "No se ha podido obtener 1 imagen de Figma. Comprueba que el archivo de origen sea accesible e inténtalo de nuevo.", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 9d0693ab6..0dc32f071 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -235,7 +235,7 @@ "requesting": "Demande…", "onlineFontProviders": "Fournisseurs de polices en ligne", "downloadMissingWebFonts": "Télécharger les polices web manquantes via les fournisseurs activés.", - "webFontProvidersRequireDesktopApp": "Les catalogues de fournisseurs de polices en ligne ne sont pas disponibles dans l’app web. Téléchargez l’app de bureau pour parcourir et charger les polices des fournisseurs.", + "webFontLoadFailed": "Échec du chargement d'une police auprès d'un fournisseur en ligne. Vérifiez votre connexion et réessayez.", "clipboardImageUnavailableWeb": "Le design collé contient 1 image qui ne peut pas être chargée dans l’application web. Utilisez l’application de bureau pour l’inclure.", "clipboardImagesUnavailableWeb": "Le design collé contient {count} images qui ne peuvent pas être chargées dans l’application web. Utilisez l’application de bureau pour les inclure.", "clipboardImageFetchFailed": "Impossible de récupérer 1 image depuis Figma. Vérifiez que le fichier source est accessible, puis réessayez.", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 772d658ec..e09ebddf6 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -235,7 +235,7 @@ "requesting": "Richiesta…", "onlineFontProviders": "Provider di font online", "downloadMissingWebFonts": "Scarica i font web mancanti tramite i provider abilitati.", - "webFontProvidersRequireDesktopApp": "I cataloghi dei provider di font online non sono disponibili nell’app web. Scarica l’app desktop per sfogliare e caricare i font dei provider.", + "webFontLoadFailed": "Impossibile caricare un font da un provider online. Controlla la connessione e riprova.", "clipboardImageUnavailableWeb": "Il design incollato contiene 1 immagine che non può essere caricata nell’app web. Usa l’app desktop per includerla.", "clipboardImagesUnavailableWeb": "Il design incollato contiene {count} immagini che non possono essere caricate nell’app web. Usa l’app desktop per includerle.", "clipboardImageFetchFailed": "Impossibile recuperare 1 immagine da Figma. Controlla che il file sorgente sia accessibile e riprova.", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index 1a9713609..7040b8d76 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -235,7 +235,7 @@ "requesting": "要求中…", "onlineFontProviders": "オンラインフォントプロバイダー", "downloadMissingWebFonts": "有効なプロバイダーから不足しているWebフォントをダウンロードします。", - "webFontProvidersRequireDesktopApp": "オンラインフォントプロバイダーのカタログはWebアプリでは利用できません。プロバイダーのフォントを参照して読み込むにはデスクトップアプリをダウンロードしてください。", + "webFontLoadFailed": "オンラインプロバイダーからフォントを読み込めませんでした。接続を確認してもう一度お試しください。", "clipboardImageUnavailableWeb": "貼り付けたデザインには、Webアプリで読み込めない画像が1件含まれています。その画像を含めるにはデスクトップアプリを使用してください。", "clipboardImagesUnavailableWeb": "貼り付けたデザインには、Webアプリで読み込めない画像が{count}件含まれています。それらの画像を含めるにはデスクトップアプリを使用してください。", "clipboardImageFetchFailed": "Figmaから画像1件を取得できませんでした。元のファイルにアクセスできることを確認して、もう一度お試しください。", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index d3a84cb7e..189685034 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -235,7 +235,7 @@ "requesting": "Żądanie…", "onlineFontProviders": "Dostawcy czcionek online", "downloadMissingWebFonts": "Pobieraj brakujące czcionki webowe przez włączonych dostawców.", - "webFontProvidersRequireDesktopApp": "Katalogi dostawców czcionek online nie są dostępne w aplikacji webowej. Pobierz aplikację desktopową, aby przeglądać i ładować czcionki dostawców.", + "webFontLoadFailed": "Nie udało się załadować czcionki od dostawcy online. Sprawdź połączenie i spróbuj ponownie.", "clipboardImageUnavailableWeb": "Wklejony projekt zawiera 1 obraz, którego nie można wczytać w aplikacji internetowej. Użyj aplikacji komputerowej, aby go dołączyć.", "clipboardImagesUnavailableWeb": "Wklejony projekt zawiera {count} obrazy, których nie można wczytać w aplikacji internetowej. Użyj aplikacji komputerowej, aby je dołączyć.", "clipboardImageFetchFailed": "Nie udało się pobrać 1 obrazu z Figmy. Sprawdź, czy plik źródłowy jest dostępny, i spróbuj ponownie.", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index 6efadaf1c..84082be0a 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -235,7 +235,7 @@ "requesting": "Запрос…", "onlineFontProviders": "Онлайн-провайдеры шрифтов", "downloadMissingWebFonts": "Загружайте отсутствующие веб-шрифты через включённых провайдеров.", - "webFontProvidersRequireDesktopApp": "Каталоги провайдеров онлайн-шрифтов недоступны в веб-приложении. Скачайте настольное приложение, чтобы просматривать и загружать шрифты провайдеров.", + "webFontLoadFailed": "Не удалось загрузить шрифт с онлайн-провайдера. Проверьте подключение к интернету.", "clipboardImageUnavailableWeb": "Вставленный дизайн содержит 1 изображение, которое нельзя загрузить в веб-приложении. Используйте настольное приложение, чтобы включить его.", "clipboardImagesUnavailableWeb": "Вставленный дизайн содержит {count} изображений, которые нельзя загрузить в веб-приложении. Используйте настольное приложение, чтобы включить их.", "clipboardImageFetchFailed": "Не удалось получить 1 изображение из Figma. Проверьте доступность исходного файла и повторите попытку.", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index bb8b36e3c..e00739c12 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -235,7 +235,7 @@ "requesting": "正在请求…", "onlineFontProviders": "在线字体提供商", "downloadMissingWebFonts": "通过已启用的提供商下载缺失的网页字体。", - "webFontProvidersRequireDesktopApp": "网页版暂不支持在线字体提供商目录。请下载桌面应用来浏览和加载提供商字体。", + "webFontLoadFailed": "无法从在线提供商加载字体。请检查网络连接后重试。", "clipboardImageUnavailableWeb": "粘贴的设计包含 1 张无法在网页应用中加载的图片。请使用桌面应用以包含该图片。", "clipboardImagesUnavailableWeb": "粘贴的设计包含 {count} 张无法在网页应用中加载的图片。请使用桌面应用以包含这些图片。", "clipboardImageFetchFailed": "无法从 Figma 获取 1 张图片。请检查源文件是否可访问,然后重试。", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 2af22dda3..7cd9e6a9f 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -94,8 +94,8 @@ export const dialogMessageDefaults = { requesting: 'Requesting…', onlineFontProviders: 'Online font providers', downloadMissingWebFonts: 'Download missing web fonts through enabled providers.', - webFontProvidersRequireDesktopApp: - 'Online font provider catalogs are unavailable in the web app. Download the desktop app to browse and load provider fonts.', + webFontLoadFailed: + 'Failed to load a font from an online provider. Check your connection and try again.', clipboardImageUnavailableWeb: 'Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.', clipboardImagesUnavailableWeb: params( diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index 1fc3371b9..175ad1c5c 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -60,7 +60,7 @@ function showWebFontUnavailableToast(): void { if (webFontUnavailableToastShown || isTauri() || !onlineFontsEnabled.value) return if (!WEB_FONT_PROVIDER_IDS.some((provider) => fontProviderSettings.value[provider])) return webFontUnavailableToastShown = true - toast.warning(dialogMessages.get().webFontProvidersRequireDesktopApp) + toast.warning(dialogMessages.get().webFontLoadFailed) } function configureTauriFontCache() { @@ -71,7 +71,48 @@ 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. +const FONT_PROXY_PREFIX = '/__font-proxy' +const PROXIED_FONT_HOSTS = new Set(['fonts.google.com']) + +// Capture the genuine native fetch once, at module load. `withFetchProxy` in +// the core font resolver temporarily swaps `globalThis.fetch` for a wrapper +// that routes every http(s) request through this fetcher, so calling the +// global `fetch` here would recurse into ourselves (stack overflow). The +// captured reference bypasses that wrapper. +const nativeFetch = globalThis.fetch + +function createBrowserFontFetch(): (url: string, init?: RequestInit) => Promise { + return (url, init) => { + let host = '' + try { + host = new URL(url).host + } catch { + // Leave invalid URLs to native fetch. + 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) + } + return nativeFetch(url, init) + } +} + +function configureBrowserFontProxy() { + if (isTauri()) return + fontManager.setWebFontFetch(createBrowserFontFetch()) +} + configureTauriFontCache() +configureBrowserFontProxy() interface TauriFontFamily { family: string @@ -150,7 +191,6 @@ export async function listFamilies(): Promise { byFamily.set(font.family, { family: font.family, source: 'local' }) return [...byFamily.values()].sort((a, b) => a.family.localeCompare(b.family)) } - showWebFontUnavailableToast() return fontManager.listFamilyOptions() } diff --git a/src/app/shell/theme.ts b/src/app/shell/theme.ts index 1cfff72e1..bcb6adf9b 100644 --- a/src/app/shell/theme.ts +++ b/src/app/shell/theme.ts @@ -13,6 +13,23 @@ const THEME_STORAGE_KEY = 'open-pencil:theme' const DEFAULT_THEME: AppTheme = 'dark' const theme = useLocalStorage(THEME_STORAGE_KEY, DEFAULT_THEME) + +// ── W4C fork delta ───────────────────────────────────────────────────────── +// The w4c-quasar /openpencil page embeds this editor as an iframe and opens it +// with `?theme=dark|light` (w4c-quasar/src/config/openpencil.ts). Upstream the +// Vue editor persists its theme only in localStorage, so before the app boots +// we bridge the URL query parameter into the storage-backed theme ref +// (useLocalStorage writes through to localStorage, no direct storage access). +// This is our fork change only — it will drift/conflict on an upstream update; +// re-apply it when rebasing onto a new openpencil master. +if (IS_BROWSER) { + const themeParam = new URLSearchParams(window.location.search).get('theme') + if (themeParam === 'dark' || themeParam === 'light') { + theme.value = themeParam + } +} +// ── /W4C fork delta ──────────────────────────────────────────────────────── + const prefersDark = usePreferredDark() export const resolvedAppTheme = computed<'dark' | 'light'>(() => { if (theme.value === 'auto') return prefersDark.value ? 'dark' : 'light' diff --git a/vite.config.ts b/vite.config.ts index 67f965485..9d37083c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ import packageJson from './package.json' import { createOpenPencilAliases } from './vite/aliases' import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation' import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets' +import { createDevFontProxyPlugin } from './vite/font-proxy' import { openPencilPwaPlugin } from './vite/pwa' import { rawMarkdownPlugin } from './vite/raw-markdown' import { createDevServerOptions } from './vite/server' @@ -33,6 +34,7 @@ export default defineConfig(async ({ command }) => ({ Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }), openPencilAutomationPlugin(command, host), vue(), + createDevFontProxyPlugin(), openPencilPwaPlugin() ], clearScreen: false, diff --git a/vite/font-proxy.ts b/vite/font-proxy.ts new file mode 100644 index 000000000..8ab591931 --- /dev/null +++ b/vite/font-proxy.ts @@ -0,0 +1,83 @@ +import type { Plugin } from 'vite' + +// Dev-only same-origin proxy for CORS-hostile online-font catalog 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` +// 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 browser relays such requests here instead (see `createFontProxyFetch()` 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. + +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']) + +export function createDevFontProxyPlugin(): Plugin { + return { + name: 'open-pencil-dev-font-proxy', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + let pathname: string + try { + pathname = new URL(req.url ?? '/', 'http://dev').pathname + } catch { + next() + return + } + if (pathname !== FONT_PROXY_PREFIX) { + next() + return + } + + const target = new URL(req.url ?? '/', 'http://dev').searchParams.get('url') + if (!target) { + res.statusCode = 400 + res.end('missing url') + return + } + + let targetURL: URL + try { + targetURL = new URL(decodeURIComponent(target)) + } catch { + res.statusCode = 400 + res.end('invalid url') + return + } + if (targetURL.protocol !== 'https:' || !PROXIED_HOSTS.has(targetURL.host)) { + res.statusCode = 403 + res.end('proxied host denied') + return + } + + 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', + accept: 'application/json, text/plain, */*', + }, + redirect: 'follow', + }) + const body = Buffer.from(await upstream.arrayBuffer()) + res.statusCode = upstream.status + const type = upstream.headers.get('content-type') + if (type) res.setHeader('content-type', type) + res.setHeader('access-control-allow-origin', '*') + res.end(body) + } catch (e) { + res.statusCode = 502 + res.end(String(e)) + } + }) + }, + } +}