Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div id="root"></div>
<!-- Branded splash: dark-first Asset Pack identity, shown while the engine
sidecar boots; removed by main.tsx once React mounts. -->
<div id="brand-splash" style="position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:#0F111A;font-family:Inter,'Segoe UI',Arial,sans-serif;transition:opacity .35s ease;">
<div id="brand-splash" style="position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:#0F111A;font-family:ui-sans-serif,system-ui,'Segoe UI',Roboto,Arial,sans-serif;transition:opacity .35s ease;">
<div style="font-size:30px;font-weight:800;letter-spacing:-0.02em;">
<span style="color:#F2F2F2;font-weight:700;">Snapmaker</span>
<span style="background:linear-gradient(90deg,#00E1FF,#00A6FF,#8F00FF,#FF8500);-webkit-background-clip:text;background-clip:text;color:transparent;">Studio</span>
Expand Down
2 changes: 0 additions & 2 deletions desktop/src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");

@tailwind base;
@tailwind components;
@tailwind utilities;
Expand Down
66 changes: 66 additions & 0 deletions desktop/src/noRemoteFonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/// <reference types="vite/client" />
import { describe, expect, it } from "vitest";

// Studio is local-first: nothing may pull a font from a remote host (#94). This guard covers named font hosts
// (Google Fonts, Bunny, Typekit, ...) and any remote @import in CSS/HTML/TS/TSX; it does not detect other remote URLs.
const sources = {
...import.meta.glob("./**/*.{css,html,ts,tsx}", { query: "?raw", import: "default", eager: true }),
...import.meta.glob("../index.html", { query: "?raw", import: "default", eager: true }),
} as Record<string, string>;

// Whitespace, newlines and CSS comments may sit between the parts of an @import.
const GAP = String.raw`(?:\s|/\*[\s\S]*?\*/)`;
const REMOTE_FONT = new RegExp(
[
String.raw`fonts\.googleapis\.com|fonts\.gstatic\.com|use\.typekit\.net|fonts\.bunny\.net|cdnfonts\.com|fontawesome\.com`,
String.raw`@import${GAP}+(?:url\(${GAP}*)?["']?${GAP}*(?:https?:)?//`,
].join("|"),
"gi",
);

/** Every remote font host or remote @import in `text`, matched across line breaks and comments. */
function remoteFontHits(text: string): string[] {
return [...text.matchAll(REMOTE_FONT)].map((m) => m[0].replace(/\s+/g, " "));
}

