Merge commit 'b9b04fb28d2b158c215f082b3eb0285a5d01258f'

This commit is contained in:
Vitali sharp8n 2026-09-03 11:02:17 +03:00
commit 34d25d30c3
22 changed files with 467 additions and 21 deletions

View file

@ -62,6 +62,7 @@
### Fixed ### 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) - 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) - 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) - Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text. (#539)

75
Dockerfile.web-vue Normal file
View file

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

View file

@ -0,0 +1,27 @@
# Per-Dockerfile ignore (BuildKit picks up `<Dockerfile>.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

60
docker/entrypoint.sh Executable file
View file

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

135
docker/static-server.mjs Normal file
View file

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

View file

@ -1,6 +1,5 @@
import type { FontFaceData, RemoteFontSource, ResolveFontResult } from 'unifont' import type { FontFaceData, RemoteFontSource, ResolveFontResult } from 'unifont'
import { IS_BROWSER } from '#core/constants'
import { parseFontStyle } from '#core/text/face' import { parseFontStyle } from '#core/text/face'
import { import {
createProviderUnifont, createProviderUnifont,
@ -124,7 +123,6 @@ export class WebFontResolver {
} }
preloadFamilies(): void { preloadFamilies(): void {
if (IS_BROWSER && !this.remoteFetch) return
for (const provider of this.enabledProviders()) void this.listFamilies(provider) for (const provider of this.enabledProviders()) void this.listFamilies(provider)
} }
@ -146,7 +144,7 @@ export class WebFontResolver {
characters = '' characters = ''
): Promise<ResolvedWebFont | null> { ): Promise<ResolvedWebFont | null> {
const providers = this.enabledProviders() 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 family of families) {
for (const provider of providers) { for (const provider of providers) {
@ -202,7 +200,7 @@ export class WebFontResolver {
} }
private async loadFamilies(provider: WebFontProviderId): Promise<string[]> { private async loadFamilies(provider: WebFontProviderId): Promise<string[]> {
if (typeof fetch === 'undefined' || (IS_BROWSER && !this.remoteFetch)) return [] if (typeof fetch === 'undefined') return []
try { try {
const unifont = await this.unifont(provider) const unifont = await this.unifont(provider)

View file

@ -16,6 +16,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
` platform path. Parent dir created 0o700. Mainly for test isolation.\n` + ` 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_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_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_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_EVAL Set to 1 to enable the eval tool\n` +
` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origin\n` + ` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origin\n` +
@ -69,6 +70,7 @@ const handle = await startServer({
httpPort: withTcp ? port : 0, httpPort: withTcp ? port : 0,
withTcp, withTcp,
socketPath: process.env.OPENPENCIL_MCP_SOCKET?.trim() || null, 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', enableEval: process.env.OPENPENCIL_MCP_EVAL === '1',
mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(), mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(),
// Auth token: undefined → auto-generate, empty string → disable auth, // Auth token: undefined → auto-generate, empty string → disable auth,

View file

@ -65,6 +65,8 @@ export interface ServerOptions {
socketPath?: string | null 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). */ /** 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 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 enableEval?: boolean
mcpRoot?: string | null mcpRoot?: string | null
/** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */ /** 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<ServerHa
let startedAt = '' let startedAt = ''
try { try {
state.socketResult = await startSocketListener(ctx.app, ctx.wss, options.socketPath ?? null) state.socketResult = await startSocketListener(ctx.app, ctx.wss, options.socketPath ?? null)
state.tcpResult = ctx.withTcp ? await tryStartTcp(ctx.app, ctx.wss, ctx.httpPort, state) : null state.tcpResult = ctx.withTcp
? await tryStartTcp(ctx.app, ctx.wss, ctx.httpPort, state, options.tcpHost ?? '127.0.0.1')
: null
const resolvedSocketPath = state.socketResult?.resolvedPath ?? null const resolvedSocketPath = state.socketResult?.resolvedPath ?? null
const actualHttpPort = state.tcpResult?.port ?? 0 const actualHttpPort = state.tcpResult?.port ?? 0

View file

@ -135,9 +135,10 @@ export async function startSocketListener(
export async function startTcpListener( export async function startTcpListener(
app: Hono, app: Hono,
wss: WebSocketServer, wss: WebSocketServer,
httpPort: number httpPort: number,
tcpHost = '127.0.0.1'
): Promise<{ server: HttpServer; port: number } | null> { ): Promise<{ server: HttpServer; port: number } | null> {
const host = '127.0.0.1' const host = tcpHost
const server = createAppServer(app) const server = createAppServer(app)
wireUpgrade(server, wss) wireUpgrade(server, wss)
@ -350,10 +351,11 @@ export async function tryStartTcp(
app: Hono, app: Hono,
wss: WebSocketServer, wss: WebSocketServer,
httpPort: number, httpPort: number,
state: ListenerState state: ListenerState,
tcpHost: string
): Promise<{ server: HttpServer; port: number } | null> { ): Promise<{ server: HttpServer; port: number } | null> {
try { try {
return await startTcpListener(app, wss, httpPort) return await startTcpListener(app, wss, httpPort, tcpHost)
} catch (err) { } catch (err) {
// If TCP listener fails after the socket listener succeeded, close the // If TCP listener fails after the socket listener succeeded, close the
// socket listener and clean up the socket file to avoid a leak. // socket listener and clean up the socket file to avoid a leak.

View file

@ -235,7 +235,7 @@
"requesting": "Wird angefragt…", "requesting": "Wird angefragt…",
"onlineFontProviders": "Online-Schriftanbieter", "onlineFontProviders": "Online-Schriftanbieter",
"downloadMissingWebFonts": "Fehlende Web-Schriften über aktivierte Anbieter herunterladen.", "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.", "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.", "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.", "clipboardImageFetchFailed": "1 Bild konnte nicht aus Figma abgerufen werden. Prüfe, ob die Quelldatei zugänglich ist, und versuche es erneut.",

View file

@ -235,7 +235,7 @@
"requesting": "Solicitando…", "requesting": "Solicitando…",
"onlineFontProviders": "Proveedores de fuentes en línea", "onlineFontProviders": "Proveedores de fuentes en línea",
"downloadMissingWebFonts": "Descarga fuentes web faltantes mediante los proveedores activados.", "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.", "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.", "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.", "clipboardImageFetchFailed": "No se ha podido obtener 1 imagen de Figma. Comprueba que el archivo de origen sea accesible e inténtalo de nuevo.",

View file

@ -235,7 +235,7 @@
"requesting": "Demande…", "requesting": "Demande…",
"onlineFontProviders": "Fournisseurs de polices en ligne", "onlineFontProviders": "Fournisseurs de polices en ligne",
"downloadMissingWebFonts": "Télécharger les polices web manquantes via les fournisseurs activés.", "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 lapp web. Téléchargez lapp 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 lapplication web. Utilisez lapplication de bureau pour linclure.", "clipboardImageUnavailableWeb": "Le design collé contient 1 image qui ne peut pas être chargée dans lapplication web. Utilisez lapplication de bureau pour linclure.",
"clipboardImagesUnavailableWeb": "Le design collé contient {count} images qui ne peuvent pas être chargées dans lapplication web. Utilisez lapplication de bureau pour les inclure.", "clipboardImagesUnavailableWeb": "Le design collé contient {count} images qui ne peuvent pas être chargées dans lapplication web. Utilisez lapplication 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.", "clipboardImageFetchFailed": "Impossible de récupérer 1 image depuis Figma. Vérifiez que le fichier source est accessible, puis réessayez.",

View file

@ -235,7 +235,7 @@
"requesting": "Richiesta…", "requesting": "Richiesta…",
"onlineFontProviders": "Provider di font online", "onlineFontProviders": "Provider di font online",
"downloadMissingWebFonts": "Scarica i font web mancanti tramite i provider abilitati.", "downloadMissingWebFonts": "Scarica i font web mancanti tramite i provider abilitati.",
"webFontProvidersRequireDesktopApp": "I cataloghi dei provider di font online non sono disponibili nellapp web. Scarica lapp 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 nellapp web. Usa lapp desktop per includerla.", "clipboardImageUnavailableWeb": "Il design incollato contiene 1 immagine che non può essere caricata nellapp web. Usa lapp desktop per includerla.",
"clipboardImagesUnavailableWeb": "Il design incollato contiene {count} immagini che non possono essere caricate nellapp web. Usa lapp desktop per includerle.", "clipboardImagesUnavailableWeb": "Il design incollato contiene {count} immagini che non possono essere caricate nellapp web. Usa lapp desktop per includerle.",
"clipboardImageFetchFailed": "Impossibile recuperare 1 immagine da Figma. Controlla che il file sorgente sia accessibile e riprova.", "clipboardImageFetchFailed": "Impossibile recuperare 1 immagine da Figma. Controlla che il file sorgente sia accessibile e riprova.",

View file

@ -235,7 +235,7 @@
"requesting": "要求中…", "requesting": "要求中…",
"onlineFontProviders": "オンラインフォントプロバイダー", "onlineFontProviders": "オンラインフォントプロバイダー",
"downloadMissingWebFonts": "有効なプロバイダーから不足しているWebフォントをダウンロードします。", "downloadMissingWebFonts": "有効なプロバイダーから不足しているWebフォントをダウンロードします。",
"webFontProvidersRequireDesktopApp": "オンラインフォントプロバイダーのカタログはWebアプリでは利用できません。プロバイダーのフォントを参照して読み込むにはデスクトップアプリをダウンロードしてください。", "webFontLoadFailed": "オンラインプロバイダーからフォントを読み込めませんでした。接続を確認してもう一度お試しください。",
"clipboardImageUnavailableWeb": "貼り付けたデザインには、Webアプリで読み込めない画像が1件含まれています。その画像を含めるにはデスクトップアプリを使用してください。", "clipboardImageUnavailableWeb": "貼り付けたデザインには、Webアプリで読み込めない画像が1件含まれています。その画像を含めるにはデスクトップアプリを使用してください。",
"clipboardImagesUnavailableWeb": "貼り付けたデザインには、Webアプリで読み込めない画像が{count}件含まれています。それらの画像を含めるにはデスクトップアプリを使用してください。", "clipboardImagesUnavailableWeb": "貼り付けたデザインには、Webアプリで読み込めない画像が{count}件含まれています。それらの画像を含めるにはデスクトップアプリを使用してください。",
"clipboardImageFetchFailed": "Figmaから画像1件を取得できませんでした。元のファイルにアクセスできることを確認して、もう一度お試しください。", "clipboardImageFetchFailed": "Figmaから画像1件を取得できませんでした。元のファイルにアクセスできることを確認して、もう一度お試しください。",

View file

@ -235,7 +235,7 @@
"requesting": "Żądanie…", "requesting": "Żądanie…",
"onlineFontProviders": "Dostawcy czcionek online", "onlineFontProviders": "Dostawcy czcionek online",
"downloadMissingWebFonts": "Pobieraj brakujące czcionki webowe przez włączonych dostawców.", "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ć.", "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ć.", "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.", "clipboardImageFetchFailed": "Nie udało się pobrać 1 obrazu z Figmy. Sprawdź, czy plik źródłowy jest dostępny, i spróbuj ponownie.",

View file

@ -235,7 +235,7 @@
"requesting": "Запрос…", "requesting": "Запрос…",
"onlineFontProviders": "Онлайн-провайдеры шрифтов", "onlineFontProviders": "Онлайн-провайдеры шрифтов",
"downloadMissingWebFonts": "Загружайте отсутствующие веб-шрифты через включённых провайдеров.", "downloadMissingWebFonts": "Загружайте отсутствующие веб-шрифты через включённых провайдеров.",
"webFontProvidersRequireDesktopApp": "Каталоги провайдеров онлайн-шрифтов недоступны в веб-приложении. Скачайте настольное приложение, чтобы просматривать и загружать шрифты провайдеров.", "webFontLoadFailed": "Не удалось загрузить шрифт с онлайн-провайдера. Проверьте подключение к интернету.",
"clipboardImageUnavailableWeb": "Вставленный дизайн содержит 1 изображение, которое нельзя загрузить в веб-приложении. Используйте настольное приложение, чтобы включить его.", "clipboardImageUnavailableWeb": "Вставленный дизайн содержит 1 изображение, которое нельзя загрузить в веб-приложении. Используйте настольное приложение, чтобы включить его.",
"clipboardImagesUnavailableWeb": "Вставленный дизайн содержит {count} изображений, которые нельзя загрузить в веб-приложении. Используйте настольное приложение, чтобы включить их.", "clipboardImagesUnavailableWeb": "Вставленный дизайн содержит {count} изображений, которые нельзя загрузить в веб-приложении. Используйте настольное приложение, чтобы включить их.",
"clipboardImageFetchFailed": "Не удалось получить 1 изображение из Figma. Проверьте доступность исходного файла и повторите попытку.", "clipboardImageFetchFailed": "Не удалось получить 1 изображение из Figma. Проверьте доступность исходного файла и повторите попытку.",

View file

@ -235,7 +235,7 @@
"requesting": "正在请求…", "requesting": "正在请求…",
"onlineFontProviders": "在线字体提供商", "onlineFontProviders": "在线字体提供商",
"downloadMissingWebFonts": "通过已启用的提供商下载缺失的网页字体。", "downloadMissingWebFonts": "通过已启用的提供商下载缺失的网页字体。",
"webFontProvidersRequireDesktopApp": "网页版暂不支持在线字体提供商目录。请下载桌面应用来浏览和加载提供商字体。", "webFontLoadFailed": "无法从在线提供商加载字体。请检查网络连接后重试。",
"clipboardImageUnavailableWeb": "粘贴的设计包含 1 张无法在网页应用中加载的图片。请使用桌面应用以包含该图片。", "clipboardImageUnavailableWeb": "粘贴的设计包含 1 张无法在网页应用中加载的图片。请使用桌面应用以包含该图片。",
"clipboardImagesUnavailableWeb": "粘贴的设计包含 {count} 张无法在网页应用中加载的图片。请使用桌面应用以包含这些图片。", "clipboardImagesUnavailableWeb": "粘贴的设计包含 {count} 张无法在网页应用中加载的图片。请使用桌面应用以包含这些图片。",
"clipboardImageFetchFailed": "无法从 Figma 获取 1 张图片。请检查源文件是否可访问,然后重试。", "clipboardImageFetchFailed": "无法从 Figma 获取 1 张图片。请检查源文件是否可访问,然后重试。",

View file

@ -94,8 +94,8 @@ export const dialogMessageDefaults = {
requesting: 'Requesting…', requesting: 'Requesting…',
onlineFontProviders: 'Online font providers', onlineFontProviders: 'Online font providers',
downloadMissingWebFonts: 'Download missing web fonts through enabled providers.', downloadMissingWebFonts: 'Download missing web fonts through enabled providers.',
webFontProvidersRequireDesktopApp: webFontLoadFailed:
'Online font provider catalogs are unavailable in the web app. Download the desktop app to browse and load provider fonts.', 'Failed to load a font from an online provider. Check your connection and try again.',
clipboardImageUnavailableWeb: clipboardImageUnavailableWeb:
'Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.', 'Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.',
clipboardImagesUnavailableWeb: params( clipboardImagesUnavailableWeb: params(

View file

@ -60,7 +60,7 @@ function showWebFontUnavailableToast(): void {
if (webFontUnavailableToastShown || isTauri() || !onlineFontsEnabled.value) return if (webFontUnavailableToastShown || isTauri() || !onlineFontsEnabled.value) return
if (!WEB_FONT_PROVIDER_IDS.some((provider) => fontProviderSettings.value[provider])) return if (!WEB_FONT_PROVIDER_IDS.some((provider) => fontProviderSettings.value[provider])) return
webFontUnavailableToastShown = true webFontUnavailableToastShown = true
toast.warning(dialogMessages.get().webFontProvidersRequireDesktopApp) toast.warning(dialogMessages.get().webFontLoadFailed)
} }
function configureTauriFontCache() { function configureTauriFontCache() {
@ -71,7 +71,48 @@ function configureTauriFontCache() {
fontManager.setHostFontLoader(loadSystemFont) 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<Response> {
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() configureTauriFontCache()
configureBrowserFontProxy()
interface TauriFontFamily { interface TauriFontFamily {
family: string family: string
@ -150,7 +191,6 @@ export async function listFamilies(): Promise<FontFamilyOption[]> {
byFamily.set(font.family, { family: font.family, source: 'local' }) byFamily.set(font.family, { family: font.family, source: 'local' })
return [...byFamily.values()].sort((a, b) => a.family.localeCompare(b.family)) return [...byFamily.values()].sort((a, b) => a.family.localeCompare(b.family))
} }
showWebFontUnavailableToast()
return fontManager.listFamilyOptions() return fontManager.listFamilyOptions()
} }

View file

@ -13,6 +13,23 @@ const THEME_STORAGE_KEY = 'open-pencil:theme'
const DEFAULT_THEME: AppTheme = 'dark' const DEFAULT_THEME: AppTheme = 'dark'
const theme = useLocalStorage<AppTheme>(THEME_STORAGE_KEY, DEFAULT_THEME) const theme = useLocalStorage<AppTheme>(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() const prefersDark = usePreferredDark()
export const resolvedAppTheme = computed<'dark' | 'light'>(() => { export const resolvedAppTheme = computed<'dark' | 'light'>(() => {
if (theme.value === 'auto') return prefersDark.value ? 'dark' : 'light' if (theme.value === 'auto') return prefersDark.value ? 'dark' : 'light'

View file

@ -11,6 +11,7 @@ import packageJson from './package.json'
import { createOpenPencilAliases } from './vite/aliases' import { createOpenPencilAliases } from './vite/aliases'
import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation' import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation'
import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets' import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets'
import { createDevFontProxyPlugin } from './vite/font-proxy'
import { openPencilPwaPlugin } from './vite/pwa' import { openPencilPwaPlugin } from './vite/pwa'
import { rawMarkdownPlugin } from './vite/raw-markdown' import { rawMarkdownPlugin } from './vite/raw-markdown'
import { createDevServerOptions } from './vite/server' import { createDevServerOptions } from './vite/server'
@ -33,6 +34,7 @@ export default defineConfig(async ({ command }) => ({
Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }), Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }),
openPencilAutomationPlugin(command, host), openPencilAutomationPlugin(command, host),
vue(), vue(),
createDevFontProxyPlugin(),
openPencilPwaPlugin() openPencilPwaPlugin()
], ],
clearScreen: false, clearScreen: false,

83
vite/font-proxy.ts Normal file
View file

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