Imported from bnfy/blanc (
AGENTS.md). Install upstream withnpx skills add bnfy/blanc. Copyright stays with the author.
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Mail/Suite licensing (October 13, 2026 owner correction): Customers must
keep the exact Browser key across monthly ↔ annual billing changes, including
existing Patron customers. Keep one key per app. The current separate Monthly
and Annual benefits do not satisfy this; do not replace a benefit in a way that
revokes existing keys or open Mail/Suite checkout before continuity is tested.
See docs/polar-mail-suite-sandbox.md for purchase evidence and migration gates. The owner subsequently confirmed both production Monthly subscriptions are
theirs and approved replacing only those keys. Production now retains
ed1cce8c as the shared Browser benefit; Kiren's complete key is unchanged.
Future billing-period changes must keep that shared key. Do not delete retired
benefits while public builds still reference them or open checkout before the
remaining acceptance/release gates pass.
What this is
Blanc — a minimal Electron browser shell: custom-drawn "Island" chrome (a single floating command pill replacing the traditional tab strip + toolbar, per the Bowser Design System's Island Chrome handoff) with ad/tracker blocking wired in at the network layer, independent of Chrome's extension store and Manifest V3's declarativeNetRequest limits. Plus favorites, history, downloads, settings, slash commands, a Quick Switcher, private tabs, an explicit permission policy, and packaging config.
The app was renamed from "Bowser" to Blanc in July 2026 — its former name was inseparable from Nintendo's Mario villain, which didn't fit a serious indie browser's ambitions. build.appId in package.json deliberately stayed me.bnfy.bowser through the rename (only name/productName changed) to preserve macOS Gatekeeper/notarization identity and the auto-update chain for existing installs — don't "clean up" that mismatch, it's intentional. The design system's project name ("Bowser Design System", referenced throughout this file) is a separate Codex Design project the user maintains and wasn't renamed alongside the app; leave those references as-is.
Current public baseline (Oct 16, 2026): v1.35.0 (Mac build 1350). Published from 95e1c2c1adb26bc223dfd8a4f98246984d43bd89 (PR #852) after the full press gate (3,253 unit tests, 324 desktop scenarios and 2,021 steps), notarized Apple Silicon and Intel builds, timestamped exact-publisher Windows signing, Linux packaged checks, complete authenticated manifest, SBOM and provenance. A fresh download of all 18 public assets passed digest, manifest (17 artifacts) and pinned-identity Sigstore verification; both public Mac apps passed Gatekeeper, strict deep codesign, the Developer ID and stapled-ticket checks and report build 1350. Native release run 38950910662 and exact-tag public run 38951898219 (including the Intel DMG on a native Intel runner) passed on their first attempts, and the release itself ran once, without the Sigstore verification-code fallback. 1.35.0 moves to official Electron 44.8.0 (Chromium 152.0.7977.167); uBO's reviewed runtime record (src/main/ublock-platforms.json) moved with it after the real blocking and tools suite passed on all four enabled platforms in hosted CI. It adds custom tab names (Rename Tab…, /rename), Open Link in Glance and /glance, Glance commands that follow the focused pane with header Back and Reload, Back/Forward shortcuts, Instrument Sans UI text, the Blanc White light palette and the vector Sunrise mark, and fixes the built-in PDF viewer (empty since v1.20.0), Favorites import from signed-in Chrome profiles, uBO letting requests through while it reloads its filter lists, shortened slash commands and a set of Glance bugs. Before release the owner chose to run the hand checks merged PRs asked for: finished screen shares passed on macOS, Windows and Linux, the window share on macOS, Instrument Sans on Windows and Linux, the Dock icon in Light and Dark and the Windows icon; the Windows window share was not checked (a Windows Firewall prompt took focus), and no real signed-in Chrome was tried for the account-bookmarks import. Staged macOS and Linux update rehearsals passed on the Electron 44.8.0 candidate. 1Password fill is enabled on Windows and Linux too; it shipped unannounced, and on October 16, 2026 the owner chose to announce it on the website within the VM evidence and waiver in docs/1password-integration.md (not on Windows on Arm). Don't describe it as unannounced or Mac-only. Optional full uBO 1.75.0 remains available on official Electron 44.8.0 for Apple Silicon, native Intel Mac, Windows x64 and Linux x64; Blanc Blocker remains the default and protects private tabs; Rosetta is excluded. The owner confirmed the adjacent public v1.34.0 → v1.35.0 updates on macOS and on Windows on October 16, through Restart Now; the Windows updater log shows the relaunched app checking from 1.35.0. blanc.day was then deployed from 06621b26 with the homepage redesign and Start Page captures retaken from the public 1.35.0 app (#856). Evidence is in docs/release-incidents/2026-10-16-v1.35.0.md.
Open-source baseline (Aug 30, 2026): MIT is the deliberate direction.
Blanc is open source, not merely source-available. Commit b49261a adopted the
MIT License for Bananify Creative-owned software, documentation, and media and
aligned the repository, FAQ, and Terms; the canonical site was deployed from
that exact commit. Do not reintroduce UNLICENSED, "source-available, not open
source", or language forbidding modification or redistribution unless the owner
explicitly reverses this decision. Open source is part of Blanc's trust,
adoption, auditability, and grant strategy. Patron should be framed as support
for Blanc and the best official experience, not as a source-code restriction.
The grant is intentionally bounded. The Blanc and Bananify Creative names,
logos, and other identity assets listed in ASSET-LICENSE.md remain reserved;
MIT does not grant trademark rights. Upstream files and third-party material
retain their own terms, recorded in THIRD-PARTY-NOTICES.md and the packaged
src/THIRD_PARTY_NOTICES.txt, including EasyList/EasyPrivacy, 1Password, Inter,
JetBrains Mono, and Lucide. Never describe the whole repository as blanket MIT
or remove those carve-outs. Public v1.11.0 is the first packaged release under
this licensing baseline. It carries LICENSE, both notice files,
ASSET-LICENSE.md, both OFL texts, and the pinned EasyList/EasyPrivacy sources
inside app.asar, as required by package.json and the packaged compliance
gate.
Launch Week is live (Sep 17, 2026): Product Hunt and Reddit shipped, Show HN is cancelled. Publishing v1.17.0 had invalidated the v1.16.2 launch freeze, release-bound approval, cleared soak, and the original September 14–17 sequence. The owner started Launch Week on Thursday, September 17 and ran two channels that day: Product Hunt (the site carries its launch banner, PR #378) and Reddit. Show HN is cancelled — do not schedule, draft, or post it. Do not re-run a channel that already shipped. Two preconditions were never re-established for subsequent public baselines and still are not: there is no launch-freeze-start row after the superseded v1.16.2 row of September 13, and no fresh 48-hour soak. The launch copy pack and growth plan also still carry their paused, not publishable markings, so their text no longer matches what shipped. Before any further channel, launch asset, or capture goes out, refresh the plan and release-bound package against the current public baseline above and obtain explicit owner approval of the finished creative and exact captions. Do not shorten or silently waive any updater, evidence, media, freeze, or soak gate to recover calendar time.
Owner review before social publication (September 11, 2026): Always show the finished graphic/video and exact caption to the owner, then wait for explicit approval of that final content before uploading, scheduling, or publishing. A request to create/share a campaign or implement its plan is not approval of unseen final creative. For usage-milestone marketing, keep the message general; do not disclose specific usage numbers in graphics, captions, alt text, or video unless the owner explicitly reverses this instruction.
Current marketing identity (September 12, 2026 owner correction): Use only the Sunrise mark and Sunrise theme for new creative. The old B letterform is retired even though assets/blanc-mark.svg remains in the repository. Use the original gold site/public/sunrise-hero-mark.png, warm ivory/gold treatment and the current rules in docs/brand-usage.md. Verify the actual review page loads its images and plays its videos before asking for approval.
Domain (Oct 13, 2026): blanc.day is Blanc's umbrella domain. The website, Blanc Mail's feed and licence API, the browser's links and the support address (support@blanc.day) moved there from blancbrowser.com, per docs/superpowers/specs/2026-10-13-blanc-day-domain-design.md. blancbrowser.com and its www. host 301 path for path through cloudflare/blancbrowser-redirects, and the domain stays registered for good; /dl/* on both domains is still answered by blanc-ping. These keep the old name on purpose, like build.appId: the tab relay tabs.blancbrowser.com (shipped browsers refuse redirects on it), the Firefox add-on ID tab-import@blancbrowser.com, the newsletter sender updates.blancbrowser.com, the Pages project blancbrowser, Worker names, social handles, and records of past work (release incidents and notes, dated ledgers, releases.json, posted marketing, grant applications). test/unit/site-domain.test.js keeps the old origin out of the site, its Workers, the browser and the current docs.
Marketing claims must be release-backed. Before writing, approving, or publishing any article, social post, ad, demo caption, outreach message, comparison, or image text about Blanc, follow docs/marketing-claims.md. Verify shipped capability against the current public release tag and its release evidence, not merely the working tree, a design spec, a plan, a mockup, or aspirational copy. Treat external-product claims the same way: use a current first-party source and distinguish sourced fact from inference. In particular, Blanc does not understand assignments, detect semantic task boundaries, automatically organize tabs by meaning, or provide an AI/agent browser; Named Groups and Blanc Spaces are user-directed features. Do not publish while any material claim is unsupported or ambiguous.
Platform direction (decided Oct 5, 2026): Blanc stays on Electron. The owner wanted full Chrome Web Store support. Electron does not provide it (its docs call full Chrome extension compatibility a non-goal), and getting it realistically means maintaining a Chromium fork (the Brave/Vivaldi model). On October 5, 2026 the owner declined Decision 1 of docs/platform-migration-electron-to-chromium-2026-10-04.md: a fork's build and rebase cadence (a near-full rebuild per platform every four-weekly Chromium major, plus prompt security updates) is not sustainable for one maintainer on one MacBook Pro. The fork spike is not planned and the extension demand check was dropped; do not start either, do not start fork work, and do not re-add a general extension runtime. Revisit only if Blanc gains dedicated browser-core staffing, if a fork release cycle is shown to fit in a few days a month, or if Electron stops loading Manifest V2. The Phase 0 bridge contract and test vectors (browser-api/) remain in use as Electron guards. The October 3 Windows reliability candidate restores safe Chrome Web Store page viewing after verifying official Electron 44.5.1 and the upstream crash fix; browsing Store pages does not enable extension installation. Blanc stays Chromium-based; WebKit-native builds are out. The Manifest V2 uBlock Origin provider shipped in v1.27.0 through PR #490 and is to be maintained for as long as Electron loads Manifest V2. If uBO is unavailable in a running build, startup returns to Blanc Blocker with a notice and preserves uBO configuration. Runtime crashes or decision timeouts of an available uBO stay fail closed: Blanc restarts uBO automatically (at most three times per profile in 10 minutes, each episode bounded to 30 s, holding requests meanwhile), then offers manual recovery; no provider is substituted. Future uBO/runtime updates still require source/license, platform-acceptance and CodeQL review.
Standing messaging preference (October 4, 2026 owner direction): Be explicit throughout the website and all other Blanc communications, including social posts, email, release notes, support, demos and product copy. Assume readers are new to Blanc: name the feature, explain what it does and connect it to a concrete benefit. Do not substitute flowery, ambiguous language for that explanation or expect screenshots, branded feature names or prior knowledge to supply it. Prefer “Choose your Start Page layout” and “View two tabs with Glance” to “Start with a view that suits you.” Keep the approved tagline “A little less browser.” Follow the explicit feature and benefit rules in docs/marketing-claims.md; this preference applies beyond the current website redesign.
Commands
npm install
npm start # run in dev (electron .)
npm run dist # installable build: macOS dmg/zip, Windows NSIS, Linux AppImage
npm run dist:dir # quick unpacked build in dist/, no installer
npm run release # scripts/release.sh: clean, build, sign, publish to GitHub Releases
After a release, npm run site:deploy is the production Cloudflare Pages
command. It deliberately includes --branch=main because the required clean
release checkout is detached at origin/main; without that flag Wrangler
creates a HEAD preview and leaves the live site (blanc.day) unchanged. A successful
post-release deployment is not complete until wrangler pages deployment list --project-name=blancbrowser shows the expected source SHA as Production on
branch main and the canonical changelog shows the new version.
Quick website update protocol (mandatory after the September 17 Product Hunt banner delay): Treat small site-only changes as a short protected-branch delivery, not as a release workflow. Fetch origin/main before editing and create the working branch from that exact tip; never discover a stale detached checkout only after the change is finished. Complete the final copy, responsive preview, and npm run site:build before asking for owner approval. Once approved, commit and push immediately, open the PR at once so required checks start, and use the normal protected merge as soon as they pass; do not attempt a direct push to main, enable unavailable auto-merge, or request an admin bypass. While checks run, preflight the cached Wrangler CLI and Cloudflare authentication without publishing. Commands that necessarily need GitHub, npm, or Cloudflare network access should use the approved outside-sandbox route on their first attempt instead of waiting for a predictable sandbox DNS failure. After merge, fetch and detach at the exact new origin/main, run npm run site:deploy once, verify the expected source SHA is Production on branch main, and check the canonical page directly. Keep user updates to blockers and completion evidence; do not narrate routine steps or repeat builds and checks without a concrete reason.
Run npm run lint for ESLint correctness checks on first-party desktop and Worker JavaScript (the substrate CI job runs it, and warnings fail); see docs/badge-lint-gate.md for the reviewed exceptions. Unit coverage runs with npm run test:unit; desktop acceptance, packaged migration/first-run, OAuth, DNS, and substrate checks have their own scripts in package.json. There is no generic npm test script. CI skips the Electron jobs on website-only PRs. In .github/workflows/parity-guards.yml, a changes job lets oauth-compatibility, modified-link-clicks, tab-handoff, workspace-regressions, favorites-import, quiet-tabs and browser-shortcuts skip when every changed path is under site/, test/site/ or docs/, or is a root *.md other than THIRD-PARTY-NOTICES.md/ASSET-LICENSE.md (both packaged into app.asar). The gate is a job-level if: rather than a workflow paths filter, so the required oauth-compatibility check still reports (as skipped) instead of blocking the PR. It fails closed: if changes fails, the jobs run. substrate, acceptance-wiring, the Windows/macOS unit jobs and CodeQL always run, because unit tests also guard the website claims ledgers. A new parity-guards job must be classified in test/unit/ci-website-only-gate.test.js, and on a website-only PR the skipped jobs appear under their bare names next to a passing changes.
UI text on the internal pages and in the Island/overlay chrome, including the Billboard clock, is Instrument Sans (SIL OFL 1.1, committed in src/renderer/pages/ with its license) at width 98 and +0.01em; counts and key caps stay Inter. The app ships no commercially licensed font. Blubird (Rajesh Rajput) is a website/wordmark face only: never add its files to the app or commit them (.gitignore blocks Blubird*.zip and src/renderer/pages/Blubird*).
npm run release bumps nothing itself — bump version in package.json and the monotonic macOS build.mac.bundleVersion build number (and consider the electron devDependency, since Chromium can't be swapped out of a running app) before running it. The distinct bundle build number keeps the native About panel meaningful (Version 1.17.0 (1170) rather than repeating 1.17.0) and must increase before every distributed macOS build. It shells out to scripts/release.sh, which authenticates via the gh CLI's own cached session (no GH_TOKEN needed locally), builds unpublished, then uses gh release create directly instead of electron-builder's own GitHub publisher — that publisher races its per-artifact upload tasks against each other on first publish for a tag and can leave a release missing latest-mac.yml or a blockmap; creating the release once with the complete macOS asset set avoids that. Released versions are immutable: the script checks local/remote tags and GitHub releases before building and fails if the version already exists; always bump to a new version, never overwrite assets. It also refuses dirty desktop release sources and wipes dist/ before building so artifacts correspond to committed code and stale files never linger.
Notarization: the build step is wrapped in op run --env-file=.env.1password --no-masking -- ..., pulling APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD from the 1Password item "Apple Notarization" (vault Dev, username/password fields) plus a literal APPLE_TEAM_ID — electron-builder's @electron/notarize integration auto-detects those three env vars. The hardened runtime keeps Electron's required JIT/unsigned-executable-memory allowances. Library validation remains enabled for Blanc, Renderer, GPU, and ordinary helpers; only Blanc Helper (Plugin).app carries the narrowly required exception for the user-invoked 1Password SDK bridge, enforced by scripts/sign-mac.js and scripts/after-sign-verify.js. release.sh requires op and fails closed if notarization cannot run — a public release never falls back to an unnotarized build. A signed-but-unnotarized build gets quarantined and blocked by Gatekeeper the moment it is downloaded (e.g. by electron-updater during an auto-update), which is exactly what broke a stale pre-rebrand install after the v0.3.0 release.
Release-operator protocol (mandatory after the v1.4.0 and v1.5.1 incidents): release.sh supports exactly two interactive modes. Native Terminal.app is the default (BLANC_RELEASE_OPERATOR=terminal). A Codex/Claude release may explicitly request BLANC_RELEASE_OPERATOR=agent, allocate a real PTY, and run the whole command outside the sandbox with the user's execution approval; never spoof TERM_PROGRAM. However, the Codex execution safety layer rejected that route during v1.5.1 even after explicit approval. Do not waste time retrying it: after the first policy rejection, explain the constraint once, obtain approval to use native Terminal, and launch the complete terminal-mode command there automatically so the user does not have to copy or type it. This is still a foreground, visible, interactive Terminal release—not a background wrapper. The script itself performs the proven fail-fast authentication command OP_BIOMETRIC_UNLOCK_ENABLED=true op signin before any build work, then uses forced desktop integration again for op run; agents must not invent a separate sign-in flow. If op offers to add an account manually, cancel. Approve only the identity matching the actual operator. A sandboxed gh auth status can falsely report an invalid token because it cannot reach the macOS keyring: retry outside the sandbox and use the connected GitHub app or working Git credential before asking the user to reauthenticate; never send the user through gh auth login unless those existing paths have all genuinely failed. The canonical commands and stop conditions are in docs/release-verification.md; read that section immediately before every release. Complete the fresh Safari Sigstore page immediately. While notarization runs, inspect process names only (ps ... comm), never full arguments: notarytool includes the app-specific password in argv. If a tag/draft already exists, never rerun the full immutable release or overwrite native assets; follow the failed-draft recovery in docs/release-verification.md. Dated records live in docs/release-incidents/, including 2026-08-17-v1.5.1.md.
Release completion and takeover protocol (mandatory after v1.15.0): Publishing the GitHub release is not the end of the release. Keep scripts/release.sh as the single owner of the signed macOS build, immutable tag and draft, native workflow dispatch, complete-manifest generation, fresh Safari Sigstore authorization, publication, logged-out asset checks, and changelog refresh; do not manually duplicate or race those stages. If taking over a live release from another agent, first reconcile the foreground Terminal process, tag SHA, GitHub release state and assets, and the exact native run. If the release is already public and SHA256SUMS.sigstore.json exists, the Sigstore authorization already succeeded—do not open another approval or rerun the release. Continue with independent post-publication evidence from a fresh temporary download: run the complete manifest validator, shasum -a 256 -c SHA256SUMS, and cosign verify-blob against the pinned identity and issuer. For macOS, mount the published DMG read-only, assess the contained Blanc.app with Gatekeeper, run strict deep codesign verification, confirm the expected Developer ID, and validate the ticket stapled to the app; the documented gate does not require a separate ticket on the DMG container. Then dispatch .github/workflows/prerelease-smoke.yml against the exact release tag with release_version set and wait for the hosted public-DMG checks (Apple Silicon and Intel, each on its native runner), public AppImage launch, and Linux and Windows DNS jobs to pass. The hosted macOS jobs independently check each published DMG's manifest digest, strict-deep signature, pinned Developer ID certificate, stapled ticket, architecture and native launch; every release ships both Apple Silicon (the default Mac build) and Intel, and release.sh refuses one architecture unless BLANC_MAC_ARCH_WAIVER records why. These hosted checks complement rather than replace the local Gatekeeper assessment. Only then update the public baselines, dated release incident, site/src/data/releases.json, the next release's default MIGRATION_BASE_VERSION, and its unit-test assertion from a clean isolated worktree; run the changelog check and full site/SEO build, commit, and push. Deploy with npm run site:deploy, verify Cloudflare reports that exact commit as Production on branch main, verify the canonical homepage and changelog, append that deployment evidence to the incident, and push the follow-up record. Leave adjacent macOS and Windows updater handoffs explicitly pending until each begins inside the immediately preceding public Blanc, discovers the public metadata, downloads the update, and invokes Restart Now; a direct installer launch is never a substitute.
Provisioning profile (Touch ID passkeys): the mac build embeds build/embedded.provisionprofile (wired via build.mac.provisioningProfile) because the WebAuthn keychain-access-groups entitlement is restricted — even for the app's own TeamID.BundleID group, AMFI SIGKILLs an app carrying it without a profile that authorizes it, and helpers can't carry a profile at all (so the inherit entitlements must never list the group). The profile is only honored when it embeds the exact certificate the build is signed with; the Apple account has several identically-named Developer ID Application certs, so the signer is made deterministic and verified twice. build.mac.identity pins the signing certificate by SHA-1 fingerprint (electron-builder substring-matches the qualifier against security find-identity lines, so a fingerprint selects exactly one identity — update the pin when the cert rotates). scripts/preflight-mac-signing.mjs (npm predist/predist:dir + release.sh) then fails the build early unless every certificate electron-builder could select — the pinned identity, a CSC_LINK p12 (parsed and validated, never skipped), or unpinned Developer ID identities — is embedded in the profile, and unless the profile is an unexpired, all-devices (ProvisionsAllDevices, never ProvisionedDevices), Platform: OSX Developer ID profile — a device-listed development-type profile would launch only on registered Macs, bricking every other install through auto-update, while end-user devices need nothing installed for the correct profile type (it ships inside the bundle). Finally scripts/after-sign-verify.js (build.afterSign) checks ground truth on every packaged app: the certificate that actually signed Blanc.app must be in the bundle's embedded profile (itself byte-identical to the repo's) with the WebAuthn entitlement present, throwing before any dmg/zip exists. Regenerate the profile on the developer portal whenever the signing cert rotates — and note every current Developer ID cert expires 2027-02-01 (G1 CA cap), so cert and profile rotation land together before then. test/unit/webauthn-packaging.test.js guards the static wiring cross-platform.
Windows & Linux: release.sh only builds macOS locally (it's a macOS dev machine), then dispatches .github/workflows/release-windows-linux.yml (gh workflow run ... -f tag=$TAG) and watches it to completion. That workflow builds NSIS on windows-latest and AppImage on ubuntu-latest and uploads both onto the same draft release; duplicate uploads fail. Dispatch or native-build failure leaves the release as an unpublished draft. Windows code signing is fail-closed: Azure Trusted Signing is tried first from the Azure secrets/variables; a complete CSC_LINK/CSC_KEY_PASSWORD pair is the traditional Authenticode fallback; absent or partial signing configuration fails the job. Both the installer and unpacked app must have a valid timestamped signature whose exact subject equals WINDOWS_EXPECTED_PUBLISHER, and windows-signature.json binds that observation to the installer SHA-256. Linux AppImages have no OS-level equivalent, so release-manifest authentication is their publisher check.
Before a platform-sensitive feature is released, agents can build private Windows and Linux candidates from the candidate branch with gh workflow run release-windows-linux.yml --ref <candidate-branch> -f mode=validation -f platform=all (or select one platform). Validation mode checks Windows Authenticode identity/timestamp and the ordinary platform fuses, packaged payloads, and required files, then uploads separate three-day GitHub Actions artifacts. It never creates or modifies a GitHub Release or uploads updater metadata to the public feed. Installing the Windows EXE proves install/launch and packaged behavior; launching it directly with /S while Blanc is open does not prove the electron-updater restart handoff. A handoff claim requires Blanc itself to discover the exact staged latest.yml, download the matching installer, and invoke Restart Now. Obtain the user's explicit affected-machine confirmation before merging, tagging, or running npm run release. If an owner elects to waive a remaining gate, state the missing evidence and risk first, obtain an explicit written approval, and record the waiver in the dated release incident; never silently check off an unperformed test. Normal releases dispatch with mode=release; scripts/release.sh supplies it explicitly.
The Azure branch requires every tenant/client/account/endpoint/profile/publisher value before it is attempted; partial Azure configuration may use the complete CSC fallback but may never reach unsigned output. Two electron-builder artifact-naming quirks matter: an AppImage for the runner's default architecture gets no -x86_64 suffix, and its block map is embedded rather than emitted separately. Re-check real dist/ output before changing patterns. The release gate also verifies Electron fuses in every packaged executable, emits a CycloneDX SBOM and SHA256SUMS, signs the complete checksum manifest with Sigstore, and requires exact OIDC identity/issuer verification before publishing. GitHub Actions are commit-SHA pinned; native artifacts receive GitHub provenance attestations.
The desktop ad blocker compiles only the bundled, hash-verified EasyList + EasyPrivacy snapshots in adblock/sources/; it makes no live filter-list request at startup and strips remote scriptlet resources. The compiled engine is cached in userData as adblock-engine.v<N>.bin; delete it to force a rebuild from the same bundled snapshot. Updating the lists is a reviewed release input: update pinned.json, run npm run adblock:build, and commit the generated snapshot/hash changes together.
The pinned blocker files are byte-verified, so .gitattributes must keep adblock/sources/*.txt, adblock/sources/pinned.json, and adblock/generated/*.json at eol=lf. Without that, a Windows checkout converts them to CRLF and every packaged launch correctly fails closed with Blocking could not start. The native Windows workflow must run npm run adblock:check before electron-builder; never remove or bypass that gate. Every electron-builder package also runs scripts/verify-packaged-adblock.js from the cross-platform afterPack hook, comparing the actual app.asar payload byte-for-byte with the verified release inputs before signing. Release workflows repeat that packaged-payload check explicitly for macOS, Windows, and Linux.
Auto-update (electron-updater against GitHub Releases) only runs in packaged builds — npm start skips it entirely. Releasing means bumping the electron devDependency (it tracks Chromium stable), version, and the monotonic macOS build.mac.bundleVersion in package.json, since Chromium itself can't be swapped out of a running app.
Architecture
Many BrowserWindows, each with many WebContentsViews. The window's own webContents renders src/renderer/index.html/styles.css through the exact-allowlist blanc-chrome:// scheme — the chrome strip: a slim (68px) band holding the resting island pill (back/forward, standalone-pin + active-section tab dots with a capped window-wide overflow, favicon, domain, the Blanc Blocker count, private chip, and a trailing reload/favorite/close-tab/downloads action cluster), window controls, and permission prompts. The whole resting pill is scaled up in one place with zoom on #islandPill (kept in lockstep with the --strip-h geometry token); each anonymous tab dot blooms into a disc showing that tab's favicon on hover/focus (.dot-peek, reusing the pill's favicon rendering) so you can tell which tab you're switching to before you click. Each tab is a separate WebContentsView added as a child of the window's contentView at y = chromeHeight; on macOS and Windows ordinary inactive tab views are hidden and detached, so switching tabs is remove-one/add-another rather than destroying anything. On Linux, Electron 44 stops drawing a tab view that is removed from its window and added back, so src/main/tab-view-lifecycle.js keeps inactive tab views hidden but window-owned; a view arriving from another window (Move Tab to New Window, a space opened elsewhere) draws again only after another child is added to that window, so the module adds a fresh 1×1 transparent View beneath it and removes it a tick later. Attach and detach tab views only through that module. The Playwright acceptance suite cannot see this class of bug (Playwright's focus emulation keeps every page drawing), so npm run test:tab-view-drawing:desktop launches Electron directly and runs in the quiet-tabs CI job (Linux under xvfb, and Windows). Managed uBO tool views on Windows/Linux stay hidden but attached through background reloads to preserve their native Aura focus root, and are removed after native destruction; Quiet Tabs and closed-tab expiry still control their renderer lifetime — but an idle background tab is a separate matter, and its renderer is deliberately discarded (see Quiet Tabs below). src/main/main.js owns all of this: the tabs Map + tabOrder array are the single source of truth, and both chrome renderers only ever reflect tabs:updated broadcasts.
Quiet Tabs. An idle background tab's renderer is deliberately discarded to reclaim its memory (~148 MB/tab measured on Electron 43), and rebuilt when the tab is activated again. Policy is pure and unit-tested in src/main/tab-sleep.js (sleepCandidates, trimSnapshot, TAB_SLEEP_DELAY_MS) — no require('electron') in that file. View construction and the whole per-tab listener set live in src/main/tab-view.js (createTabView, wireTabView, initTabView), which is also the single home of the private-session choice. main.js owns sleepTab/wakeTab, the 30 s sweep, and the wake generation (tab.wakeGeneration) that suppresses history and failure handling across every hop of a redirect chain. liveContents(tab) is the only correct liveness check: after webContents.close(), view.webContents can read back undefined. The main-process-only sleepSnapshots Map retains bounded navigation recovery data and must never move onto the tab record or cross IPC, disk persistence, sync, or crash reporting; snapshots may contain page state and POST bodies. During app shutdown, closeTab must never select and wake a surviving quiet tab while Electron tears down the native window. The dirty probe protects changed form controls across the frame tree and Chromium's unload objection. It deliberately does not treat non-empty sessionStorage as user work, because ordinary sites create site-owned keys on load and that blanket rule makes the feature inoperative. Instead, a storage-bearing tab keeps its WebContentsView in the snapshot Map, discards only its provably-exclusive renderer, and reloads that same WebContents so Chromium's browser-process sessionStorage namespace survives; any beforeunload listener or shared/uncertain renderer PID leaves it awake. session.json has an optional meta: [{title, favicon}] array parallel to urls, written into each v2 windows[] entry, never the five-key flat rollback mirror; version is 2. Restored tabs are born quiet and only the selected tab loads. Internals say asleep/tabSleep/sleepSnapshots; every user-visible and assistive string says quiet, except the fixed /sleep command. The device-local delay is Settings → General → Quiet inactive tabs (off|30m|1h|6h, default 1h, deliberately outside SYNCED_KEYS); Off stops future quieting and never wakes an existing quiet tab or drops its snapshot.
Reopen Closed Tab. Closing a tab records a per-window closed entry with three internal restore tiers: for ~30 s the closed tab's live WebContentsView is parked (same view re-attached on ⌘⇧T, no re-fetch, JS and POST state intact), then it degrades to a trimSnapshot() restore (back stack, scroll, group, pin, slot), with URL-only as the floor. Never expose tier jargon such as held in user-visible copy. Pure policy lives in src/main/closed-tabs.js (holdEligibility, sanitizeSnapshot, buildTabEntry/buildGroupEntry, expireHolds, expireEntries, projectEntries — no require('electron')); main.js owns parking (parkTabView), the held-state firewall (a parked page keeps executing, so it gets deny-all setWindowOpenHandler, main-frame navigation refusal, crash/destroy downgrade observers, and blanket permission denial via heldWebContents + setHeldRequesterCheck in permissions.js), restore (reopenEntry, the adoptView seam in createTab), and disposal. One entry per user action: closeGroup captures first and records a single group entry restored whole at its cluster index. Closed entries are the same class of secret as sleepSnapshots — snapshots and view refs never cross IPC/disk/sync; renderers see only {id,title,favicon,tabCount}. The ⌘L panel shows them in a Recently closed band styled like a group and placed among the groups; its place is a per-window closedSlot (number of groups above it) that src/main/closed-slot.js keeps through every group change and that persists with group order in session.json and Spaces. The band offers per-entry Forget and Clear all; either must immediately destroy a parked view. Entries also expire after one hour and remain capped at 25. Capture-anchored, prompt-pending, loading, quiet, and opener-family tabs are never held (Tier 1); private tabs are never recorded at all. Nothing is persisted, and on macOS dock-close the held view is destroyed while snapshot entries survive until their normal expiry.
Per-window state (workspace, overlay, shield popover, utility sheet, focus
reclaim, permission prompts) lives on a window-runtime record
(src/main/window-runtime-registry.js) — one per native browser window — resolved
via AsyncLocalStorage bindings and sender-derived IPC; the tabs Map stays
process-wide with per-tab runtimeId. session.json is versioned (v2 +
a v0 mirror of the focused window so a rollback to 1.0.x keeps tabs).
Local profiles. profiles.json defines the permanent Personal identity plus bounded named profiles. Personal keeps every shipped root JSON file and Electron's default session, so upgrades move no existing data. Named profiles store Favorites, history, download metadata, and remembered permissions under profiles/<opaque-id>/; their normal tabs use persist:blanc-profile-<id> and private tabs use private-browsing-<id>. Settings, Supporter, telemetry, and network/presentation preferences remain device-level. Profile Sync and remote-tab presentation are Personal-only. Deletion is exact-name-confirmed and crash-resumable through the device-level profile-deletions.json marker; it closes the profile's windows, clears its Electron sessions, drops cached store/download state before removing its directory, and removes every saved workspace. Downloaded files remain on disk.
The island's expanded states live in a separate always-on-top overlay WebContentsView (src/renderer/overlay.html/overlay.js, transparent via setBackgroundColor('#00000000')), attached to the window's contentView only while something is showing, so panels float over the web content instead of growing the strip and shifting it down. Main owns the mode (overlayMode): 'panel' — the command bar expanded in place at the pill's anchor; 'palette' — the same panel summoned by ⌘L, on the same anchor as 'panel' but over a scrim (it used to sit 120px lower, which read as a detached window rather than the pill opening); 'find' — the find capsule, whose overlay bounds are kept tight around the capsule so the rest of the page stays clickable; 'spaces' — the Spaces menu alone, opened from the Island's space mark (the pill's leading chip showing the current space's symbol and name; calculateSpacesBounds sizes the view around the 300px menu with room for its shadow and opening scale, hanging it just below the Island so it never covers it; dismissed like the shield popover, focus returning to the mark). From the ⌘L panel the same menu hangs below the Island from the footer's icon-only Spaces button. showOverlay/hideOverlay in main are the only mutators; the overlay renderer just applies overlay:show/overlay:hide. Guards that matter: a tab view attached while the overlay is open must be re-stacked below it (re-addChildView(overlayView) in setActiveTab), Escape dismisses via main's before-input-event, and overlay blur dismisses panel/palette (not find) — but is ignored while a blank-tab focus reclaim is pending, or the reclaim dance re-opens/closes in a loop. The panel's list area shows the tab switcher at rest, slash commands when the input starts with /, and a six-row blend of the Quick Switcher (loose substring/in-order matching across open tabs, favorites, history) plus best-effort autocomplete from the selected search engine while typing anything else. Provider requests are debounced, main-process-only, cookie-free, bounded, and never made for URL-like/sensitive-looking/pasted text or private tabs; the device-local searchSuggestions toggle can disable them entirely, and stale/failing responses silently disappear.
Private tabs (/private, ⌘⇧N, private on main's tab model): never recorded to history (history.addVisit/updateTitle guarded per-tab), excluded from session persistence, sync, and reopen-closed-tab, inherited by window.open/context-menu children, and opened to blanc://newtab/?private=1. All private tabs in one app run share the non-persistent private-browsing Electron session, separate from regular tabs; it is discarded at quit. Completed private-download metadata is kept only in memory, while the file the user saved remains on disk. Favorites still behave normally. While the active tab is private, both chrome documents set data-theme="private" on <html>; the pill gets a dashed border, hollow dots, and a "private" chip that closes the tab.
Tab groups (per the Island Tab Groups exploration in the Bowser Design System): main owns groups ({id, name, collapsed}, display order = cluster order) alongside tabs; each tab carries groupId (or null). Groups have names, not colors — lowercase mono labels. A group exists only while it holds tabs (pruneEmptyGroups); creation happens through /group <name> (find-or-create) or the inline "new group…" field in the per-row "group" chip's picker, and membership moves via that picker (→ name / → none), /ungroup, or /close-group. The picker's name field survives tabs:updated re-renders (value+focus restored across replaceChildren) — broadcasts arrive ~10/s while any tab loads. The pill renders standalone pins followed by the active section (named group, loose tabs, or pinned shelf), capped at 8 direct one-tab dots; a quiet window-wide +N counts every omitted tab and opens the panel. Pins take priority after the active tab, the two sets use spacing alone as a separator, and Quiet state never changes dot inclusion, order, styling, or accessible naming. The pill does not name the group — group identity lives in the ⌘L panel. Pinning is group-local: a grouped pin stays in that group and leads its rows/dots, while only ungrouped pins use the standalone pinned section in the ⌘L panel. Other groups also live in that panel behind per-group headers (click folds/unfolds; collapsed groups show an "N tabs tucked away" row). The Quick Switcher matches group names (ranked above tabs; picking one focuses the group), and ⌘1–9 change meaning when groups exist: nth cluster (group's first tab, unfolding it) instead of nth tab. A plain new tab (⌘T, panel row, /new) always launches ungrouped, regardless of the active tab's group — join a group afterward via the row's "group" chip picker. window.open/context-menu children are the exception: they still inherit the opener tab's group, since a link opened from a grouped tab is presumed to belong with it. New private tabs are never grouped. session.json persists groups plus a groupIds array parallel to urls; groups referenced only by private tabs stay out of the file.
The chrome documents only load their HTML/CSS once, at window creation (rt().window.loadURL(CHROME_INDEX_URL) + createOverlay() in createMainWindow()). Editing src/renderer/index.html, overlay.html, styles.css, or either chrome renderer requires relaunching the app to see the change — Cmd/Ctrl+R reloads the active tab's WebContentsView (which re-fetches blanc://.../pages.css fresh on every navigation), not the chrome. Don't rely on a plain reload to verify a chrome-level change; restart npm start.
blanc:// internal pages (newtab, bookmarks, history, downloads, settings) are served by src/main/pages.js over a privileged custom scheme instead of file://, so they get a real origin and ordinary web content can never link into local files. The handler resolves every request through path.basename() before joining it to PAGES_DIR (src/renderer/pages/) — it can only serve flat files directly in that one directory, no subdirectories and no ../ traversal. Any asset (pages.css, icon.svg, a font file) that pages/*.html needs must live flush inside src/renderer/pages/. The five utility pages (bookmarks, history, downloads, settings, shortcuts) never open as tabs — main routes them (via src/main/utility-pages.js's isUtilityUrl, the single classifier checked at openInternalPage, tab will-navigate, createTab, the window-open handler, and typed navigation) into the utility sheet: a transparent always-below-the-island WebContentsView whose page draws its own scrim + card (body.sheet in pages.css + the shared sheet.js). Esc, scrim click, and the nav ✕ (strictly-guarded pages:surface:close) dismiss; tab switches/creation and summoning ⌘L dismiss; outbound clicks open real tabs; session restore filters stale utility tabs through session-restore.js's zipped filter. blanc://mahjong (mahjong solitaire) is a KNOWN page served flat like the others but deliberately NOT a utility page — each of the four start-page footers opens it as an ordinary managed tab. Its engine/page split is mahjong-engine.js (pure, unit-tested) + mahjong.js (DOM, timer, localStorage best). The newtab page offers four layouts (Ledger, Billboard, Shelf, Tally — newtabLayout) inside one shared frame: an in-flow header (Sunrise mark + date), one centered content area, and a fixed footer (left: Ledger's weekly blocked count, version, the moving-in checklist as a "Finish setup" pill whose popover holds the list and whose × hides it for good, and an "Upgrade to Blanc Patron" pill whose × hides it for 90 days via device-local patronCalloutDismissedAt; centre: a Customize popover holding the layout picker and dynamic wallpaper; right: Mahjong, ⌘L hint). Ledger is a centered spread under one "Where to?" line: favorites left; tab groups ("Pick up where you left off" — clicking one focuses that group) and other devices right. Design: docs/superpowers/specs/2026-10-05-start-page-layouts-polish-design.md. There's deliberately no mascot on this page — an earlier version had an animated pixel-art dog sprite here, retired along with the "Bowser" name as part of the July 2026 rebrand; don't reintroduce one without being asked. Live group state and the blocked counter cross from main.js via the startPage hooks passed to setupPages (pages:start:data / pages:start:focus-group).
Mahjong sound cues. mahjong-sound.js synthesizes the game's quiet Web Audio cues locally and lazily after a user gesture; do not replace it with remote or bundled media without a CSP/privacy review. mahjong.js owns the accessible sound toggle and stores its device-local preference in the Mahjong origin's localStorage, which stays ephemeral in private tabs.
Security posture: the chrome window, the overlay view, and every tab's WebContentsView run contextIsolation: true, nodeIntegration: false, sandbox: true. Three separate preload scripts, deliberately asymmetric:
src/main/preload.js— the richbrowserAPIbridge (tab lifecycle, window controls, island data + command actions). Attached only to the chrome window and the overlay view.src/main/tab-preload.js— a host-specificbowserPagesbridge: eachblanc:page receives only its own methods, and navigation re-evaluates the surface. Main independently maps everypages:*channel to the exact expected host, owned WebContents/session/surface, and main frame throughpages-ipc-trust.js.bowserPageskept its pre-rebrand internal name.src/main/chrome-compat-preload.js— an unprivileged session-widewindow.chrome.app/csi/loadTimescompatibility shim. It is registered throughsession.registerPreloadScriptso both ordinary tabs and Chromium-adopted OAuth children receive it without replacing the child'swebPreferences.preload(which severswindow.opener). It exposes no IPC or application data.
IPC channel namespaces: tabs:* (tab lifecycle, via browserAPI), pages:* (internal pages' guarded data API, via bowserPages), window:* (minimize/maximize/close), chrome:* (strip/overlay → main: layout height, open-island/open-find, and island data + command actions — history-list/favorites-list/history-clear/adblock-toggle/adblock-exempt-active/cycle-theme), overlay:* (main ↔ overlay show/hide/close).
Address bar focus is intentionally intricate (tabsWantingAddressBarFocus, reclaimAddressBarFocus() in main.js) because a blank new tab needs the island panel's address input focused rather than its empty page content, and WebContentsView focus can settle asynchronously after Electron's own focus/navigation callbacks — hence the reassert-on-next-tick pattern rather than a single focus call. focusAddressBar() now means "show the overlay panel and focus its input".
Theming: CSS custom properties in :root (+ a prefers-color-scheme: dark override, + an explicit :root[data-theme="private"] scope), with the same token names duplicated across styles.css (chrome strip and overlay — one shared stylesheet) and src/renderer/pages/pages.css (internal pages) — separate files by necessity given the flat-serving constraint above, kept in sync by hand, not shared/imported. nativeTheme.themeSource is driven by Settings → Appearance (also cycled by /theme), so system/light/dark propagates to chrome, internal pages, and web content together, no restart. Warm surfaces (utility sheets today; see docs/brand-usage.md → Desktop Sunrise surfaces) remap these semantic tokens to the themed --sunrise-* palette inside a scope class such as body.sheet, the same technique the Start Page's .ledger-body uses; the Island and its frame stay neutral.
Interface language (F44; copy/messages/, src/main/i18n.js, src/renderer/pages/i18n.js): every Blanc-authored string lives in copy/messages/en.json under a stable key; translations (de, es, fr, pt-BR and it, each selectable since it reached 100% coverage; a new language stays hidden until then) carry a per-entry source hash that only npm run copy:ack -- <locale> <keys> advances — copy:build/copy:check never do, so a stale translation cannot be cleared by building. A PR that changes English updates every language in the same PR. copy:build generates src/renderer/pages/strings.<locale>.js (never hand-edit), which every blanc-chrome:// and blanc:// document loads as strings.js (the handlers map that name to the active language and append the formatting locale) followed by i18n.js; main requires the same files. It also generates src/main/ublock-strings.json (only the ublock.* keys, every language): uBlock Origin's pages cannot load the catalog, so the uBO package adaptation ships that text beside a formatter-only i18n.js as hashed host inputs, and changing a ublock.* message needs node scripts/build-ublock-adaptation.cjs --write. Static HTML keeps its English inline with data-i18n* keys; JS calls blancI18n.t(key, params); never innerHTML a message. uiLanguage (system or a code) is device-local, written only by settings.setUiLanguage() through changeUiLanguage() (never pages:settings:set), resolved once at whenReady and frozen, so a change applies after a relaunch; a stored unavailable language renders English. Accept-Language, search suggestions, Chromium's in-page widgets and spellcheck stay on the OS language; Blanc never passes --lang. copy/i18n-scope.json marks each string-bearing file pending or guarded; guarded files fail copy:check on hard-coded English, and npm run test:i18n:desktop (pseudo-locale en-XA) is the runtime check.
CSP is declared per-file, not centralized: index.html, overlay.html, and each pages/*.html file carries its own <meta http-equiv="Content-Security-Policy">. Chrome/internal-page fonts and assets are bundled locally; their CSPs do not allow remote images or fonts. Adding any external resource requires a narrow per-document policy change and a privacy/data-flow review.
Ad blocking (src/main/adblock.js): a single @ghostery/adblocker-electron engine is built from the bundled verified snapshot and attached to both regular and private sessions. Request blocking, declarative cosmetic CSS, and bundled page-context scriptlets remain; adblock-scriptlets.js isolates each scriptlet's declarations to prevent cross-scriptlet helper corruption. Toggling in Settings calls enableBlockingInSession/disableBlockingInSession at runtime; blocked-request counts are per-tab and coalesced (~10 broadcasts/s).
Dark websites (F42; src/main/dark-websites*.js, dark-reader/): off by default and device-local. While Blanc is dark, http(s) main frames are darkened from first paint by the pinned, unmodified Dark Reader API build (dark-reader/upstream/, hash-pinned in dark-reader/pinned.json, MIT). dark-reader/build.mjs generates src/main/dark-websites-preload.js from the two templates in dark-reader/; never hand-edit it, and run npm run darkreader:build after changing a template, the pin or the constants in dark-websites.js (darkreader:check runs in substrate:check). The session preload asks main synchronously whether to darken and runs the engine only when the answer is yes, in isolated world 1002 with its own CSP (the page's CSP would otherwise block its styles; world 999 is shared by every Blanc preload and must not get that exemption). That world's only capability is dark-websites:fetch-css, which main serves only after the tab's own blocker (Blanc Blocker or uBO) allows the stylesheet, from an in-memory session with no cookies, redirects or non-public addresses, text/css only, and size, time and rate limits (dark-websites-fetch.js). /dark-site and the shield popover's Dark website switch (shieldPopover.darkSite, from siteState() in the service) flip the current site; in private tabs that choice is memory-only and never written to settings. Iframes stay as drawn because session preloads reach only main frames. Updating Dark Reader is a reviewed release input, like the blocker lists.
Capture indicator (src/main/capture-state.js, capture-preload.js, capture-mainworld.js): a window-wide pill chip + popover showing live mic/camera use. The only off→on signal is the main-process permission grant (setCaptureGrantObserver in permissions.js); page-world instrumentation refines toward off via per-frame snapshots/settlements over capture:report — display state, not security truth (macOS's system indicator is the malicious-page backstop; a defeated patch fails stuck-on, never silently-off). Main-frame-only by spike-proven necessity (session preloads don't reach iframes without nodeIntegrationInSubFrames, which stays off — the main-world source therefore lives inline in capture-preload.js, and capture-mainworld.js re-exports the shipped bytes for the vm tests). Auxiliary window.open popups are capture surfaces in a process-wide registry that survives macOS window close/reopen. Capturing tabs are excluded from Quiet Tabs (tab.capturing, re-checked inside sleepTab's final guard). Never persisted or synced; only the tabs:updated projection (capture, captureChip, capturePopover) leaves main. Capture IPC crosses unbound native boundaries, so its broadcasts rebind the window runtime (scheduleCaptureBroadcast) — same rule as downloads.js. Design: docs/superpowers/specs/2026-08-13-capture-indicator-design.md. The same preload also carries the truthful permissions.query shim: main-frame mic/camera queries answer granted/denied/prompt from Blanc's stored decisions over a sender-validated bridge (capture:permission-query), so preflighting sites ask instead of declaring the device blocked — display truth only; Electron's strict check/request handlers are untouched, and any bridge failure falls back to the strict query. Never map undecided to true in setPermissionCheckHandler (reviewed P0, reverted). Spec: docs/superpowers/specs/2026-08-13-permissions-query-truthful-state.md.
Optional managed uBlock Origin; no general extension runtime or Blanc-owned password manager. The October 2 implementation request authorized full uBO as a regular-profile blocking provider. On October 3 the owner separately approved distribution on their authority and the protected merge/release across four verified desktop targets; that approval does not waive future source/license, platform or installed-candidate acceptance. It uses official, unmodified Electron, sandboxed Blanc views, pinned upstream executable resources, and a narrow host bridge. Private sessions keep Blanc Blocker. General extension-store support remains outside scope. Public v1.27.0 packages include the reviewed uBO payload on approved targets. Builds without distribution clearance still exclude it; signed uBO candidates require explicit opt-in and distribution clearance. Keep first-party MIT and reserved identity assets separate from upstream GPL terms. The production 1Password integration is narrower: off by default, device-local, explicit Fill gesture only, and dependent on the user's installed 1Password app/account. src/main/onepassword-broker.js is the sole SDK owner inside Electron's Plugin utility process; only that helper may disable macOS library validation. When several items match, main sees at most ten transient built-in usernames solely to distinguish the native picker; the broker binds that snapshot to the SDK item version before returning the selected credential for injection into the revalidated isolated-world target. These va
Truncated - read the full file at https://github.com/bnfy/blanc/blob/687e945b7140c25e8dcef789303fb1847bd15130/AGENTS.md.