describe("no remote font requests (#94)", () => {
it("finds the sources to scan", () => {
expect(Object.keys(sources).length).toBeGreaterThan(50);
expect(Object.keys(sources)).toContain("./index.css");
expect(Object.keys(sources)).toContain("../index.html");
// Vacuity guard: if raw CSS is stubbed to empty (vitest.config.ts css.include), the scan below would pass on nothing.
expect(sources["./index.css"]).toContain("@tailwind");
});

it("no CSS, HTML or TSX under desktop/src or index.html references an external font host", () => {
const bad = Object.entries(sources)
.filter(([f]) => !f.endsWith("noRemoteFonts.test.ts"))
.flatMap(([f, text]) => remoteFontHits(text).map((h) => `${f}: ${h}`));
expect(bad).toEqual([]);
});

it("the matcher catches the original offending import and common variants", () => {
const hits = (t: string) => (remoteFontHits(t).length > 0 ? 1 : 0);
expect(hits('@import url("https://fonts.googleapis.com/css2?family=Inter");')).toBe(1);
expect(hits("@import 'http://example.com/a.css';")).toBe(1);
expect(hits("@import url(//cdn.example.com/a.css);")).toBe(1);
expect(hits('<link href="https://fonts.gstatic.com/s/x.woff2">')).toBe(1);
expect(hits('@import "https://example.com/fonts.css";')).toBe(1);
});

it("catches an @import split across lines or interrupted by comments", () => {
const hits = (t: string) => (remoteFontHits(t).length > 0 ? 1 : 0);
expect(hits('@import\n url(\n"https://example.com/fonts.css"\n);')).toBe(1);
expect(hits('@import /* why */\n url(/* x */ "https://example.com/f.css");')).toBe(1);
expect(hits('@import /* why */ "//example.com/f.css";')).toBe(1);
expect(hits("a {}\n\n https://fonts.bunny.net/css?family=x\n")).toBe(1);
});

it("lets local imports and local or data: fonts through", () => {
const hits = (t: string) => (remoteFontHits(t).length > 0 ? 1 : 0);
expect(hits('@import "./local.css";')).toBe(0);
expect(hits('@import\n url("./local.css");')).toBe(0);
expect(hits('@font-face { src: url("./fonts/a.woff2"), url(data:font/woff2;base64,AAAA); }')).toBe(0);
expect(hits("font-family: ui-sans-serif, system-ui;")).toBe(0);
});
});
5 changes: 3 additions & 2 deletions desktop/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ export default {
},
borderRadius: { lg: "12px", md: "8px", sm: "6px" },
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
// System stacks only: Studio is local-first, so no font is fetched from a remote host (issue #94).
sans: ["ui-sans-serif", "system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
mono: ["ui-monospace", "SFMono-Regular", "Cascadia Mono", "Consolas", "Menlo", "monospace"],
},
},
},
Expand Down
2 changes: 2 additions & 0 deletions desktop/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
// Let ?raw CSS imports through unstubbed so the remote-font guard reads the real stylesheet.
css: { include: [/\.css(\?.*)?$/] },
},
});
36 changes: 36 additions & 0 deletions docs/testing/local-fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Local fonts: no remote font request (issue #94)

Studio's stylesheet used to start with an `@import` of Google Fonts (Inter, JetBrains Mono), so loading the web UI in a
browser or the Vite dev server contacted a remote host. That conflicts with Studio's local-first rule. The import is gone and
the font stacks are system stacks (set in `desktop/tailwind.config.js`, plus the splash in `desktop/index.html`). The CSP in
`desktop/src-tauri/tauri.conf.json` is unchanged.

Script: `tools/acceptance/local-fonts-offline.mjs` (Windows; Microsoft Edge via playwright-core;
`--out <folder> --tag before|after --mode dev|preview`). It loads the UI in dark and light themes with every request
recorded, aborts anything that is not loopback / `data:` / `blob:`, and fails if one was attempted or no text rendered.

## Result

| Run | Requests seen | Non-loopback attempts | Text rendered (dark, light) |
|---|---|---|---|
| before, dev server (`before-*.json`) | 320 | **2**, both `fonts.googleapis.com` | yes |
| after, dev server (`after-dev.json`) | 318 | **0** | yes |
| after, production build via `vite preview` (`after-preview.json`) | 6 | **0** | yes |

Screenshots: `before-dark.png`, `before-light.png`, `after-dark.png`, `after-light.png` (dev server, no engine running, so
the status bar says "Reconnecting"; no real data, printers, paths or model names).

The before and after screenshots are byte-identical (same SHA-256 for the dark pair and for the light pair). That is expected, not a missed change: the font request
was blocked in the "before" run, and Inter is not installed on this machine, so both fell back to the system UI font.
On a machine that has Inter installed, or with the font host reachable, the "before" look could differ slightly.

## Not run

- The packaged Tauri window (Windows installer build). The issue's guess that the CSP already blocks the request there is
still unchecked; this change makes the question moot for the stylesheet.
- WebKitGTK (Linux).
- Any browser other than Edge.

The vitest guard `desktop/src/noRemoteFonts.test.ts` fails if any CSS, HTML, TS or TSX under `desktop/src`, or
`desktop/index.html`, references a known font host or a remote `@import`. Mutation-checked: putting the old `@import`
back in `index.css` makes it fail.
Binary file added docs/testing/local-fonts/after-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions docs/testing/local-fonts/after-dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"tag": "after",
"mode": "dev",
"requestsSeen": 318,
"nonLoopbackAttempts": [],
"results": [
{
"theme": "dark",
"bodyFont": "ui-sans-serif, system-ui, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
"monoFont": "ui-monospace, SFMono-Regular, \"Cascadia Mono\", Consolas, Menlo, monospace",
"textLength": 1958,
"dark": true,
"fontFacesLoaded": 0
},
{
"theme": "light",
"bodyFont": "ui-sans-serif, system-ui, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
"monoFont": "ui-monospace, SFMono-Regular, \"Cascadia Mono\", Consolas, Menlo, monospace",
"textLength": 1958,
"dark": false,
"fontFacesLoaded": 0
}
],
"checks": [
{
"check": "no request left loopback",
"pass": true
},
{
"check": "text rendered in both themes",
"pass": true
},
{
"check": "theme applied as requested",
"pass": true
}
]
}
Binary file added docs/testing/local-fonts/after-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions docs/testing/local-fonts/after-preview.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"tag": "after",
"mode": "preview",
"requestsSeen": 6,
"nonLoopbackAttempts": [],
"results": [
{
"theme": "dark",
"bodyFont": "ui-sans-serif, system-ui, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
"monoFont": "ui-monospace, SFMono-Regular, \"Cascadia Mono\", Consolas, Menlo, monospace",
"textLength": 1958,
"dark": true,
"fontFacesLoaded": 0
},
{
"theme": "light",
"bodyFont": "ui-sans-serif, system-ui, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
"monoFont": "ui-monospace, SFMono-Regular, \"Cascadia Mono\", Consolas, Menlo, monospace",
"textLength": 1958,
"dark": false,
"fontFacesLoaded": 0
}
],
"checks": [
{
"check": "no request left loopback",
"pass": true
},
{
"check": "text rendered in both themes",
"pass": true
},
{
"check": "theme applied as requested",
"pass": true
}
]
}
Binary file added docs/testing/local-fonts/before-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions docs/testing/local-fonts/before-dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"tag": "before",
"mode": "dev",
"requestsSeen": 320,
"nonLoopbackAttempts": [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap",
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
],
"results": [
{
"theme": "dark",
"bodyFont": "Inter, system-ui, sans-serif",
"monoFont": "\"JetBrains Mono\", ui-monospace, monospace",
"textLength": 1958,
"dark": true,
"fontFacesLoaded": 0
},
{
"theme": "light",
"bodyFont": "Inter, system-ui, sans-serif",
"monoFont": "\"JetBrains Mono\", ui-monospace, monospace",
"textLength": 1958,
"dark": false,
"fontFacesLoaded": 0
}
],
"checks": [
{
"check": "no request left loopback",
"pass": false
},
{
"check": "text rendered in both themes",
"pass": true
},
{
"check": "theme applied as requested",
"pass": true
}
]
}
Binary file added docs/testing/local-fonts/before-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
101 changes: 101 additions & 0 deletions tools/acceptance/local-fonts-offline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env node
// Offline check that Studio's web UI asks nothing of the outside world for fonts (issue #94). Windows, Microsoft Edge.
//
// node tools/acceptance/local-fonts-offline.mjs --out <folder> [--tag before|after] [--mode dev|preview]
//
// Starts the Studio web UI from this checkout (vite dev server, or `vite build` + `vite preview`) on a free loopback
// port, loads it in Edge with every request recorded, aborts any request that is not loopback / data: / blob:, and
// fails if one was attempted or if no text rendered. Screenshots are taken in dark and light themes. No engine is
// started, so the UI shows its "engine not reachable" state; that is enough to prove fonts and text, nothing more.
// It is not the packaged Tauri window and not WebKitGTK.
import { createRequire } from "node:module";
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import net from "node:net";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

