From 620928d2c0f80bc391c2a7989fe5195b972c7775 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 15:59:55 -0300 Subject: [PATCH] feat(favicons): site icons on the tabs and the new tab, kept inside the app - A tab downloads the icon its page announced (or /favicon.ico) through its own session, keeps it per host in the profile (favicons/, at most 500) and points the tab at that copy. The icon of the page being left no longer stays on the next one: it shows the new host's saved icon or the globe. A private tab keeps its icon in memory only and never writes to the profile. - The new tab draws those icons on its tiles (the letter stays as the fallback), on a light circle so dark icons stay visible, and asks once, in the background, for the icons of favorites it never saw load. A site is one tile: a recent page of a site already listed is left out. - joychromium://favicon is served only with a per-run key that the shell and the internal pages are handed, so a web page cannot probe which sites were visited; the reply is sandboxed and not sniffed. Clearing browsing data clears the icons too. - Fix: opening a private tab threw "Attempted to register a second handler" when the block lists were loaded, because the ad blocker registers global ipcMain handlers once per session. A private tab could not be opened at all. Co-Authored-By: Claude Sonnet 5.5 --- app/src/assets/home.css | 3 + app/src/assets/newtab.html | 39 ++++++- app/src/main/adblock.ts | 17 ++- app/src/main/favicons.ts | 200 ++++++++++++++++++++++++++++++++ app/src/main/main.ts | 9 +- app/src/main/pageBridge.ts | 28 ++++- app/src/main/protocol.ts | 27 ++++- app/src/main/shell.ts | 4 + app/src/main/tabs.ts | 50 +++++++- app/tests/e2e/favicons.spec.ts | 99 ++++++++++++++++ app/tests/unit/favicons.test.ts | 116 ++++++++++++++++++ 11 files changed, 579 insertions(+), 13 deletions(-) create mode 100644 app/src/main/favicons.ts create mode 100644 app/tests/e2e/favicons.spec.ts create mode 100644 app/tests/unit/favicons.test.ts diff --git a/app/src/assets/home.css b/app/src/assets/home.css index 453d3dc..6900d4b 100644 --- a/app/src/assets/home.css +++ b/app/src/assets/home.css @@ -113,6 +113,9 @@ main { font-style: normal; font-weight: 700; } +/* Site icons are drawn for a light page (GitHub's is black): they sit on a light circle so every one stays visible. */ +.card i.has-icon { background: #f4f5f7; } +.card i img { width: calc(28 * var(--u)); height: calc(28 * var(--u)); object-fit: contain; border-radius: calc(4 * var(--u)); } .card span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } footer { position: fixed; bottom: var(--space-4); left: 0; right: 0; display: flex; align-items: center; gap: var(--space-4); justify-content: center; color: var(--faint); font-size: var(--text-xs); flex-wrap: wrap; } diff --git a/app/src/assets/newtab.html b/app/src/assets/newtab.html index cce48a6..8852fd2 100644 --- a/app/src/assets/newtab.html +++ b/app/src/assets/newtab.html @@ -48,6 +48,11 @@ let recent = []; let showSites = true; + // The site's own icon, kept inside the app; the initial in the circle stays until an icon loads (or when there is none). + let refreshed = 0; + let faviconKey = ''; + const hostOf = (url) => { try { const u = new URL(url); return u.protocol === 'https:' || u.protocol === 'http:' ? u.hostname : ''; } catch { return ''; } }; + function card(name, url) { const b = document.createElement('button'); b.className = 'card'; @@ -55,7 +60,18 @@ const badge = document.createElement('i'); // The initial is decoration: the button is named by the title alone, not "Y YouTube TV". badge.setAttribute('aria-hidden', 'true'); - badge.textContent = name.trim()[0]?.toUpperCase() ?? '?'; + // The letter is its own text node, so taking it away when the icon arrives leaves the icon in place. + const letter = document.createTextNode(name.trim()[0]?.toUpperCase() ?? '?'); + badge.append(letter); + const host = hostOf(url); + if (host) { + const icon = document.createElement('img'); + icon.alt = ''; + icon.addEventListener('load', () => { letter.remove(); badge.classList.add('has-icon'); }); + icon.addEventListener('error', () => icon.remove()); + icon.src = `joychromium://favicon/?host=${encodeURIComponent(host)}&k=${faviconKey}&r=${refreshed}`; + badge.append(icon); + } const label = document.createElement('span'); label.textContent = name; b.append(badge, label); @@ -79,12 +95,18 @@ r.setProperty('--accent-text', (0.2126 * (v >> 16 & 255) + 0.7152 * (v >> 8 & 255) + 0.0722 * (v & 255)) / 255 > 0.45 ? '#0A0F1A' : '#FFFFFF'); $('engine').textContent = `${msg.searchEngine.name}`; document.body.dataset.bg = msg.newTabBackground || 'Wallpaper'; + faviconKey = msg.faviconKey || ''; const b = parseInt(t.background.slice(1), 16); document.body.dataset.tone = (0.2126 * (b >> 16 & 255) + 0.7152 * (b >> 8 & 255) + 0.0722 * (b & 255)) / 255 > 0.5 ? 'light' : 'dark'; showSites = msg.newTabSites !== false; pinned = [...msg.shortcuts, ...(msg.favorites || [])]; render(); } + if (msg.type === 'favicons') { + // Icons arrived for sites that had none: draw the tiles again so those load. + refreshed += 1; + render(); + } if (msg.type === 'history' && Array.isArray(msg.entries)) { recent = msg.entries.map((e) => ({ name: e.title || e.url, url: e.url })); render(); @@ -92,11 +114,22 @@ }); function render() { - const seen = new Set(); - const sites = [...pinned, ...recent].filter((s) => !seen.has(s.url) && seen.add(s.url)).slice(0, MAX_TILES); + // The user's own shortcuts and favorites always show; a recent page is left out when its site is already there, so a + // site is one tile whatever page of it was opened last. + const siteOf = (url) => hostOf(url).replace(/^www\./, '') || url; + const urls = new Set(); + const own = pinned.filter((s) => !urls.has(s.url) && urls.add(s.url)); + const taken = new Set(own.map((s) => siteOf(s.url))); + const later = recent.filter((s) => !taken.has(siteOf(s.url)) && taken.add(siteOf(s.url))); + const sites = [...own, ...later].slice(0, MAX_TILES); $('sites').hidden = !showSites || sites.length === 0; $('sites').replaceChildren(...sites.map((s) => card(s.name, s.url))); + // Sites with no icon kept yet (visited before icons were saved, or only added by hand): ask for them once. + const wanted = [...new Set(sites.map((s) => hostOf(s.url)).filter((h) => h && !asked.has(h)))]; + for (const h of wanted) asked.add(h); + if (wanted.length) post('favicon-fetch', { hosts: wanted }); } + const asked = new Set(); post('ready', {}); post('history-search', { query: '' }); diff --git a/app/src/main/adblock.ts b/app/src/main/adblock.ts index 0798bd5..31ef586 100644 --- a/app/src/main/adblock.ts +++ b/app/src/main/adblock.ts @@ -2,7 +2,7 @@ import { Request } from "@ghostery/adblocker"; import { ElectronBlocker } from "@ghostery/adblocker-electron"; import { promises as fs } from "node:fs"; import { join } from "node:path"; -import type { Session } from "electron"; +import { ipcMain, type Session } from "electron"; import type { TrackingLevel } from "../shared/settings"; import { trackerListsFor } from "../shared/trackerLists"; @@ -68,9 +68,20 @@ export class AdBlock { }); } + /** + * The engine registers its cosmetic-filter handlers on two channels of ipcMain, which are global: turning it on in a + * second session (a private tab's own) throws "Attempted to register a second handler" and the tab never opens. The + * handlers belong to the one engine whatever the session, so the earlier ones are dropped first. + */ + private enableIn(engine: ElectronBlocker, session: Session): void { + ipcMain.removeHandler("@ghostery/adblocker/inject-cosmetic-filters"); + ipcMain.removeHandler("@ghostery/adblocker/is-mutation-observer-enabled"); + engine.enableBlockingInSession(session); + } + enable(session: Session): void { if (!this.blocker || this.enabledOn.has(session)) return; - this.blocker.enableBlockingInSession(session); + this.enableIn(this.blocker, session); this.enabledOn.add(session); } @@ -100,7 +111,7 @@ export class AdBlock { private install(engine: ElectronBlocker): void { for (const session of this.enabledOn) this.blocker?.disableBlockingInSession(session); this.blocker = engine; - for (const session of this.enabledOn) engine.enableBlockingInSession(session); + for (const session of this.enabledOn) this.enableIn(engine, session); } private cachePath(level: TrackingLevel): string { diff --git a/app/src/main/favicons.ts b/app/src/main/favicons.ts new file mode 100644 index 0000000..19acab9 --- /dev/null +++ b/app/src/main/favicons.ts @@ -0,0 +1,200 @@ +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Site icons kept inside the app: the tab strip and the new tab read them from here, never from the site. */ +export const FAVICON_MAX_BYTES = 128 * 1024; +const FAVICON_TIMEOUT_MS = 8000; + +const EXTENSIONS: Record = { + "image/png": "png", "image/x-icon": "ico", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/svg+xml": "svg", +}; +const MIME_OF = Object.fromEntries(Object.entries(EXTENSIONS).map(([mime, ext]) => [ext, mime])); + +export interface Favicon { + mime: string; + bytes: Buffer; +} + +/** The host a favicon belongs to, lowercased, or undefined for anything that is not a plain web host. */ +export function faviconHost(url: string | undefined | null): string | undefined { + if (!url) return undefined; + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; + // The host becomes a file name: letters, digits, dots and dashes only (this also leaves out IPv6 literals). + return /^[a-z0-9.-]{1,253}$/.test(parsed.hostname) ? parsed.hostname : undefined; + } catch { + return undefined; + } +} + +/** What the bytes really are, whatever the server said: only image types a tab can show are accepted. */ +export function sniffImage(bytes: Uint8Array): string | undefined { + const at = (i: number) => bytes[i] ?? -1; + if (at(0) === 0x89 && at(1) === 0x50 && at(2) === 0x4e && at(3) === 0x47) return "image/png"; + if (at(0) === 0 && at(1) === 0 && at(2) === 1 && at(3) === 0) return "image/x-icon"; + if (at(0) === 0xff && at(1) === 0xd8) return "image/jpeg"; + if (at(0) === 0x47 && at(1) === 0x49 && at(2) === 0x46) return "image/gif"; + if (at(0) === 0x52 && at(1) === 0x49 && at(2) === 0x46 && at(8) === 0x57 && at(9) === 0x45 && at(10) === 0x42 && at(11) === 0x50) return "image/webp"; + if (/]/i.test(Buffer.from(bytes.subarray(0, 512)).toString("utf8"))) return "image/svg+xml"; + return undefined; +} + +/** Which of the icons a page announced to take: a web icon over a data one, the first of each kind. */ +export function pickFavicon(candidates: readonly string[]): string | undefined { + return candidates.find((c) => c.startsWith("https://")) ?? candidates.find((c) => c.startsWith("http://")) ?? candidates.find((c) => c.startsWith("data:image/")); +} + +async function readBody(response: Response): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > FAVICON_MAX_BYTES) return undefined; + if (!response.body) return undefined; + const chunks: Uint8Array[] = []; + let size = 0; + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > FAVICON_MAX_BYTES) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + return Buffer.concat(chunks); +} + +/** Downloads (or decodes) one favicon through `fetchFn`, the session of the tab that asked. Returns undefined on any doubt. */ +export async function readFavicon(url: string, fetchFn: (input: string, init?: { signal?: AbortSignal }) => Promise): Promise { + try { + let bytes: Buffer | undefined; + if (url.startsWith("data:image/")) { + const comma = url.indexOf(","); + if (comma < 0 || url.length > FAVICON_MAX_BYTES * 2) return undefined; + const head = url.slice(0, comma); + const payload = url.slice(comma + 1); + bytes = head.includes(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload), "utf8"); + } else { + // A plain http icon is asked for over https: the page itself is upgraded the same way. + const target = url.replace(/^http:\/\//i, "https://"); + if (!target.startsWith("https://")) return undefined; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FAVICON_TIMEOUT_MS); + try { + const response = await fetchFn(target, { signal: controller.signal }); + if (!response.ok) return undefined; + bytes = await readBody(response); + } finally { + clearTimeout(timer); + } + } + if (!bytes || bytes.length === 0 || bytes.length > FAVICON_MAX_BYTES) return undefined; + const mime = sniffImage(bytes); + return mime ? { mime, bytes } : undefined; + } catch { + return undefined; + } +} + +interface Entry { + mime: string; + bytes?: Buffer; + file?: string; + version: number; +} + +/** + * One icon per host. Normal tabs write theirs to the profile (so the new tab can draw them on the next launch); + * private tabs keep theirs in memory only, gone when the app closes. At most `maxFiles` icons stay on disk, the + * oldest going first. + */ +export class FaviconStore { + private readonly disk = new Map(); + private readonly memory = new Map(); + private counter = 1; + /** + * A random value made at start-up and put in every address handed to our own documents. A web page asking for + * joychromium://favicon looks exactly like the shell asking (no referrer, no origin), so without it any page could + * probe which sites were visited by checking which icons exist. + */ + readonly key = randomBytes(16).toString("hex"); + + constructor(private readonly dir: string, private readonly log: (message: string) => void = () => {}, private readonly maxFiles = 500) { + try { + const files = existsSync(dir) ? readdirSync(dir) : []; + const known = files + .map((file) => ({ file, host: file.slice(0, file.lastIndexOf(".")), ext: file.slice(file.lastIndexOf(".") + 1) })) + .filter((f) => MIME_OF[f.ext] && /^[a-z0-9.-]{1,253}$/.test(f.host)) + .map((f) => ({ ...f, time: statSync(join(dir, f.file)).mtimeMs })) + .sort((a, b) => a.time - b.time); + for (const f of known) this.disk.set(f.host, { mime: MIME_OF[f.ext]!, file: f.file, version: this.counter++ }); + } catch (error) { + this.log(`Favicons: could not read ${dir}: ${String(error)}`); + } + } + + has(host: string): boolean { + return this.memory.has(host) || this.disk.has(host); + } + + /** The address the shell and the internal pages use for a host's icon, or undefined when none is kept. */ + urlFor(host: string | undefined): string | undefined { + const entry = host ? this.memory.get(host) ?? this.disk.get(host) : undefined; + return entry && host ? `joychromium://favicon/?host=${encodeURIComponent(host)}&v=${entry.version}&k=${this.key}` : undefined; + } + + get(host: string): Favicon | undefined { + const entry = this.memory.get(host) ?? this.disk.get(host); + if (!entry) return undefined; + if (!entry.bytes && entry.file) { + try { + entry.bytes = readFileSync(join(this.dir, entry.file)); + } catch (error) { + this.log(`Favicons: could not read ${entry.file}: ${String(error)}`); + this.disk.delete(host); + return undefined; + } + } + return entry.bytes ? { mime: entry.mime, bytes: entry.bytes } : undefined; + } + + put(host: string, icon: Favicon, persist: boolean): void { + const version = this.counter++; + if (!persist) { + this.memory.set(host, { mime: icon.mime, bytes: icon.bytes, version }); + return; + } + const ext = EXTENSIONS[icon.mime]; + if (!ext) return; + try { + mkdirSync(this.dir, { recursive: true }); + const file = `${host}.${ext}`; + const old = this.disk.get(host); + writeFileSync(join(this.dir, file), icon.bytes); + // The same host may come back in another format: the old file must not outlive it. + if (old?.file && old.file !== file) rmSync(join(this.dir, old.file), { force: true }); + this.disk.delete(host); + this.disk.set(host, { mime: icon.mime, bytes: icon.bytes, file, version }); + while (this.disk.size > this.maxFiles) { + const [oldest, entry] = this.disk.entries().next().value as [string, Entry]; + this.disk.delete(oldest); + if (entry.file) rmSync(join(this.dir, entry.file), { force: true }); + } + } catch (error) { + this.log(`Favicons: could not save ${host}: ${String(error)}`); + } + } + + /** Forgets every icon, on disk and in memory: part of clearing the browsing data. */ + clear(): void { + this.memory.clear(); + this.disk.clear(); + try { + rmSync(this.dir, { recursive: true, force: true }); + } catch (error) { + this.log(`Favicons: could not clear ${this.dir}: ${String(error)}`); + } + } +} diff --git a/app/src/main/main.ts b/app/src/main/main.ts index 113f0f7..02c43f3 100644 --- a/app/src/main/main.ts +++ b/app/src/main/main.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { AdBlock } from "./adblock"; import { exportDiagnostics } from "./diagnostics"; +import { FaviconStore } from "./favicons"; import { History } from "./history"; import { Log } from "./log"; import { PolicyService } from "./policyService"; @@ -35,7 +36,7 @@ export interface JoyState { adblockLevel: () => string | undefined; userAgent: string; wouldBlock: (url: string) => boolean; - tabs: () => { url: string; title: string; isActive: boolean; isLoading: boolean; isPlayingAudio: boolean }[]; + tabs: () => { url: string; title: string; isActive: boolean; isLoading: boolean; isPlayingAudio: boolean; favicon?: string; isPrivate: boolean }[]; command: (command: unknown) => Promise; /** Sends a controller event through the same router a gamepad uses (tests only). */ controller: (input: ControllerEvent) => Promise; @@ -68,6 +69,7 @@ const distDir = join(__dirname, ".."); const log = new Log(join(dataDir, "logs")); const store = new SettingsStore(dataDir, (m) => log.warn(m)); const history = new History(join(dataDir, "history.jsonl"), (m) => log.warn(m)); +const favicons = new FaviconStore(join(dataDir, "favicons"), (m) => log.warn(m)); const policy = new PolicyService(dataDir, (m) => log.warn(m)); const adblock = new AdBlock(dataDir, (m) => log.info(m)); const updater = new Updater(log, () => policy.current.blockedAppVersions); @@ -101,7 +103,7 @@ async function start(): Promise { const settings = store.load(); policy.loadCached(); crashes.sync(settings.crashReports); - installProtocolHandler(distDir); + installProtocolHandler(distDir, favicons); const minimum = effectiveMinimumChromium(policy.current, MINIMUM_CHROMIUM); if (!versionAtLeast(process.versions.chrome, minimum)) log.warn(`Chromium ${process.versions.chrome} is below the minimum ${minimum}`); @@ -143,6 +145,7 @@ async function start(): Promise { const shell = new Shell({ store, history, + favicons, policy, adblock, log, @@ -167,7 +170,7 @@ async function start(): Promise { shellRef = shell; globalThis.joy.exportDiagnostics = exportDiagnosticsReport; globalThis.joy.tabs = () => - shell.tabs.tabs.map((t) => ({ url: t.url, title: t.title, isActive: t === shell.tabs.active, isLoading: t.isLoading, isPlayingAudio: t.isPlayingAudio })); + shell.tabs.tabs.map((t) => ({ url: t.url, title: t.title, isActive: t === shell.tabs.active, isLoading: t.isLoading, isPlayingAudio: t.isPlayingAudio, favicon: t.favicon, isPrivate: t.isPrivate })); globalThis.joy.command = (command) => shell.handle(command as never); globalThis.joy.controller = (input) => shell.handleController(input); globalThis.joy.pageEval = (script) => shell.activeWebContents?.executeJavaScript(script, true) ?? Promise.resolve(undefined); diff --git a/app/src/main/pageBridge.ts b/app/src/main/pageBridge.ts index 096efdf..b3609e9 100644 --- a/app/src/main/pageBridge.ts +++ b/app/src/main/pageBridge.ts @@ -10,6 +10,7 @@ import { clearBrowsingData } from "./services/privacy"; import type { ShellDeps } from "./shell"; import type { Shell } from "./shell"; import type { Tab } from "./tabs"; +import { faviconHost, readFavicon } from "./favicons"; type Message = Record & { type?: string }; @@ -29,6 +30,7 @@ export class PageBridge { const s = this.deps.store.current; return { theme: s.theme, + faviconKey: this.deps.favicons.key, presets: THEME_PRESETS, searchEngine: s.searchEngine, searchEngines: SEARCH_ENGINES, @@ -75,6 +77,23 @@ export class PageBridge { }; } + /** Hosts whose /favicon.ico gave no icon, and when: not asked again for a while, so a dropped connection does not decide it for good. */ + private readonly noFavicon = new Map(); + + /** The icon of a site the new tab lists but the app never saw load: /favicon.ico, once, through the asking tab's session. */ + private async fetchFavicon(tab: Tab, host: string): Promise { + if (faviconHost(`https://${host}/`) !== host) return false; + if (this.deps.favicons.has(host)) return true; + if (Date.now() - (this.noFavicon.get(host) ?? -Infinity) < 10 * 60_000) return false; + const icon = await readFavicon(`https://${host}/favicon.ico`, (input, init) => tab.session.fetch(input, init)); + if (!icon) { + this.noFavicon.set(host, Date.now()); + return false; + } + this.deps.favicons.put(host, icon, !tab.isPrivate); + return true; + } + async handle(tab: Tab, raw: unknown): Promise { if (!raw || typeof raw !== "object") return; const m = raw as Message; @@ -167,6 +186,13 @@ export class PageBridge { if (typeof m.url === "string") store.update((s) => ({ ...s, favorites: s.favorites.filter((f) => f.url !== m.url) })); this.shell.broadcast(); break; + case "favicon-fetch": { + const hosts = Array.isArray(m.hosts) ? m.hosts.slice(0, 8).filter((h): h is string => typeof h === "string") : []; + const found = await Promise.all(hosts.map((host) => this.fetchFavicon(tab, host))); + const arrived = hosts.filter((_, i) => found[i]); + if (arrived.length > 0) this.reply(tab, { type: "favicons", hosts: arrived }); + break; + } case "history-search": this.reply(tab, { type: "history", entries: this.deps.history.search(typeof m.query === "string" ? m.query : "") }); break; @@ -201,7 +227,7 @@ export class PageBridge { } break; case "clear-data": - await clearBrowsingData({ session: tab.webContents.session, clearHistory: () => this.deps.history.clear() }); + await clearBrowsingData({ session: tab.webContents.session, clearHistory: () => { this.deps.history.clear(); this.deps.favicons.clear(); } }); this.shell.notify("browsingDataCleared"); break; default: diff --git a/app/src/main/protocol.ts b/app/src/main/protocol.ts index d145710..9ceeeab 100644 --- a/app/src/main/protocol.ts +++ b/app/src/main/protocol.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { join, normalize } from "node:path"; import { pathToFileURL } from "node:url"; import { internalPageFile, isDevOnlyFile, SCHEME } from "../shared/pages"; +import type { FaviconStore } from "./favicons"; /** Must run before app.whenReady(): makes joychromium:// behave like https for the pages we serve. */ export function registerScheme(): void { @@ -11,16 +12,40 @@ export function registerScheme(): void { ]); } +const notFound = () => new Response("Not found", { status: 404, headers: { "content-type": "text/plain" } }); + +/** + * joychromium://favicon/?host=…&k=: the icon kept for a host. A site's icon in the store says the user visited that + * site, so only whoever holds the start-up key (the shell and the internal pages, which are handed it) may ask; a web page + * cannot tell our documents from itself by the request alone. The reply is also locked down (sandbox, no sniffing) so a + * kept SVG can never run anything, even if someone navigated to it. + */ +function serveFavicon(request: Request, url: URL, favicons: FaviconStore): Response { + if (url.searchParams.get("k") !== favicons.key) return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } }); + const icon = favicons.get(url.searchParams.get("host") ?? ""); + if (!icon) return notFound(); + return new Response(new Uint8Array(icon.bytes), { + headers: { + "content-type": icon.mime, + "content-security-policy": "sandbox; default-src 'none'", + "x-content-type-options": "nosniff", + // Read from local disk on every request: an icon that changed must show, and the tab strip's v= address is unique anyway. + "cache-control": "no-cache", + }, + }); +} + /** * Serves the shell UI and internal pages from the compiled output directory. * joychromium://chrome/... -> dist/ (the shell UI and its ES modules) * joychromium://settings/ -> dist/assets/settings.html (and sibling files by path) */ -export function installProtocolHandler(distDir: string): void { +export function installProtocolHandler(distDir: string, favicons: FaviconStore): void { const root = normalize(distDir); protocol.handle(SCHEME, (request) => { const url = new URL(request.url); let file: string | undefined; + if (url.hostname === "favicon") return serveFavicon(request, url, favicons); if (url.hostname === "chrome") { const rel = url.pathname === "/" ? "/assets/chrome/index.html" : url.pathname; file = join(root, normalize(rel)); diff --git a/app/src/main/shell.ts b/app/src/main/shell.ts index 5c42331..82f6025 100644 --- a/app/src/main/shell.ts +++ b/app/src/main/shell.ts @@ -19,6 +19,7 @@ import { isFavorite, toggleFavorite, withInputMode, withPermission, type InputMo import { defaultModeFor, nextMode } from "../shared/controllerInput"; import type { AdBlock } from "./adblock"; import { VirtualCursor } from "./controllerActions"; +import type { FaviconStore } from "./favicons"; import type { History } from "./history"; import type { Log } from "./log"; import { PageBridge } from "./pageBridge"; @@ -33,6 +34,8 @@ import { Tab, TabManager } from "./tabs"; export interface ShellDeps { store: SettingsStore; history: History; + /** The site icons kept inside the app, drawn by the tab strip and the new tab. */ + favicons: FaviconStore; policy: PolicyService; adblock: AdBlock; log: Log; @@ -99,6 +102,7 @@ export class Shell { userAgentFor: (url) => userAgentForUrl(url, { desktop: deps.desktopUserAgent, tv: deps.tvUserAgent, enabled: deps.store.current.streamingDesktopUa }), prepareSession: (ses) => this.prepareSession(ses), + favicons: deps.favicons, log: deps.log, }); this.bridge = new PageBridge(this, deps); diff --git a/app/src/main/tabs.ts b/app/src/main/tabs.ts index 1d016e4..4dd82c7 100644 --- a/app/src/main/tabs.ts +++ b/app/src/main/tabs.ts @@ -5,6 +5,7 @@ import { displayUrl, errorPageFor, isInternal, isWebUrl, Pages } from "../shared import { hostOf, isHttp, isNavigationAllowed, upgradeToHttps } from "../shared/security"; import type { TabState } from "../shared/ipc"; import type { StatusId } from "../shared/status"; +import { faviconHost, pickFavicon, readFavicon, type FaviconStore } from "./favicons"; export interface TabEnvironment { preloadDir: string; @@ -14,6 +15,8 @@ export interface TabEnvironment { /** The agent a tab loads a URL with: the desktop one on a listed streaming host, the TV one elsewhere. */ userAgentFor: (url: string) => string; prepareSession: (ses: Session) => void; + /** The site icons kept inside the app (see favicons.ts). */ + favicons: FaviconStore; log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void }; } @@ -63,6 +66,11 @@ export class Tab { return this.viewRef.webContents; } + /** The session this tab browses in: the private partition for a private tab. */ + get session(): Session { + return this.ses; + } + /** Drops the renderer process; the URL and title stay so the tab can be restored on demand. */ sleep(): boolean { if (this.sleeping) return false; @@ -273,6 +281,34 @@ export class TabManager extends EventEmitter { .filter((u) => u !== Pages.welcome && u !== Pages.settings && !u.startsWith(Pages.error)); } + /** + * Downloads the icon a page announced, through the session of the tab that loaded the page (so a private tab's request + * never touches the normal profile), keeps it for the host, and points the tab at the kept copy. A normal tab saves it to + * the profile; a private one only in memory. + */ + private async saveFavicon(tab: Tab, iconUrl: string): Promise { + if (tab.sleeping || tab.webContents.isDestroyed()) return; + const host = faviconHost(tab.webContents.getURL()); + if (!host) return; + const icon = await readFavicon(iconUrl, (input, init) => tab.session.fetch(input, init)); + if (!icon) return; + this.env.favicons.put(host, icon, !tab.isPrivate); + if (tab.sleeping || tab.webContents.isDestroyed() || faviconHost(tab.webContents.getURL()) !== host) return; + tab.favicon = this.env.favicons.urlFor(host); + this.emit("changed"); + } + + /** Pages without an icon link still often serve /favicon.ico: asked for once, after the page had time to announce its own. */ + private fallbackFavicon(tab: Tab): void { + const host = faviconHost(tab.webContents.getURL()); + if (!host || this.env.favicons.has(host)) return; + setTimeout(() => { + if (tab.sleeping || tab.webContents.isDestroyed() || this.env.favicons.has(host)) return; + const url = tab.webContents.getURL(); + if (faviconHost(url) === host) void this.saveFavicon(tab, new URL("/favicon.ico", url).toString()); + }, 2000).unref(); + } + private wire(tab: Tab): void { const wc = tab.webContents; wc.setWindowOpenHandler(({ url }) => { @@ -313,6 +349,8 @@ export class TabManager extends EventEmitter { if (!details.isMainFrame) return; tab.url = displayUrl(details.url); tab.isLoading = true; + // The icon of the page being left must not stay on the new one: show the saved icon of the new host, or the globe. + if (!details.isSameDocument) tab.favicon = this.env.favicons.urlFor(faviconHost(details.url)); this.emit("changed"); }); wc.on("did-finish-load", () => { @@ -320,6 +358,7 @@ export class TabManager extends EventEmitter { tab.pendingHttpsUpgrade = undefined; tab.url = displayUrl(wc.getURL()); if (!tab.isPrivate && isWebUrl(wc.getURL()) && !isInternal(wc.getURL())) this.emit("visited", wc.getURL(), wc.getTitle()); + this.fallbackFavicon(tab); this.emit("changed"); }); wc.on("did-fail-load", (_event, code, description, url, isMainFrame) => { @@ -338,8 +377,15 @@ export class TabManager extends EventEmitter { this.emit("changed"); }); wc.on("page-favicon-updated", (_event, favicons) => { - tab.favicon = favicons.find((f) => f.startsWith("https://")) ?? favicons[0]; - this.emit("changed"); + // Our own pages announce a bundled file; a site's icon is downloaded once and kept (see saveFavicon). + const internal = favicons.find((f) => f.startsWith("joychromium://")); + if (internal) { + tab.favicon = internal; + this.emit("changed"); + return; + } + const icon = pickFavicon(favicons); + if (icon) void this.saveFavicon(tab, icon); }); wc.on("media-started-playing", () => { tab.isPlayingAudio = true; diff --git a/app/tests/e2e/favicons.spec.ts b/app/tests/e2e/favicons.spec.ts new file mode 100644 index 0000000..bf862af --- /dev/null +++ b/app/tests/e2e/favicons.spec.ts @@ -0,0 +1,99 @@ +import { _electron as electron, expect, test, type ElectronApplication, type Page } from "@playwright/test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// A 1x1 PNG: what the fake sites serve as their icon. +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +let data: string; +let app: ElectronApplication; +let chrome: Page; + +test.beforeEach(async () => { + data = mkdtempSync(join(tmpdir(), "joychromium-favicons-")); + writeFileSync(join(data, "settings.json"), JSON.stringify({ + onboardingCompleted: true, startup: "NewTab", language: "en", windowMode: "window", + favorites: [{ name: "Backfill", url: "https://backfill.example/" }], + })); + // Starts on a blank page so the fake sites below are in place before the app asks for anything. + // A page of the favorite's own site was opened before: the new tab shows the site once. + writeFileSync(join(data, "history.jsonl"), `${JSON.stringify({ url: "https://backfill.example/other", title: "Other page", visitedUtc: "2026-10-11T12:00:00.000Z" })} +`); + app = await electron.launch({ args: ["."], env: { ...process.env, JOYCHROMIUM_DATA: data, JOYCHROMIUM_START: "about:blank" } }); + chrome = await app.firstWindow(); + await expect.poll(() => app.evaluate(() => globalThis.joy.tabs().length), { timeout: 30_000 }).toBe(1); + // The sites: two announce an icon, one serves only /favicon.ico, one has no icon at all. The CI has no network. + await app.evaluate(({ protocol, session }, png) => { + const html = (head: string) => new Response(`Site${head}`, { headers: { "content-type": "text/html" } }); + const serve = (request: Request) => { + const { hostname, pathname } = new URL(request.url); + if (pathname === "/icon.png" || (pathname === "/favicon.ico" && hostname === "backfill.example")) { + return new Response(Buffer.from(png, "base64"), { headers: { "content-type": "image/png" } }); + } + if (pathname === "/favicon.ico") return new Response("no", { status: 404 }); + return html(hostname.startsWith("icons") ? '' : ""); + }; + protocol.handle("https", serve); + // The private tab of these tests is the second one: it browses in its own session. + session.fromPartition("private-2").protocol.handle("https", serve); + }, PNG); +}); + +test.afterEach(async () => { + await app.close().catch(() => undefined); + rmSync(data, { recursive: true, force: true }); +}); + +const tabs = () => app.evaluate(() => globalThis.joy.tabs()); +const active = async () => (await tabs()).find((t) => t.isActive); +const command = (c: unknown) => app.evaluate(({}, cmd) => globalThis.joy.command(cmd), c); +const pageEval = (code: string) => app.evaluate(({}, c) => globalThis.joy.pageEval(c), code) as Promise; +const ICON_URL = (host: string) => new RegExp(`^joychromium://favicon/\\?host=${host.replace(/\./g, "\\.")}&v=\\d+&k=[0-9a-f]{32}$`); + +test("a tab shows its site's icon, kept inside the app and gone when the site changes", async () => { + await command({ type: "navigate", url: "https://icons.example/" }); + await expect.poll(async () => (await active())?.favicon, { timeout: 30_000 }).toMatch(ICON_URL("icons.example")); + // Kept in the profile, and drawn by the tab strip from there, not from the site. + expect(existsSync(join(data, "favicons", "icons.example.png"))).toBe(true); + await expect.poll(() => chrome.evaluate(() => [...document.querySelectorAll(".tab-icon")] + .some((img) => img.src.startsWith("joychromium://favicon/") && img.naturalWidth > 0))).toBe(true); + + // A web page asking for the icon, with no key or a made-up one, learns nothing about the browsing history. + const probe = (query: string) => pageEval(`new Promise((done) => { const i = new Image(); i.onload = () => done("loaded"); i.onerror = () => done("blocked"); i.src = "joychromium://favicon/?host=icons.example${query}"; })`); + expect(await probe("")).toBe("blocked"); + expect(await probe("&k=00000000000000000000000000000000")).toBe("blocked"); + + // A site with no icon at all: the globe, not the previous site's icon. + await command({ type: "navigate", url: "https://plain.example/" }); + await expect.poll(async () => (await active())?.url, { timeout: 30_000 }).toBe("https://plain.example/"); + await expect.poll(async () => (await active())?.isLoading, { timeout: 30_000 }).toBe(false); + expect((await active())?.favicon).toBeUndefined(); +}); + +test("a site that only serves /favicon.ico gets its icon too", async () => { + await command({ type: "navigate", url: "https://backfill.example/" }); + await expect.poll(async () => (await active())?.favicon, { timeout: 30_000 }).toMatch(ICON_URL("backfill.example")); +}); + +test("a private tab shows the icon but writes nothing to the profile", async () => { + // With the block lists loaded (they need the network or a cache, so not on every CI run) the private tab's own session + // gets the blocker too, and that used to throw "second handler" so the tab never opened. + await expect.poll(() => app.evaluate(() => globalThis.joy.adblockReady), { timeout: 20_000 }).toBe(true).catch(() => undefined); + await command({ type: "new-tab", isPrivate: true }); + expect((await tabs()).filter((t) => t.isPrivate)).toHaveLength(1); + await command({ type: "navigate", url: "https://icons-private.example/" }); + await expect.poll(async () => (await active())?.favicon, { timeout: 30_000 }).toMatch(ICON_URL("icons-private.example")); + expect(existsSync(join(data, "favicons", "icons-private.example.png"))).toBe(false); +}); + +test("the new tab draws the kept icons, fetching the ones it never saw", async () => { + await command({ type: "navigate", url: "joychromium://newtab" }); + // A script run in a page still on its way in may never answer, so the page settles first. + await expect.poll(async () => { const tab = await active(); return tab?.url === "joychromium://newtab" && !tab.isLoading; }, { timeout: 30_000 }).toBe(true); + // "Backfill" is a favorite the app never loaded: its icon is fetched once, in the background, and then drawn. + await expect.poll(() => pageEval(`[...document.querySelectorAll('#sites .card')].some((card) => + card.textContent.includes('Backfill') && [...card.querySelectorAll('img')].some((img) => img.naturalWidth > 0))`), { timeout: 30_000 }).toBe(true); + expect(existsSync(join(data, "favicons", "backfill.example.png"))).toBe(true); + expect(await pageEval("document.querySelectorAll('#sites .card').length")).toBe(1); +}); diff --git a/app/tests/unit/favicons.test.ts b/app/tests/unit/favicons.test.ts new file mode 100644 index 0000000..2caf5bb --- /dev/null +++ b/app/tests/unit/favicons.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FAVICON_MAX_BYTES, FaviconStore, faviconHost, pickFavicon, readFavicon, sniffImage } from "../../src/main/favicons"; + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); +const ICO = Buffer.from([0, 0, 1, 0, 1, 0, 16, 16]); +const SVG = Buffer.from(''); +const respond = (body: Uint8Array | string, status = 200, headers: Record = {}) => async () => new Response(body as BodyInit, { status, headers }); + +describe("favicon host and choice", () => { + it("accepts plain web hosts only", () => { + expect(faviconHost("https://www.YouTube.com/watch?v=1")).toBe("www.youtube.com"); + expect(faviconHost("http://localhost:3000/x")).toBe("localhost"); + expect(faviconHost("joychromium://newtab")).toBeUndefined(); + expect(faviconHost("https://[::1]/")).toBeUndefined(); + expect(faviconHost("not a url")).toBeUndefined(); + expect(faviconHost(undefined)).toBeUndefined(); + }); + + it("prefers a web icon over a data one", () => { + expect(pickFavicon(["data:image/png;base64,AAAA", "https://a.example/i.png"])).toBe("https://a.example/i.png"); + expect(pickFavicon(["joychromium://logo.png", "data:image/png;base64,AAAA"])).toBe("data:image/png;base64,AAAA"); + expect(pickFavicon(["joychromium://logo.png"])).toBeUndefined(); + }); +}); + +describe("reading a favicon", () => { + it("trusts the bytes, not the header", async () => { + expect(sniffImage(PNG)).toBe("image/png"); + expect(sniffImage(ICO)).toBe("image/x-icon"); + expect(sniffImage(SVG)).toBe("image/svg+xml"); + expect(sniffImage(Buffer.from(""))).toBeUndefined(); + const icon = await readFavicon("https://a.example/f.ico", respond(PNG, 200, { "content-type": "text/html" })); + expect(icon?.mime).toBe("image/png"); + }); + + it("refuses what is not an image, is too big, or fails", async () => { + expect(await readFavicon("https://a.example/f", respond(""))).toBeUndefined(); + expect(await readFavicon("https://a.example/f", respond(PNG, 404))).toBeUndefined(); + expect(await readFavicon("https://a.example/f", respond(Buffer.concat([PNG, Buffer.alloc(FAVICON_MAX_BYTES)])))).toBeUndefined(); + expect(await readFavicon("https://a.example/f", async () => { throw new Error("offline"); })).toBeUndefined(); + expect(await readFavicon("file:///C:/x.png", respond(PNG))).toBeUndefined(); + }); + + it("decodes a data icon and upgrades a plain http one", async () => { + expect((await readFavicon(`data:image/png;base64,${PNG.toString("base64")}`, respond("")))?.mime).toBe("image/png"); + expect((await readFavicon(`data:image/svg+xml,${encodeURIComponent(SVG.toString())}`, respond("")))?.mime).toBe("image/svg+xml"); + let asked = ""; + await readFavicon("http://a.example/f.png", async (url) => { asked = url; return new Response(PNG); }); + expect(asked).toBe("https://a.example/f.png"); + }); +}); + +describe("favicon store", () => { + const temp = () => mkdtempSync(join(tmpdir(), "joy-fav-")); + + it("keeps an icon on disk and finds it again in a new run", () => { + const dir = temp(); + try { + const store = new FaviconStore(join(dir, "favicons")); + expect(store.urlFor("a.example")).toBeUndefined(); + store.put("a.example", { mime: "image/png", bytes: PNG }, true); + expect(store.urlFor("a.example")).toMatch(/^joychromium:\/\/favicon\/\?host=a\.example&v=\d+&k=[0-9a-f]{32}$/); + const again = new FaviconStore(join(dir, "favicons")); + expect(again.has("a.example")).toBe(true); + expect(again.get("a.example")).toEqual({ mime: "image/png", bytes: PNG }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("changes the address when the icon changes, so a shown icon refreshes", () => { + const dir = temp(); + try { + const store = new FaviconStore(join(dir, "f")); + store.put("a.example", { mime: "image/png", bytes: PNG }, true); + const first = store.urlFor("a.example"); + store.put("a.example", { mime: "image/x-icon", bytes: ICO }, true); + expect(store.urlFor("a.example")).not.toBe(first); + // One file per host: the old format is gone. + expect(readdirSync(join(dir, "f"))).toEqual(["a.example.ico"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps a private tab's icon in memory only", () => { + const dir = temp(); + try { + const store = new FaviconStore(join(dir, "f")); + store.put("secret.example", { mime: "image/png", bytes: PNG }, false); + expect(store.get("secret.example")?.mime).toBe("image/png"); + expect(existsSync(join(dir, "f"))).toBe(false); + expect(new FaviconStore(join(dir, "f")).has("secret.example")).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("drops the oldest icons beyond its limit and clears everything on request", () => { + const dir = temp(); + try { + const store = new FaviconStore(join(dir, "f"), () => undefined, 2); + for (const host of ["a.example", "b.example", "c.example"]) store.put(host, { mime: "image/png", bytes: PNG }, true); + expect(store.has("a.example")).toBe(false); + expect(readdirSync(join(dir, "f")).sort()).toEqual(["b.example.png", "c.example.png"]); + store.clear(); + expect(store.has("b.example")).toBe(false); + expect(existsSync(join(dir, "f"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});