3 Commits
Author SHA1 Message Date
Zack SheppardandClaude Opus 5.5 0a7d47cf14 Fix headset discovery against SteamOS's mDNS responder (#7)
* Fix headset discovery against SteamOS's mDNS responder

SteamOS answers a _steamos-devkit._tcp browse with only the PTR record,
not the SRV/TXT it points to, so browse() dropped every headset. It now
asks for SRV and TXT, falls back to <name>.local:32000, re-asks over three
rounds (responders skip records multicast in the last second, and an idle
headset's Wi-Fi adds latency), and remembers addresses between scans. The
test's fake headset now answers the same way SteamOS does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Scan for headsets once per visit, and harden the discovery cache

- The connect screen scans once when it opens instead of every 5 s. If
  nothing answers it shows "No headset found" with a "Click to try
  again" button; when headsets are found, a "Search again" link.
- Discovery only remembers addresses for names it asked about, and
  connecting never falls back to a remembered address: connection
  targets always come from a live answer (security review on #7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:24:12 +01:00
Zack SheppardandClaude Opus 5.5 2cbc333477 Use the new logo in the app header and on the website (#6)
- App: the logo replaces the old blue mark in the home top bar and on
  the connect screen
- Website: logo in the nav of every page, PNG favicon and Apple touch
  icon, and screenshots regenerated so they show the new header

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:26:37 +01:00
Zack SheppardandClaude Opus 5.5 4a300ed5fa Let electron-builder find the signing identity in signed Mac builds (#5)
CSC_IDENTITY_AUTO_DISCOVERY=false was set for every Mac build. With it,
electron-builder imports CSC_LINK into its keychain but won't look the
identity up, so the v0.0.3 build skipped signing and failed the signature
check. It's now set only for unsigned builds.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:01:44 +01:00
25 changed files with 137 additions and 66 deletions

No files matched your search

+5 -1
View File
@@ -140,7 +140,6 @@ jobs:
- name: Build installers
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
SIGN_MAC: ${{ inputs.sign_mac }}
SIGN_CSC_LINK: ${{ secrets.CSC_LINK }}
SIGN_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@@ -160,6 +159,11 @@ jobs:
trap 'rm -f "$key_file"' EXIT
export CSC_LINK="$SIGN_CSC_LINK" CSC_KEY_PASSWORD="$SIGN_CSC_KEY_PASSWORD"
export APPLE_API_KEY="$key_file" APPLE_API_KEY_ID="$SIGN_APPLE_API_KEY_ID" APPLE_API_ISSUER="$SIGN_APPLE_API_ISSUER"
else
# Unsigned (ad-hoc) build: don't let electron-builder look for a certificate.
# Not set for signed builds: with it, electron-builder imports CSC_LINK but then
# won't look the identity up, and silently skips signing.
export CSC_IDENTITY_AUTO_DISCOVERY=false
fi
unset SIGN_CSC_LINK SIGN_CSC_KEY_PASSWORD SIGN_APPLE_API_KEY SIGN_APPLE_API_KEY_ID SIGN_APPLE_API_ISSUER
pnpm build
+2 -1
View File
@@ -53,6 +53,7 @@ const configs = [
function copyStatic() {
mkdirSync("dist", { recursive: true });
cpSync("src/renderer/index.html", "dist/index.html");
cpSync("src/renderer/logo.png", "dist/logo.png");
// Shared tokens first, then the app's own styles.
writeFileSync("dist/styles.css", readFileSync(TOKENS, "utf8") + "\n" + readFileSync("src/renderer/styles.css", "utf8"));
}
@@ -64,7 +65,7 @@ if (watch) {
// Keep static files fresh too.
const { watch: fsWatch } = await import("node:fs");
fsWatch("src/renderer", { recursive: true }, (_e, f) => {
if (f && (f.endsWith(".html") || f.endsWith(".css"))) copyStatic();
if (f && (f.endsWith(".html") || f.endsWith(".css") || f.endsWith(".png"))) copyStatic();
});
fsWatch(TOKENS, () => copyStatic());
console.log("watching…");
+63 -15
View File
@@ -42,12 +42,33 @@ function open(): Mdns | undefined {
}
}
// Addresses seen for headset hostnames, across scans, for display only. Responders won't
// repeat a record they multicast within the last second, so a scan can see a name without
// its address. Only names a scan asked about are stored, and connecting never uses this:
// it always resolves live, and known headsets are pinned by SSH host key anyway.
const seenAddresses = new Map<string, { address: string; at: number }>();
const SEEN_TTL_MS = 10 * 60 * 1000;
function remember(host: string, address: string): void {
if (isIP(address) === 4) seenAddresses.set(host, { address, at: Date.now() });
}
function recalled(host: string): string | undefined {
const hit = seenAddresses.get(host);
return hit && Date.now() - hit.at < SEEN_TTL_MS ? hit.address : undefined;
}
function records(r: { answers?: Record_[]; additionals?: Record_[] }): Record_[] {
return [...(r.answers ?? []), ...(r.additionals ?? [])];
}
/** "<label>.local" when a service instance name is also a valid hostname (SteamOS uses the hostname). */
function hostGuess(instanceName: string): string | undefined {
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(instanceName) ? `${instanceName.toLowerCase()}.local` : undefined;
}
/** Browse for headsets advertising the devkit service. Resolves after timeoutMs with whatever answered. */
export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
export function browse(timeoutMs = 3000): Promise<DiscoveredFrame[]> {
return new Promise((resolve) => {
const m = open();
if (!m) return resolve([]);
@@ -60,12 +81,23 @@ export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
const instances = new Map<string, Inst>();
const addresses = new Map<string, Set<string>>();
const asked = new Set<string>();
const askA = (host: string) => {
if (addresses.has(host) || asked.has(host)) return;
asked.add(host);
m.query({ questions: [{ name: host, type: "A" }] });
};
const inst = (full: string): Inst => {
const key = norm(full);
let i = instances.get(key);
if (!i) {
i = { name: key.replace("." + DEVKIT_SERVICE, ""), txt: {} };
instances.set(key, i);
// SteamOS's responder answers the PTR browse with just the PTR (plus an A record),
// not the SRV/TXT it points to, so ask for them. Also ask for <name>.local, which
// is what SteamOS uses as the target, in case the SRV answer never comes.
m.query({ questions: [{ name: key, type: "SRV" }, { name: key, type: "TXT" }] });
const guess = hostGuess(i.name);
if (guess) askA(guess);
}
return i;
};
@@ -78,28 +110,41 @@ export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
const i = inst(name);
i.host = norm(d.target);
i.port = d.port;
if (!addresses.has(i.host) && !asked.has(i.host)) {
asked.add(i.host);
m.query({ questions: [{ name: i.host, type: "A" }] });
}
askA(i.host);
} else if (rec.type === "TXT" && name.endsWith("." + DEVKIT_SERVICE)) Object.assign(inst(name).txt, parseTxt(rec.data));
else if (rec.type === "A") {
if (!addresses.has(name)) addresses.set(name, new Set());
addresses.get(name)!.add(String(rec.data));
if (asked.has(name) || [...instances.values()].some((i) => (i.host ?? hostGuess(i.name)) === name)) remember(name, String(rec.data));
}
}
});
const ask = () => m.query({ questions: [{ name: DEVKIT_SERVICE, type: "PTR" }] });
ask();
const again = setTimeout(ask, Math.min(900, timeoutMs / 2));
// Ask a few times: an idle headset's Wi-Fi drops or delays packets, and responders
// skip records they multicast in the last second. Each round also re-asks for any
// instance still missing its SRV or address.
const round = () => {
m.query({ questions: [{ name: DEVKIT_SERVICE, type: "PTR" }] });
for (const [key, i] of instances) {
if (!i.host) m.query({ questions: [{ name: key, type: "SRV" }, { name: key, type: "TXT" }] });
const host = i.host ?? hostGuess(i.name);
if (host && !addresses.get(host)?.size) m.query({ questions: [{ name: host, type: "A" }] });
}
};
round();
const rounds = [0.3, 0.6].map((f) => setTimeout(round, Math.round(timeoutMs * f)));
setTimeout(() => {
clearTimeout(again);
rounds.forEach(clearTimeout);
m.destroy();
const out: DiscoveredFrame[] = [];
for (const i of instances.values()) {
if (!i.host) continue;
const addrs = [...(addresses.get(i.host) ?? [])].filter((a) => isIP(a) === 4);
out.push({ name: i.name, host: i.host, address: addrs[0] ?? "", port: i.port ?? 32000, login: i.txt.login, addresses: addrs });
const host = i.host ?? hostGuess(i.name);
if (!host) continue;
const addrs = [...(addresses.get(host) ?? [])].filter((a) => isIP(a) === 4);
if (!addrs.length) {
const prior = recalled(host);
if (prior) addrs.push(prior);
}
out.push({ name: i.name, host, address: addrs[0] ?? "", port: i.port ?? 32000, login: i.txt.login, addresses: addrs });
}
resolve(out.sort((a, b) => a.name.localeCompare(b.name)));
}, timeoutMs);
@@ -107,7 +152,7 @@ export function browse(timeoutMs = 2500): Promise<DiscoveredFrame[]> {
}
/** Resolve "<name>.local" (or a bare name) to an IPv4 address with our own mDNS query. */
export function resolveMdns(host: string, timeoutMs = 2000): Promise<string | undefined> {
export function resolveMdns(host: string, timeoutMs = 3000): Promise<string | undefined> {
return new Promise((resolve) => {
const m = open();
if (!m) return resolve(undefined);
@@ -116,7 +161,7 @@ export function resolveMdns(host: string, timeoutMs = 2000): Promise<string | un
const finish = (addr?: string) => {
if (done) return;
done = true;
clearTimeout(again);
retries.forEach(clearTimeout);
clearTimeout(timer);
m.destroy();
resolve(addr);
@@ -127,8 +172,11 @@ export function resolveMdns(host: string, timeoutMs = 2000): Promise<string | un
}
});
const ask = () => m.query({ questions: [{ name: want, type: "A" }] });
// Responders drop repeats of a record they multicast within the last second,
// and an idle headset's Wi-Fi adds latency, so ask a few times.
ask();
const again = setTimeout(ask, 700);
const retries = [700, 1600].map((ms) => setTimeout(ask, ms));
// No cached fallback here: this result is a connection target, so it must be a live answer.
const timer = setTimeout(() => finish(undefined), timeoutMs);
});
}
+1 -1
View File
@@ -121,7 +121,7 @@ function wireIpc(): void {
),
);
ipcMain.handle("connection:discover", () => browse(2500));
ipcMain.handle("connection:discover", () => browse());
ipcMain.handle("payload:inspect", async (_e, paths: string[]): Promise<PayloadInfo> => {
discardAll();
+10 -10
View File
@@ -276,34 +276,34 @@ export function cleanError(e: unknown): string {
return m.replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
}
let discoveryTimer: number | undefined;
let discoveryStarted = false;
let discoveryBusy = false;
async function discoverOnce(): Promise<void> {
/** One scan of the local network. Results stay on screen until the user asks again. */
export async function discoverOnce(): Promise<void> {
if (discoveryBusy) return;
discoveryBusy = true;
store.set({ discovering: true });
try {
const discovered = await api.connection.discover();
store.set({ discovered, discovering: false });
store.set({ discovered, discovering: false, discoveryDone: true });
} catch {
store.set({ discovering: false });
store.set({ discovering: false, discoveryDone: true });
} finally {
discoveryBusy = false;
}
}
/** Keep browsing for headsets while the connect screen is up. */
/** Scan once when the connect screen opens; further scans happen only when the user asks. */
export function startDiscovery(): void {
if (discoveryTimer !== undefined) return;
if (discoveryStarted) return;
discoveryStarted = true;
void discoverOnce();
discoveryTimer = window.setInterval(() => void discoverOnce(), 5000);
}
/** Called when the connect screen closes, so the next visit scans again. */
export function stopDiscovery(): void {
if (discoveryTimer === undefined) return;
clearInterval(discoveryTimer);
discoveryTimer = undefined;
discoveryStarted = false;
}
export function useDiscovered(host: string): void {
-1
View File
@@ -52,7 +52,6 @@ const ICONS: Record<string, string> = {
eraser: '<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
drop: '<path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/>',
};
export function icon(name: string, cls = ""): HTMLElement {
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+2 -1
View File
@@ -77,7 +77,8 @@ export function installMock(): void {
installRuntime: async () => ({ ok: true }),
discover: async () => {
await sleep(600);
return scenario === "fresh" || scenario === "connected"
// ?nodisc=1 pretends no headset answers.
return !q.has("nodisc") && (scenario === "fresh" || scenario === "connected")
? [{ name: "frame", host: "frame.local", address: "192.168.1.42", addresses: ["192.168.1.42"], port: 32000, login: "steamos" }]
: [];
},
+3
View File
@@ -68,6 +68,8 @@ export interface State {
inspectError?: string;
discovered: DiscoveredFrame[];
discovering: boolean;
/** At least one scan has finished since the connect screen opened. */
discoveryDone: boolean;
}
export const initialState: State = {
@@ -89,6 +91,7 @@ export const initialState: State = {
inspecting: false,
discovered: [],
discovering: false,
discoveryDone: false,
};
type Listener = (s: State) => void;
+2 -4
View File
@@ -36,11 +36,9 @@ p { margin: 0; }
}
.topbar > * { -webkit-app-region: no-drag; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; }
.brand .logo { width: 24px; height: 24px; border-radius: 7px; background: var(--accent); color: #fff; display: grid; place-items: center; }
.brand .logo svg { width: 15px; height: 15px; }
.brand .logo { width: 24px; height: 24px; border-radius: 6px; display: block; }
.brand-lg { font-size: 20px; gap: 12px; }
.brand-lg .logo { width: 32px; height: 32px; border-radius: 9px; }
.brand-lg .logo svg { width: 20px; height: 20px; }
.brand-lg .logo { width: 32px; height: 32px; border-radius: 8px; }
.main { flex: 1; overflow: auto; padding: 24px 20px 32px; }
.container { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.center { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100%; gap: 20px; }
+19 -8
View File
@@ -2,7 +2,7 @@ import { h, icon } from "../dom";
const TERMS_URL = "https://www.frameloader.com/terms";
import { store, type State } from "../state";
import { cancelPair, connectSaved, connectWithPassword, openSettings, pair, useDiscovered } from "../actions";
import { cancelPair, connectSaved, connectWithPassword, discoverOnce, openSettings, pair, useDiscovered } from "../actions";
export function connectView(s: State): HTMLElement {
const f = s.connectForm;
@@ -30,15 +30,25 @@ export function connectView(s: State): HTMLElement {
h("div", { class: "help" }, "Changed the hostname in Steam Settings → System? Enter it here. Frameloader finds the address itself, so .local is optional."),
);
const searching = s.discovering || !s.discoveryDone;
const found = h(
"div",
{ class: "found" },
h(
"div",
{ class: "row small muted", style: "gap: 8px" },
s.discovering && !s.discovered.length ? h("span", { class: "spinner" }) : icon("headset"),
s.discovered.length ? "Found on your network" : s.discovering ? "Looking for headsets on your network…" : "No headset found yet. Developer Mode must be on and the Frame awake.",
),
s.discovered.length
? h(
"div",
{ class: "row spread small muted", style: "gap: 8px" },
h("span", { class: "row", style: "gap: 8px" }, icon("headset"), "Found on your network"),
h("button", { class: "link small", disabled: searching || pairing, onclick: () => void discoverOnce() }, searching ? "Searching…" : "Search again"),
)
: searching
? h("div", { class: "row small muted", style: "gap: 8px" }, h("span", { class: "spinner" }), "Looking for headsets on your network…")
: h(
"div",
{ class: "row spread small muted", style: "gap: 8px" },
h("span", { class: "row", style: "gap: 8px" }, icon("headset"), "No headset found. Developer Mode must be on and the Frame awake."),
h("button", { class: "btn sm", style: "white-space: nowrap; flex: none", disabled: pairing, onclick: () => void discoverOnce() }, icon("refresh"), "Click to try again"),
),
s.discovered.length
? h(
"div",
@@ -56,6 +66,7 @@ export function connectView(s: State): HTMLElement {
: null,
);
const pairBlock = h(
"div",
{ class: "stack" },
@@ -144,7 +155,7 @@ export function connectView(s: State): HTMLElement {
step(3, "Open Pair new host", "Settings → Developer → Pair new host, when you click Pair."),
);
const brand = h("div", { class: "brand brand-lg" }, h("span", { class: "logo" }, icon("drop")), "Frameloader");
const brand = h("div", { class: "brand brand-lg" }, h("img", { class: "logo", src: "logo.png", alt: "" }), "Frameloader");
const settingsBtn = h("button", { class: "btn sm quiet corner", "aria-label": "Settings", title: "Settings", onclick: () => openSettings() }, icon("settings"));
+1 -1
View File
@@ -40,7 +40,7 @@ export function topbar(s: State): HTMLElement {
return h(
"div",
{ class: "topbar" },
h("div", { class: "brand" }, h("span", { class: "logo" }, icon("drop")), "Frameloader"),
h("div", { class: "brand" }, h("img", { class: "logo", src: "logo.png", alt: "" }), "Frameloader"),
h(
"div",
{ class: "row", style: "align-items: flex-start; gap: 6px" },
+19 -14
View File
@@ -3,24 +3,29 @@ import assert from "node:assert/strict";
import mdns from "multicast-dns";
import { browse, resolveHost, DEVKIT_SERVICE } from "../src/main/discovery";
// A fake Steam Frame answering on the local multicast group. Skips (not fails)
// when multicast isn't available on this machine.
// A fake Steam Frame answering on the local multicast group the way SteamOS does:
// the PTR browse gets only the PTR and an A record; SRV and TXT are answered only
// when asked for. Skips (not fails) when multicast isn't available on this machine.
function fakeFrame(name: string, host: string, address: string) {
const m = mdns();
m.on("error", () => undefined);
const instance = `${name}.${DEVKIT_SERVICE}`;
m.on("query", (q) => {
const wantsPtr = q.questions.some((x) => x.type === "PTR" && x.name.toLowerCase() === DEVKIT_SERVICE);
const wantsA = q.questions.some((x) => x.type === "A" && x.name.toLowerCase() === host);
if (!wantsPtr && !wantsA) return;
const instance = `${name}.${DEVKIT_SERVICE}`;
m.respond({
answers: wantsPtr ? [{ name: DEVKIT_SERVICE, type: "PTR", ttl: 120, data: instance }] : [],
additionals: [
{ name: instance, type: "SRV", ttl: 120, data: { target: host, port: 32000, priority: 0, weight: 0 } },
{ name: instance, type: "TXT", ttl: 120, data: [Buffer.from("login=steamos")] },
{ name: host, type: "A", ttl: 120, data: address },
],
});
const answers: { name: string; type: string; ttl: number; data: unknown }[] = [];
for (const x of q.questions) {
const qname = x.name.toLowerCase();
if (x.type === "PTR" && qname === DEVKIT_SERVICE) {
answers.push({ name: DEVKIT_SERVICE, type: "PTR", ttl: 120, data: instance });
answers.push({ name: host, type: "A", ttl: 120, data: address });
} else if (x.type === "SRV" && qname === instance) {
answers.push({ name: instance, type: "SRV", ttl: 120, data: { target: host, port: 32000, priority: 0, weight: 0 } });
} else if (x.type === "TXT" && qname === instance) {
answers.push({ name: instance, type: "TXT", ttl: 120, data: [Buffer.from("login=steamos")] });
} else if (x.type === "A" && qname === host) {
answers.push({ name: host, type: "A", ttl: 120, data: address });
}
}
if (answers.length) m.respond({ answers } as never);
});
return () => m.destroy();
}
+3 -2
View File
@@ -6,14 +6,15 @@
<title>Page not found · Frameloader</title>
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="/" aria-label="Frameloader home">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/></svg></span>
<img class="logo" src="/logo.png" width="28" height="28" alt="" />
Frameloader
</a>
</div>
+3 -2
View File
@@ -13,14 +13,15 @@
<meta property="og:image" content="https://www.frameloader.com/screenshots/home-light.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="canonical" href="https://www.frameloader.com/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="#top" aria-label="Frameloader home">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/></svg></span>
<img class="logo" src="/logo.png" width="28" height="28" alt="" />
Frameloader
</a>
<nav class="nav-links" aria-label="Sections">
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#2E6BE6"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 6v12"/><path d="m11.5 13.5 4.5 4.5 4.5-4.5"/><rect x="8" y="20" width="16" height="6" rx="2"/></g></svg>

Before

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+1 -2
View File
@@ -28,8 +28,7 @@ svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; s
.nav-inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; color: var(--text); }
.brand:hover { text-decoration: none; }
.logo { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; display: grid; place-items: center; }
.logo svg { width: 17px; height: 17px; stroke-width: 2.2; }
.logo { width: 28px; height: 28px; border-radius: 7px; display: block; }
.nav-links { display: flex; align-items: center; gap: 20px; font-size: 15px; }
.nav-links > a:not(.btn) { color: var(--text-2); }
.nav-links > a:not(.btn):hover { color: var(--text); text-decoration: none; }
+3 -2
View File
@@ -7,14 +7,15 @@
<meta name="description" content="Frameloader is free software provided as is, with no warranty. Use it at your own risk." />
<meta name="color-scheme" content="light dark" />
<link rel="canonical" href="https://www.frameloader.com/terms" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="/" aria-label="Frameloader home">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><rect x="4" y="15" width="16" height="6" rx="2"/></svg></span>
<img class="logo" src="/logo.png" width="28" height="28" alt="" />
Frameloader
</a>
<nav class="nav-links" aria-label="Sections">