if (process.platform !== "win32") { console.error("This check is written for Windows (taskkill, npx.cmd)."); process.exit(2); }
const here = dirname(fileURLToPath(import.meta.url));
const desktop = join(here, "..", "..", "desktop");
const require = createRequire(import.meta.url);
const { chromium } = require("playwright-core");
const arg = (n, d) => (process.argv.includes(n) ? process.argv[process.argv.indexOf(n) + 1] : d);
const out = resolve(arg("--out", join(here, "local-fonts-evidence")));
const tag = arg("--tag", "run");
const mode = arg("--mode", "dev");
mkdirSync(out, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const children = [];
const dist = mkdtempSync(join(tmpdir(), "local-fonts-dist-"));
const cleanup = () => { for (const c of children) { try { spawnSync("taskkill", ["/PID", String(c.pid), "/T", "/F"], { stdio: "ignore" }); } catch { /* gone */ } } try { rmSync(dist, { recursive: true, force: true }); } catch { /* gone */ } };
process.on("exit", cleanup);
for (const sig of ["SIGINT", "SIGTERM", "SIGBREAK"]) process.on(sig, () => { cleanup(); process.exit(130); });

const freePort = () => new Promise((res) => { const s = net.createServer(); s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); }); });
const port = await freePort();
const run = (args) => { const c = spawn("npx.cmd", ["vite", ...args], { cwd: desktop, stdio: "ignore", shell: true }); children.push(c); return c; };
if (mode === "preview") {
const b = spawnSync("npx.cmd", ["vite", "build", "--outDir", dist], { cwd: desktop, stdio: "ignore", shell: true });
if (b.status !== 0) { console.error("vite build failed"); process.exit(1); }
run(["preview", "--outDir", dist, "--port", String(port), "--strictPort", "--host", "127.0.0.1"]);
} else {
run(["--port", String(port), "--strictPort", "--host", "127.0.0.1"]);
}
const base = `http://127.0.0.1:${port}/`;
for (let i = 0; ; i++) {
try { if ((await fetch(base)).ok) break; } catch { /* not up yet */ }
if (i > 120) { console.error("web UI did not start"); process.exit(1); }
await sleep(500);
}

