FireLeash Build Log
A running record of what shipped, how it works, and how it was verified. Newest first.
Originally built as MultiView — the earliest entries below predate the FireLeash rebrand, so the history reads straight through.
Every entry was screenshot- or measurement-verified in the packaged build before it counted as done.
Runtime: Electron 31 / Chromium 126 · Windows · portable single .exe (~70 MB) (early builds; panes are now real Firefox windows).
2026-07-20 — Public release
Shipped to the world — github.com/Divisionless/fireleash + fireleash.com
shipped
- What: the first public release. The repo was renamed MultiView → FireLeash (the old URL 301-redirects), the installer was published to GitHub Releases, and fireleash.com grew from a one-pager into a real product site with download, docs, and this build log.
- Clean for public: every file was reviewed before the push — no secrets and no personal data shipped. A personal workspace file and some internal planning notes were kept local; all user-visible branding was unified to FireLeash.
- Verified: the stable
releases/latest/download/FireLeash-Setup.exe URL returns the 79 MB installer (HTTP 200); the retired /multiview repo URL 301-redirects to /fireleash.
The full color system, published
shipped
- What: the site's design pane now documents the complete token set — the fire ramp, the warm-charcoal surfaces, and the ink tiers — each swatch named with its hex and role, straight from the design brief.
2026-07-19 — Hardening + install
Pre-release hardening — 20 bugs found, 19 fixed
shipped
- What: a deep pass over the shared-Firefox lifecycle before release, with every fix re-checked for regressions on the installed build.
- The heavy hitters: an orphaned shared Firefox from an unclean exit held a dead control port and silently broke the omnibox for every pane — now reaped on startup. Index-keyed launch cards weren't re-homed when panes renumber, so a "Retry" could quit a healthy pane — now cleared on every renumber. Plus four config-index desyncs on reorder, a dropped URL on rapid re-submit, and a silent config-save data-loss path.
- Verified: on the installed build — an unclean kill stranded the shared Firefox, and relaunch reaped the orphan and stood up a fresh process with a live control port, exactly one process. A clean quit reaps it too.
Proper install — NSIS, its own folders
shipped
- What: FireLeash installs via a per-user NSIS installer to
%LOCALAPPDATA%\Programs\FireLeash, with its own %APPDATA%\FireLeash config and %LOCALAPPDATA%\FireLeash profile store.
- Why: a config-write-while-still-running race had been reverting the workspace on close. Giving the app its own clean folders fixed the footing, and the legacy profile folders (~15 GB) were removed.
Lazy panes + split-mark new tabs
shipped
- What: a blank pane costs nothing — Firefox launches only when the pane first navigates. New tabs feature the FireLeash mark split across the empty panes, and each half drops out as its pane fills.
The brand — three fox heads
shipped
- What: the accepted mark — a leash of foxes, which is exactly three flaming fox heads — became the app icon, the greeting, the toolbar, and the field-manual hero. The last of the old blue-bubble art was retired.
2026-07-18 — The FireLeash pivot
One chrome — chrome-less Firefox + a FireLeash omnibox
shipped
- What: Firefox's own toolbar is hidden by default (via a guarded
userChrome tweak) so there is a single clean chrome; the FireLeash omnibox carries back / forward / reload / navigate. A ☰ button reveals Firefox's real toolbar on demand for bookmarks, extensions, and settings.
Unified omnibox drives the exact pane
shipped
- What: each shared-profile window reports its own native window handle over a local control channel (a
127.0.0.1 WebSocket plus an in-profile autoconfig script); the omnibox then drives the precise focused pane by that handle.
- Why it works: no automation flag is set, so Google and friends never hand the panes a bot-blocked session.
Retire Chromium — Firefox everywhere
shipped
- What: the built-in Chromium / BrowserView panes were dropped entirely. Every pane is now a real Firefox window; the Electron shell renders no web content of its own.
Shared login — import once, signed in everywhere
shipped
- What: the model that makes it usable. One Firefox process runs on one FireLeash-managed profile; N windows are forwarded into that same process with
-new-window, each identified by a before/after window-handle diff. A one-time import of your real Firefox profile (cookies, logins, extensions) means every pane is already signed in from the first launch.
Real Firefox windows as panes — owned-window embedding
shipped
- What: the core trick. A real top-level Firefox window is reparented as an owned child of the FireLeash frame via Win32 — owned top-level, not
WS_CHILD (which blanks the window). Resize the frame or drag a divider and the Firefox windows track it exactly.
- The trap: Firefox's launcher relaunches into a different pid that owns the window — identify ours by the profile-path token in its command line, never the spawned pid.
2026-07-17 — Dual-engine shell (real Firefox + real Chrome panes)
Real browsers as first-class panes
shipped
FireLeash can now host real Firefox and real Chrome windows as panes, mixed with Chromium BrowserView panes, in the actual layout engine (resize with dividers), both logged in. Opt-in per pane via site.engine: 'firefox'|'chrome'; non-engine panes are byte-identical to before. Code in dual-engine/*.cjs + DUAL-ENGINE-ROADMAP.md.
- Embedding = owned top-level windows, NOT
SetParent/WS_CHILD. Reparented cross-process browser windows blank whenever the shell loses focus/drags (DWM stops servicing a child's surface — survives every GPU/occlusion/software flag). Fix (komorebi/GlazeWM model): borderless top-level made an owned window via GWLP_HWNDPARENT, positioned in screen coords, tracked on shell move/resize/minimize. Keeps its own DWM surface → can't blank; GPU stays on. The "just add more GPU flags" path was tested and ruled out.
- Divider grab:
ENGINE_GUTTER=6 inset + hide engine panes during a drag (owned windows capture the mouse & occlude the placeholder).
- Chrome login ("beat the chrome crap"): Chrome 127+ exclusively-locks its live cookie DB (no non-admin copy) — "seed every open" is impossible without admin. But App-Bound decryption is free when the genuine chrome.exe relaunches same-user (key binds to app+machine+user, not the profile dir). Model: a persistent owned profile per pane (
%LOCALAPPDATA%\FireLeash\engine-profiles) that stays logged in; first run opportunistically inherits the default session iff Chrome is closed, else sign in once. Proven: signed in → restarted → still logged in.
- Firefox: no lock — seeds from the default profile every open, logs in cleanly.
- Verified in the packaged exe.
2026-07-17
Consent gate for browser-history suggestions (nothing read without an explicit yes)
shipped
- Why: the app must never read a user's browser history silently. Reading Firefox at startup without asking was wrong for a shipped tool.
- First-run prompt: a themed dark modal (
consent.html, matches the app — no native dialogs) explains exactly what it does ("read your local Firefox/Chrome/Edge/Brave history to autocomplete the address bar; nothing is uploaded"), with Yes / Later / No and a "Don't ask again" checkbox. Yes → suggestConsent:'granted'; No → 'denied'; Later → asks again next launch (or never, if "don't ask again" was ticked).
- Gate:
browserSuggestAllowed() = consent granted. Until then, suggestions come only from configured panes, FireLeash's own visits, and the generic popular-domain list — no external browser is touched. ensureFirefoxHistory() short-circuits to [] when not allowed.
- Settings → Suggestions tab: a master toggle to turn it on/off any time (immediate, via a dedicated
suggest:set-consent IPC — not gated behind the Workspaces Save). Turning it off drops the cached history. config:save carries the consent fields across so a stale settings form can't wipe them.
- Verified (CDP, fresh isolated profiles): first-run shows the prompt; before consent a Firefox-only query (
r/example) returns [] while the popular fallback (githu→github.com) still works; clicking Yes ungates (your real subreddits appear) and the window closes; granted persists and warms at startup with no re-prompt on relaunch; clicking No persists denied and stays gated. Consent files confirmed in the packaged asar.
Autosuggest now reads your real Firefox history + bookmarks
shipped
- Why: the first cut suggested from a hardcoded "popular domains" list + FireLeash's own tiny visit log. Your actual browsing lives in Firefox — so read that.
- Source: the default Firefox profile's
places.sqlite (resolved from profiles.ini, prefers the default-release install default). Firefox already ranks every URL by frecency (frequency × recency); we pull the top 3000 http(s) rows + all bookmarks into memory, refreshed at most every 10 min, warmed at startup so the first keystroke is instant. The live DB is copied before reading (Firefox holds it open). tens of thousands of URLs available.
- Runtime SQLite: Electron 31 = Node 20, no built-in SQLite → bundled sql.js (wasm),
asarUnpack'd so WebAssembly can load it from the packaged app.
- Ranking: configured panes (weight ×3) > Firefox history/bookmarks (×2, + frecency bonus, bookmarks +40 and shown with a ★) > FireLeash's own visits (×1.5) > popular-domain fallback. Top 8.
- Verified: dev-run CDP — "redd" → reddit.com + your real subreddits, "youtu" → youtube.com + a bookmarked ★ video, "wikipe" → articles you've read, "exampl" → configured example.com wins; dropdown renders the FF items. Packaged-build CDP (the honest test of wasm-from-
asar.unpacked): launched win-unpacked\fireleash.exe, addrSuggest('redd') returned reddit rows → runtime wasm path confirmed.
- Privacy: entirely local — reads a local file on your own machine to power your address bar; nothing leaves the box.
Address-bar autosuggest (domains + visited sites)
shipped
- What: typing in any address bar drops a suggestion panel: your configured panes across all workspaces, every URL you've actually visited in FireLeash, and ~30 popular domains — ranked host-prefix > host-contains > url-contains, weighted by visit count/recency. ArrowUp/Down to select, Enter/click applies, Escape closes.
- History:
did-navigate records to <userData>/history.json (hash-stripped, capped 600→prune to 500 by count+recency, flushed every 15 s). Only http(s).
- The dropdown-over-native-views problem: the web overlay can't paint over a
BrowserView. Solution: while a bar's input has focus, that one pane's view is pushed down an extra 180 px (addrEditIndex + insetAddr(rect, i)), and the panel renders in the freed strip inside the #addressRow overlay. Blur (or workspace switch / bar-toggle) reclaims the space.
- Verified (CDP, isolated profile): suggest IPC returns config-site, history, and popular-domain matches ranked correctly; dropdown renders on real typing; pane innerHeight 816→636 on focus (exactly −180) and restores on blur; ArrowDown+Enter navigated the pane to the selected suggestion;
history.json flushed with correct counts. Re-verified in the packaged asar.
2026-07-16
Fix: divider "snapped to the far side" after an edge-slam drag (lost mouseup)
shipped
- Bug: in the nested [side | stacked pair | side] layout, slamming the left divider fully left made it "snap all the way to the right, taking the right column with it."
- Cause: releasing the mouse outside the window (which is exactly where the cursor ends up when you slam a drag to the edge) means the
mouseup never reaches the app — the drag stays live, and when the cursor re-enters, the divider chases it across the screen.
- Fix: both drag loops (tree + flat) now check
e.buttons on every move — if the button is no longer held, the drag ends cleanly at the last real position. Applies to all divider drags.
- Verified via CDP: a held drag to −150px followed by button-up moves at ±5000px committed exactly the −150 position, ignored the wild moves (right column untouched), and a normal drag still worked afterwards.
Nested sections can be pulled to 0 width
shipped
- Bug: in a nested layout (side panels flanking a stacked middle), the side panels couldn't be dragged all the way to 0 — tree-mode dividers had a 60px floor.
- Fix: the floor is gone — a nested section drags to exactly 0 (its divider stays at the edge as the handle to pull it back open), matching the flat-mode feel. The drag preview dims tiny sections like flat mode does, and
insetAddr is clamped so a 0-height section can't produce negative view bounds.
- Verified via CDP (the exact repro config: row [side, col[two], side]): left pane → sizes
[0, 0.8, 0.2] with all dividers still present; dragged back open to 0.18; right pane to 0 as well. No console errors.
Flexible-layout engine, step 2: NESTING — uneven grids + split/insert controls
shipped
- What: workspaces can now nest splits — rows inside columns inside rows — for genuinely uneven grids (e.g. two stacked panes on the left, one tall pane on the right). Every address bar gained ◫ (split beside) and ⊟ (split below); hovering any divider shows a + pill that inserts a new pane between the two. Panes get stable ids; the tree references panes by id.
- How (hybrid, zero regression): flat workspaces keep ALL existing behavior (drag-to-collapse included). A perpendicular split converts the group to tree mode (
group.split: leaves {site:id}, splits {dir,sizes,kids}); a parallel split just adds a sibling and stays flat. When edits simplify a tree back to one level it auto-flattens (sites/entries reordered in lockstep to leaf order), restoring flat perks. Recursive treeLayout drives the panes (main) and treeGeom mirrors it for dividers/bars (renderer) — nested dividers span only their own region and resize their node's sizes (60px floor, sent back whole via tree:update). All structural ops are tree-aware: insert = sibling-after; remove = prune + simplify; within-group grip drop = swap two panes' tree positions; cross-group move/copy re-ids the site for the destination (ids are per-group) and prunes/appends the trees. Tree-mode groups show "Custom — nested splits" in Settings (picking a flat layout dissolves the nesting). DIVIDER widened 6→8px for the + pill.
- Verified via CDP: ⊟ on a 2-pane row produced root-row[nested-col[A1,New], A2] — pixel-confirmed uneven (A1 696×384 vs A2 696×816); the nested divider spanned only its half and dragging it re-split to [0.594,0.406]; + pill rendered; removing the pane auto-flattened back to a 2-pane row with 1 divider. Slice 3 (junction-aware drag: one side vs both within ~25px) still queued.
Fix: collapsing a pane left the address bars' strip space empty
shipped
- Bug: dragging the left panel to 0% made the address bars disappear but their reserved strip space stayed behind (an empty 40px band above each pane).
- Cause: main always reserves the per-pane address strip when the bar mode is on, but the renderer's earlier simplification hid ALL bars while any pane was collapsed — space reserved, nothing in it.
- Fix: address bars now stay through collapse — one bar per visible pane, positioned by the same reduced-region
collapsedLayout() the dividers use (the collapsed pane itself has no bar; it's just the strip). Reopening restores the full set.
- Verified via CDP (3-pane repro): 3 bars → collapse pane 0 → 2 bars remain, first starting right after the 26px strip and both matching their panes' widths exactly (26+912, 944+456) → reopen → 3 bars. No console errors.
Fix: with 3+ panes, collapsing one froze the other dividers
shipped
- Bug: 3 panes, drag the left one to 0% (collapse) → the divider between the remaining two couldn't be moved until the first pane got width back.
- Cause: the collapsed-state render drew ONLY the collapse strip — no dividers for the still-visible panes (collapse was originally built for 2-pane groups, where one collapsed pane leaves nothing to divide).
- Fix:
collapsedLayout() mirrors main's reduced-region geometry (strip reserved, visible fractions renormalized); renderCollapsedDividers() draws draggable dividers between the visible panes; startCollapsedDrag() resizes the two adjacent sites' fractions inside that region. The drag core was generalized from "divider i ↔ panes i,i+1" to an explicit pair of site indices (a,b) with a scale factor (pixels → stored-fraction units, since layout renormalizes by the sum) — which also makes the normal drag track more accurately when fractions don't sum to 1. While one pane is collapsed the others floor at the collapse threshold (no second collapse). Placeholders preview the reduced-region layout mid-drag.
- Verified via CDP (3-pane repro): collapse pane 0 → 1 divider still renders → dragging it re-split the remaining panes
[0.667,0.333] → [0.754,0.246] with the collapsed pane untouched → strip click reopened and both dividers returned. No console errors.
Delete a workspace from its tab (hover ✕ + inline confirm)
shipped
- What: hovering a group tab reveals a small red ✕; clicking it swaps the tab into a red "Delete? Yes / No" chip. Yes deletes the workspace (its cached views reclaimed; if it was active you land on a neighbor; deleting the last one leaves a fresh empty split). No just cancels.
- How:
confirmDeleteGroupId renders the confirm chip in renderTabs (same survive-re-render pattern as tab-rename); group:delete IPC discards the group's cache, splices config.groups, re-seeds a default group if none remain, and re-renders. The ✕ stopPropagations so it never switches tabs.
- Verified via CDP: ✕ on every tab → chip shows → No left both groups intact → Yes removed Beta → deleting the last (active) group produced a fresh
new-split and the tab strip re-rendered to it. No console errors.
Extension on/off toggles
shipped
- What: each extension in Settings → Extensions has an on/off checkbox (row dims when off). Off is remembered (
config.disabledExtensions, by folder name) and applied live: the extension is removed from every running pane session, so pages stop getting it on their next load — flip it back on and the next load has it again. No restart either way.
- How:
isExtDisabled(dir) filters both the startup loadExtensionsInto and the import hot-load; extensions:toggle(base, enabled) persists the set and walks every prepped session calling ses.loadExtension / ses.getAllExtensions() + ses.removeExtension(id) (matched by path).
- Verified via CDP: marker present at boot → toggle off → config shows
disabledExtensions:["testext"], row dimmed, and a reloaded pane had no marker → toggle on → reloaded pane marked again. No console errors.
Import all from Chrome + compatibility badges
shipped
- What: Settings → Extensions gained "Import all from Chrome" — scans every local Chrome profile (
Default + Profile *), copies the newest version of each installed extension into FireLeash's extensions folder, and hot-loads them into running pane sessions (content scripts apply on the next page load — no restart). Every listed/imported extension gets a compatibility badge: ✓ likely works · ⚠ partial · ✕ won't do anything visible, with the reasons in the tooltip.
- How:
extensions:import-chrome walks %LocalAppData%\Google\Chrome\User Data\*\Extensions\<id>\<version> (env-overridable via MV_CHROME_UD for tests), version-sorts (1.2.3_0 style), resolves __MSG_extName__ names from _locales, skips themes/apps, and fs.cpSyncs with a filter that excludes Chrome's _metadata signing folder (which breaks unpacked loading). assessCompat(manifest) is an honest heuristic: content scripts/devtools ⇒ ok; popup UI, MV3 service worker, or APIs Electron lacks (declarativeNetRequest, webRequest, contextMenus, …) ⇒ partial with reasons; no content scripts ⇒ ✕. Import results render as a per-extension report with badges + counts.
- Verified via CDP (fake Chrome profile via MV_CHROME_UD): imported 2/2;
__MSG_ name resolved ("Fake Blocker"); _metadata confirmed absent from the copy; ✓ and ✕ badges rendered; and a pane reload picked up the imported content script without restarting (hot-load works). No console errors.
Chrome extensions (unpacked)
shipped
- What: drop unpacked extension folders (each with a
manifest.json) into <userData>\extensions\ and they load into every pane session at startup — content-script extensions (uBlock Origin, Dark Reader, userscript managers) work across all panes. New Settings → Extensions tab lists what's loaded (name/version/MV) with an "Open extensions folder" button and how-to (copy from Chrome's own profile …\User Data\Default\Extensions\<id>\<version>). Restart to pick up changes.
- How:
discoverExtensions() scans the folder once per run; loadExtensionsInto(partition) calls session.loadExtension(dir, {allowFileAccess:true}) per partition (memoized in extReady); buildEntry chains the pane's first loadURL behind that promise so content scripts catch the very first page load. IPC extensions:list / extensions:open-folder. Honest limits: Electron has no Chrome toolbar-button/popup UI and no Web Store installs; MV2 content-script extensions are the sweet spot, MV3 support is partial.
- Verified via CDP: a test MV3 extension's content script stamped
data-mv-ext="ok" on a real pane (example.com) — proving per-partition load + first-load injection — and the Extensions tab listed it. No console errors.
Per-pane address bars (each in its own pane, not one crammed row)
shipped
- What: with grid/column/multi-pane layouts the single top row of address bars didn't line up with the panes. Now each pane's address bar sits at the top of that pane's cell, sized to the pane, and toggles away with the address button as before.
- How:
topOffset() no longer includes the address row (was a global strip). Instead each pane reserves an addrInset() (ADDRESS_ROW_HEIGHT) strip at the top of its cell — insetAddr() carves it off every view's bounds in layoutViews (row/column + grid). The renderer computes full cell rects for any layout (layoutCellRects → paneRects for row/col, new gridRects for grid) and absolutely-positions each .addr-bar at its cell's top strip inside a click-through #addressRow overlay. The insert + moved inside each bar (insert after this pane). Bars are hidden while a pane is collapsed (layout differs then).
- Verified via CDP (2×2 grid): 4 bars at distinct per-pane positions — TL
(0,0), TR (703,0), BL (0,431), BR (703,431), each 697px wide (its pane's width); a pane's view measured 385px tall = cell 425 − 40 strip (inset confirmed). No console errors.
Flexible-layout engine, step 1: the second axis (top/bottom split)
shipped
- What: a new "Split column — stacked" layout (Settings → Workspaces): panes stack top-over-bottom with a horizontal divider you drag up/down. The exact mirror of the side-by-side row split — resize, drag-to-collapse (to the top/bottom edge + reopen), grips, per-domain styles all work vertically too. First step of the flexible tiling was requested; nesting (rows-in-columns → uneven grids + edge-splitting) builds on this next.
- How: generalized the layout to a main axis (width for row, height for column). Main
layoutViews positions each pane along the axis (x/width for row, y/height for column). Renderer got an isColumn() helper and rowRects→paneRects (axis-aware); dividers render vertical or .divider.horiz (row-resize cursor); the drag/collapse/placeholder logic now tracks startPos/available on the active axis (clientY/height for column). Collapse strip flips to top/bottom. Address bars split evenly in column mode (panes are full-width). Settings layout dropdown gained the option.
- Verified via CDP: a column group's two panes measured 1400×425 each (full-width, half-height — genuinely stacked), rendered a horizontal divider, and dragging it down grew the top pane (
fractions → [0.64, 0.36]); a row group still shows a vertical divider. No console errors.
Drag tabs to reorder workspaces
shipped
- What: grab a group tab and drag it left/right to reorder the workspaces. A blue insertion edge (left/right half of the target tab) shows where it'll land; the drag carries a labeled ghost of the group name.
- How: tabs are
draggable; a tab drag sets a separate dragGroupId (distinct from the pane-grip dragPayload, so both drag types share the tabs as drop targets without clashing). ondrop picks the insertion side from the cursor's x vs the target tab's midpoint and calls group:reorder(draggedId, targetId, after) — main splices the group to before/after the target, saves, config:updated re-renders the tabs. Click-to-switch and double-click-to-rename still work (drag needs movement).
- Verified via CDP (synthetic DnD): tabs report
draggable, dragging Alpha onto Gamma's right half reordered [g1,g2,g3] → [g2,g3,g1] in both config and the tab strip. No console errors.
2026-07-15
Per-domain styles/scripts + full-window settings
shipped
- Styles/JS are per-DOMAIN now, not per-tab:
config.domainStyles is a list of {domain, css, js}; on each pane load, stylesForUrl(currentUrl) matches the pane's current host (exact, then apex) and injects that domain's CSS (user-origin) + JS. So one rule covers every pane on that domain, and a pane navigating to another domain picks up ITS rules. A one-time migrateDomainStyles() in loadConfig folds any legacy per-site css/js into domainStyles (keyed by apex) and drops the old fields — nothing lost.
- Settings takes over the whole tab: replaced the 720×680 child window with an in-window
BrowserView (openSettingsView/closeSettingsView/layoutSettingsView) filling the content area below the toolbar, with the group's panes detached while it's up. The gear toggles it (and lights up while open); clicking a tab or the gear closes it. config:save while open defers the pane rebuild until close (settingsDirty), so panes don't render under the settings view. The settings page gained a Per-domain styles section (add/remove domains, CSS + JS each) and lost the per-site CSS/JS textareas; Save trims empty domain entries and shows "Saved ✓".
- Verified via CDP (isolated profile): a config with a legacy
site.css migrated into domainStyles (example.org added, example.com kept, per-site fields cleared); a domain rule body{background:rgb(1,2,3)} actually painted the example.com pane's body that color; the gear opened the settings view (target present + gear active) and toggled it closed (target gone + gear inactive). No console errors.
- Follow-up — tabbed settings + real editor: the settings page is now a flex-column app with a tab bar (Workspaces | Styles). Styles is its own tab: a domain sidebar on the left (click to select, live-updating labels, add/delete) and a full-height CSS + JS editor on the right (the textareas are
flex:1, ~325px tall vs the old 34px — the "too tiny to work in" complaint). Footer Save/Close persists across tabs; Save shows "Saved ✓". Verified via CDP: 2 tabs, Styles activates, sidebar lists domains, selecting one loads its CSS into a 325px editor, add-domain focuses the new name, Workspaces still shows groups.
Pane shortcuts: zoom (Ctrl +/-/0, Ctrl+wheel) + back/forward (Ctrl ←/→)
shipped
- What: in any pane, Ctrl
+/- zoom in/out, Ctrl 0 resets, Ctrl+MouseWheel zooms, and Ctrl+Left / Ctrl+Right go back / forward. Back/forward is suppressed while typing in a page input (so word-nav still works).
- How: a single universal pane preload (
renderer/pane-preload.js) now loads on every pane — it captures the key/wheel events and sends pane:zoom (±1/0) / pane:nav (±1) to main, which adjusts setZoomLevel (±0.5, clamped) or goBack/goForward on the event's own webContents. It also absorbs the old "own dark" job: the per-site pane-cs-dark.js was removed and its color-scheme declaration folded in, gated on an additionalArguments flag --mv-owndark (set in buildEntry when site.ownDark).
- Verified via CDP (synthetic Ctrl-key/Ctrl-wheel events in a real pane):
devicePixelRatio went 1.0 → 1.2 on Ctrl+= ×2, back to 1.0 on Ctrl+0, and 1.095 on Ctrl+wheel — proving preload→IPC→zoom end-to-end. Back/forward shares that proven path (goBack/goForward, with an E31 navigationHistory-or-legacy fallback). (CDP's Input domain hangs on Electron BrowserView targets — synthetic DOM events are the reliable way to exercise a pane preload.)
Modern-browser chrome + address-bar close/pin/spring-load
shipped
- Frameless title bar: removed the OS title bar — the toolbar/tabs now share the top row with the native min/max/close (Windows Controls Overlay).
titleBarStyle: 'hidden' + titleBarOverlay: {color:'#1b1c20', symbolColor:'#c9cad1', height:44} on the main window; the toolbar was already -webkit-app-region: drag, and got padding-right: 140px so its right cluster clears the overlay controls. (Kept titleBarStyle:'hidden', NOT frame:false, so resize borders + native controls remain — the window can always be closed.)
- "x" close per pane: each address bar has a close (✕) button →
pane:remove(index) (splices site + fractions + collapse-index fixups, reclaims just that pane's view, re-renders). Removing the last pane leaves an empty group (fine).
- Pin is now a toggle: the 📌 lights (amber) when a pane has a saved home URL. Click toggles it —
pane:toggle-pin(index) pins the current location as the home, or (when lit) forgets it (unpin, keeps the page on screen). Both address buttons got :active click feedback; the fresh-pane "go"/Enter persists a typed URL via pane:set-url.
- Drag-to-tab feedback (revised — see fix below): dragging a pane grip onto a tab highlights it and, on drop, switches to that workspace so you see the result. (First shipped as "spring-load" — a mid-drag live switch — but that painted blank; superseded same day, next entry.)
Fix: drag-to-another-tab went blank + drop didn't apply until re-visiting
shipped
- Symptoms: dragging a pane onto another tab showed no proper ghost and "went blank"; dropping "did nothing until I leave the tab and come back."
- Cause: the spring-load live-switched the view during the drag — but an in-progress OS drag runs a modal loop that blocks the main process from applying the switch cleanly, and native
BrowserViews don't repaint mid-drag → blank. And the cross-group move handler only re-rendered when the source was active; spring-load made the destination active by drop time, so it never re-rendered → "nothing until leave/return."
- Fix: removed the mid-drag switch entirely. Now: (1) a labeled chip drag ghost (
setDragImage on a .drag-ghost element) so you can see what you're carrying; (2) the hovered tab highlights (drop-target) so you see where it'll land; (3) on drop, moveSite(...).then(switchGroup(target)) — the drag is over so the view paints, and you land on the destination showing the moved pane immediately. Removed springTimer/springGroupId/cancelSpring.
- Verified via CDP: dragover a tab does NOT switch mid-drag (
active_during_drag: Alpha), target highlights, drop moves (g1→[A2], g2→[B1,B2,A1]) and lands on the destination (active_after_drop: Beta); no console errors.
- Verified via CDP (isolated 2-group profile):
navigator.windowControlsOverlay present + visible, toolbar padding-right 140px; pin lit for a pane with a URL → toggle cleared it + unlit; ✕ removed a pane (g1→[A1]); a simulated grip-drag hovering the Beta tab switched to Beta after the spring delay; no console errors.
Insert panes from the address row ("+" between bars)
shipped
- What: in address mode, a small
+ sits at every boundary of the address bars — before the first, between each pair, and after the last (N+1 for N panes). Click one to insert a new pane at exactly that spot; its URL box is auto-focused so you can type the address immediately, and what you type sticks (a fresh empty pane persists its first URL rather than treating it as a temporary navigation).
- How:
renderAddressBars interleaves makeAddrInsertBtn(at) buttons; pane:insert(groupId, at) splices an empty {label:'New', url:''} site + a freshly-built cache entry at at, even-splits fractions, shifts a collapse index, re-renders via group:active. A focusPaneIndex set before the IPC makes the post-render focus the new input. New pane:set-url(index, url) persists a typed value (config + navigate); the address-bar Enter uses it for panes with no saved URL, and stays a temporary navigate for panes that already have one (pin to keep).
- Verified via CDP (isolated 2-group profile, address bar on): 2 panes → 3 insert buttons; clicking the last inserts at the end (
[A1,A2,New], buttons→4, new input focused); clicking the first inserts at the start ([New,A1,A2,New]); a typed URL persisted as https://example.com; no console errors. Tradeoff noted: the inline +s shift the bars slightly off pane-alignment in this mode (acceptable — address mode is for editing).
Logins now persist — session-cookie persistence + clean UA
shipped
- What: stopped losing logins (Facebook Messenger reproduced it: "You must log in to continue" on every launch). Two fixes so sessions survive a restart like Chrome does.
- Diagnosis (from the real cookie store, not a guess): the
apex-facebook.com partition held exactly one cookie — datr (a tracking cookie, persistent, expiry 2027). The actual login cookies c_user (account id) and xs (session secret) were absent. Root cause: Electron does not persist SESSION cookies (no expiry) across app quit — even in a persist: partition. Facebook/Google set their login cookies as session cookies, so they evaporated on close while long-lived tracking cookies survived.
- How:
ensureSessionPersistence(partition) hooks each partition session's cookies.on('changed'); when a session cookie is set (cookie.session === true, not removed), it rewrites it with a ~13-month expiry so it lands on disk — mirroring Chrome's "continue where you left off." Guarded against a rewrite loop (our own set() lands as a non-session cookie, so it doesn't re-trigger). Wired into buildEntry per partition.app.userAgentFallback set to a plain Chrome UA (Chrome/126.0.0.0, dropping the default fireleash/x + Electron/x tokens) so Facebook/Google stop flagging the client and handing it throwaway sessions/extra challenges.
- Verified via CDP (isolated profile): pane UA is
…Chrome/126.0.0.0 Safari/537.36 (no Electron/fireleash); a session cookie set in a pane, followed by a graceful quit, was found on disk with is_persistent=1, has_expires=1 — i.e. a would-be-dropped session cookie now survives the restart. Note: existing logins were already lost, so you log in once more after updating; from then on it sticks.
Drag sites between workspaces — grips + cross-tab move
shipped
- What: each pane gets a small drag grip (⠿) in a slim band above it. Drag a grip onto another workspace tab to move that site into that workspace; drag it onto another grip to reorder within the current one. Mix and match panes across tabs freely.
- How: reserved a
GRIP_ROW = 16px band above the panes (native BrowserViews can't have DOM painted over them, so an interactive handle needs a gap — same reason the dividers/collapse-strip live in gaps). Both processes offset the pane top by the band: main (layoutViews row branch + computeGridBounds) sets y = topOffset() + GRIP_ROW, height -= GRIP_ROW; the renderer mirrors it in rowRects, dividers, placeholders, and the collapse strip. Grips are HTML5-draggable; drop targets are DOM (tabs + grips), never over a view — so drops always register. New site:move(fromGroupId, fromIndex, toGroupId, toIndex) IPC: same-group = reorder cache entries in lockstep (no reload); cross-group = splice the site across config.groups, reclaim only the moved pane in the source (its siblings stay warm) and splice a freshly-built entry into the destination cache if it's warm (only the moved pane loads). Extracted buildEntry() from buildGroupEntries() for that single-pane build. Fractions: source renormalizes, destination resets to even; collapse indices are kept in range.
- Verified via CDP (isolated 2-group profile): grips render one-per-pane and are draggable, tabs are drop targets, panes sit below the 16px band; reorder within Alpha →
[A2, A1]; move Alpha→Beta → Alpha [A1], Beta [B1, B2, A2], active group re-rendered to 1 grip; zero console errors (caught + fixed one on the way: config:updated was sent without its config payload, which every other sender includes → the renderer read .groups of undefined).
- Revised same day (grip moved off the panes): dropped the permanent 16px band — it cost pane height for a handle you rarely need. The grip (⠿) now sits left of each pane's address bar, which shows/hides with the top-left toggle. So panes reclaim the full height; to rearrange, flip the address bar on and drag from a grip. All the band geometry (
GRIP_ROW in both processes) was reverted; grips render in renderAddressBars, the address bar is the reorder drop target, tabs remain the move-to-workspace target. Re-verified via CDP: 2 grips in the address row, .pane-grip band count 0, dividers no longer offset, reorder + cross-tab move still [A2,A1] / Alpha [A1] / Beta [B1,B2,A2], no console errors.
- Shift+drag = duplicate: holding Shift while dropping a grip copies the site instead of moving it — onto another tab (a live duplicate that shares the site's login/session, since same URL → same partition), or in place.
site:move gained a copy flag (JSON.parse(JSON.stringify(site)) clone, source untouched, fresh entry spliced into a warm destination cache); the grip's effectAllowed is copyMove and drop targets show the copy cursor + allow Shift-drop onto the current tab. Verified via CDP: copy A1→Beta leaves Alpha [A1,A2] / Beta [B1,B2,A1]; copy in place → Alpha [A1,A1,A2] (3 grips); plain drag still moves; no console errors.
- Drop a pane on the
+: the toolbar + (new split) is now also a drop target — dropping a grip on it spins that pane out into its own brand-new workspace and switches to it (Shift = leave a copy behind). New site:to-new-group(fromGroupId, fromIndex, copy) IPC clones-or-removes the site, creates a one-pane group (named after the site), pushes it, config:updated + renderGroup to it (mirrors the +-click flow minus the settings dialog). Verified via CDP: copy→+ keeps g1=[A1,A2] and makes a new [A1] group (count 3); move→+ gives g1=[A1] + new [A2] group (count 4) with that new tab now active; no console errors.
Drag-to-collapse — dividers do it all, collapse buttons removed
shipped
- What: drag a divider all the way to an edge to collapse that pane to the slim strip; drag the strip back inward to reopen it (or click the strip to restore an even split). The two toolbar chevron buttons (
#collapseCtl) are gone — the border does everything now.
- How: removed the old
MIN_FRAC = 0.12 drag clamp so a pane can shrink to an edge; onDragMove now clamps only to [0, pairSum]. On release, onDragEnd decides: if either side is narrower than COLLAPSE_PX = 44, that pane collapses (group.collapsed = index); otherwise it's a normal resize. Reopening reuses the same gesture via startOpenDrag(e, idx) — it seeds a divider drag with the collapsed pane starting at strip width, so the same move/end logic grows it. Collapse state now rides the drag:end IPC ({fractions, collapsed}); the standalone collapse:toggle IPC + collapseToggle preload method were deleted as orphaned. Placeholders below the threshold get a .willcollapse hint during the drag.
- Verified via CDP (isolated throwaway profile, never touched the live config): 2-pane group shows 1 divider → dragging it to the edge yields 1 strip / 0 dividers (collapsed) → dragging the strip inward yields 0 strips / 1 divider (reopened); no console errors; persisted config showed
collapsed: null + fractions [0.52, 0.48] after the round-trip. Buttons confirmed absent, startOpenDrag present.
2026-07-12
Single instance + focus + "open another" prompt
shipped
- What: accidentally launching FireLeash again no longer spawns a duplicate (which would fight over the session/config). The existing window is brought to the front and a prompt asks whether to open a second, separate window.
- How:
app.requestSingleInstanceLock(); the second-instance event focuses the main window and opens a themed dark dialog (a small child window, not a native OS dialog). "Open another" spawns a second instance with its own --user-data-dir (independent profile/login). A same-session second window would need a per-window state refactor — deferred.
- Verified: a second launch didn't duplicate (only the dialog's process appeared) and the prompt showed; "Stay here" dismisses it.
Double-click a tab to rename
shipped
- What: double-click a group tab to rename it inline; Enter/blur saves permanently, Esc cancels.
- How: rename state (
renamingGroupId/renamingValue) lives outside the tab element so it survives the re-renders that a tab click triggers; clicking the already-active tab is now a no-op. group:rename IPC persists it.
- Verified via CDP: dispatched a real double-click + Enter, and the group name changed in config.
Per-site "own dark" — fixes force-dark black-on-black (Gmail)
shipped
- What: global force-dark mangles sites that have their own dark theme (Gmail → black-on-black icons). A per-site "own dark" checkbox in Settings excludes that pane from force-dark so the site's own theme shows.
- How:
site.ownDark attaches a preload (renderer/pane-cs-dark.js) that sets document.documentElement.style.colorScheme = 'dark' at document-start; Chromium's force-dark backs off for pages that declare a dark color-scheme. Timing matters — it must be set before first paint; post-load insertCSS does not work (it produces the black-on-black itself). Repurposed the previously-dead "dark" checkbox.
- Verified: captured pixels — a white page under force-dark renders dark (baseline) but white with the preload, including under
sandbox:true. For Gmail: check "own dark" + set Gmail's own theme to Dark.
Instant tab switching — persistent view cache
shipped
- What: switching workspaces no longer reloads. Each group's panes are built once and kept alive; switching shows/hides them.
- How:
groupCache: Map<groupId,{entries,idleTimer}>. renderGroup hides the previous group (removeBrowserView, keeps webContents alive) and shows the target (cached = instant, else build + load). Chrome-style lifecycle: warm cache + auto-throttle of hidden views, LRU cap = 5 (enforceCap discards oldest non-active), idle discard = 10 min (discardGroup drops refs → GC reclaims; reloads on return). Reclamation never touches the active group.
- Edge cases:
config:save discards the cache and rebuilds (edits invalidate it); group:reverse/move-pane reorder cache entries in lockstep (no reload); background panes can't clobber the active address bar (guarded by group id).
- Verified: renderer-process count 8 (one group) → 10 (opened a second, first stayed warm) → 10 (switched back, nothing destroyed). The climb-and-hold proves caching; old code held flat at ~8.
Per-pane address bar (toggleable)
shipped
- What: a ▾ arrow at the far left of the tab bar toggles a second row with one address bar per pane. Type + Enter/→ navigates that pane temporarily; 📌 pin accepts the current location as the pane's permanent URL (writes to config).
- How:
#addressRow (40px), bars flex-grow = pane fraction (widths track the panes). Live URLs via each pane's did-navigate → pane:url IPC. topOffset() shifts the panes down when shown (main + renderer agree). Persisted via config.showAddressBar.
- Verified: toggle shows/hides + persists; typed
example.com → left pane only went to Example Domain (config unchanged); pin → config URL became the new location.
Toolbar "+" — new split workspace
shipped
- What: a
+ next to the tabs creates a fresh 2-pane "New split" group, switches to it, and opens Settings to fill in the URLs. Empty panes show a placeholder instead of a broken load.
- Verified: click → new tab active + 2 placeholder panes + Settings opened (confirmed in UI and via config gaining the group).
Unread badges on group tabs (active group)
shipped
- What: a red count badge on the group tab, read from the page title's
(N) and a navigator.setAppBadge monkeypatch (the durable path for PWAs).
- Verified: a pane titled
Test (7) produced a 7 badge. Real Google Messages count is unconfirmed (was logged out during test); a per-site adapter is the fallback if it doesn't expose one. All-tab coverage arrives with the view cache.
Pane collapse / expand
shipped
- What: toolbar chevrons
⇤/⇥ fold the left/right pane away; the other fills the window. The collapsed pane leaves an edge strip with an inward chevron to restore it. Persists per group.
- How: collapsed view is detached (stops painting/input); the strip lives in a reserved gap. Triggers are in the toolbar (not on the divider) because BrowserViews paint over the toolbar DOM — only gaps are clickable.
- Verified: collapse-left → switch to collapse-right → expand, full cycle, both panes restored.
Programmable panes — per-site custom CSS + JS
shipped
- What: a CSS box and a JS box per site in Settings, injected into that pane only. CSS: hide clutter, widen columns, restyle. JS: tailor, trim, automate (a private, scoped userscript).
- How: on
did-finish-load — CSS via insertCSS(css, {cssOrigin:'user'}) (overrides the page's own styles); JS via executeJavaScript(js) in the page's main world. Both re-apply on Save.
- Verified: CSS → a banner painted on the Contacts pane and nothing on Messages. JS → a script-built "JS INJECTED OK" banner on Contacts only. Both genuinely per-site.
Dark mode — global, at the paint layer
shipped
- What: every pane and site rendered dark, including Google's Shadow-DOM + service-worker-cached apps that resisted everything else.
- How:
app.commandLine.appendSwitch('blink-settings','forceDarkModeEnabled=true') — Chromium's render-level force-dark. Luminance-aware (already-dark pages left alone), preserves colorful images.
- Failure ledger (all failed before the fix): CSS invert (SPA re-asserts light) → self-healing JS invert (can't reach Shadow DOM) → Dark Reader (same wall) →
enable-features=WebContentsForceDark (silent no-op in Chromium 126; the "MDN went dark" proof was a false positive — MDN has its own dark theme). The honest test is example.com (zero dark support, prefers-color-scheme forced light).
- Verified: dark on
example.com, the Google sign-in, and the packaged build.
Swap / reorder panes
shipped
⇄ flips pane order; double-click a divider swaps the two panes beside it. Order + split sizes persist.
Foundation (initial build)
- Shell: Electron
BrowserView per site (real isolated Chromium views, not iframes — framing headers can't block them).
- Sessions: persistent partitions keyed by apex domain — same-domain sites share one login.
- Workspaces: named "groups" of arbitrary sites, row or grid layout, edited live from the gear.
- Layout: draggable resizable dividers; last group + split sizes persisted.
- Delivery: portable
.exe via electron-builder + desktop shortcut with a custom icon (blue chat bubble / gold contact glyph, generated by build/icon-gen.js).
Environment notes (gotchas that cost time)
| Symptom | Cause | Fix |
| "Electron failed to install correctly" | npm's extract-zip corrupts the binary unpack on this machine | Extract the cached zip with PowerShell Expand-Archive into node_modules/electron/dist, write path.txt |
| Build "hangs" ~10 min | Windows Defender scanning the fresh 70 MB exe ("output file locked → waiting for unlock") | Not a hang; clears itself |
| Build stalls at signing | signtool reaching a timestamp server | CSC_IDENTITY_AUTO_DISCOVERY=false (personal build needs no signing) |
| Google login keeps dropping | Force-killing the app (/F) truncates Chromium's cookie write | Always close gracefully (WM_CLOSE) |
| Copied profile logged out despite cookies | Cookies encrypted with a key in the profile's Local State | Copy Local State alongside the partition |
Build: CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --win portable