const isLocal = (u) => {
if (/^(data|blob|about):/.test(u)) return true;
try { const h = new URL(u).hostname; return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1"; } catch { return false; }
};
const seen = [];
const blocked = [];
const browser = await chromium.launch({ channel: "msedge", headless: true });
const results = [];
for (const theme of ["dark", "light"]) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
await ctx.addInitScript((t) => localStorage.setItem("theme", t), theme);
await ctx.route("**/*", (route) => {
const u = route.request().url();
seen.push(u);
if (isLocal(u)) return route.continue();
blocked.push(u);
return route.abort();
});
const page = await ctx.newPage();
await page.goto(base, { waitUntil: "load" });
await page.waitForTimeout(2500);
const info = await page.evaluate(async () => {
await document.fonts.ready;
const cs = (el) => getComputedStyle(el).fontFamily;
return {
bodyFont: cs(document.body),
monoFont: cs(Object.assign(document.body.appendChild(document.createElement("code")), { className: "font-mono", textContent: "x" })),
textLength: document.body.innerText.trim().length,
dark: document.documentElement.classList.contains("dark"),
fontFacesLoaded: [...document.fonts].filter((f) => f.status === "loaded").length,
};
});
await page.screenshot({ path: join(out, `${tag}-${theme}.png`) });
results.push({ theme, ...info });
await ctx.close();
}
await browser.close();
const checks = [
["no request left loopback", blocked.length === 0],
["text rendered in both themes", results.every((r) => r.textLength > 20)],
["theme applied as requested", results[0].dark === true && results[1].dark === false],
];
const report = { tag, mode, requestsSeen: seen.length, nonLoopbackAttempts: blocked, results, checks: checks.map(([n, ok]) => ({ check: n, pass: ok })) };
writeFileSync(join(out, `${tag}-${mode}.json`), JSON.stringify(report, null, 2));
for (const [n, ok] of checks) console.log(`${ok ? "PASS" : "FAIL"} ${n}`);
console.log(`requests seen: ${seen.length}; non-loopback attempts: ${blocked.length}`);
for (const b of blocked) console.log(` blocked: ${b}`);
console.log(JSON.stringify(results));
process.exit(checks.every(([, ok]) => ok) ? 0 : 1);
Loading