Two Ways to Hide a Browser
When you build a "lock my browser while I step away" extension, there are exactly two architectures, and every product on the store picks one of them:
- Navigate away — send every tab to a lock page. Simple, looks airtight, and destroys everything: the half-typed email is gone, the video restarts from zero, and single-page apps come back on the wrong route because their in-memory state evaporated with the document.
- Cover in place — leave every document loaded and paint a veil on top of it. Unlocking is just removing the veil, so the page never notices anything happened.
VKT VeilMate, my tab-privacy extension, is the second kind. The header comment of its content script (content/veil.js) is the whole design brief in four lines:
* 1. Only block — never host the password field: it is typed on the
* extension's own page, so no page script can sniff it.
* 2. Never destroy a page: mount a shadow-DOM overlay; unlock removes it;
* forms, video and SPA state survive untouched.
* 3. Self-heal: if the site deletes the overlay, put it back.
* 4. Cosmetic layer (wallpaper / big clock / message) is painted AFTER
* blocking — cover first, decorate second.
"Cover first, decorate second" sounds obvious until you watch the fetch for the user's wallpaper delay the veil by a frame. The overlay attaches synchronously; everything pretty loads afterwards.
The Veil Is a Closed Shadow Root
The overlay is one div with a closed shadow root hung at z-index: 2147483647. From content/veil.js:
function buildNode(kind, style) {
const node = document.createElement('div');
node.id = HOST_ID; // 'veilmate-veil-root'
node.tabIndex = -1;
assertHostStyles(node);
shadow = node.attachShadow({ mode: 'closed' });
mode: 'closed' buys two things. The page cannot call host.shadowRoot to walk inside the tree — it gets null — and the page's stylesheet cannot select anything behind the boundary, because shadow DOM is a real encapsulation border, not a convention. The styles live inside the root itself:
:host { all: initial; }
.veil {
position: fixed; inset: 0; z-index: 2147483647;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: radial-gradient(1200px 760px at 50% 16%, #2a2350 0%, #14122b 55%, #0b0a18 100%);
color: #ece9ff;
user-select: none; cursor: default;
}
The :host { all: initial } line matters more than it looks: it kills anything the document would otherwise pass down the tree (inherited fonts, letter-spacing, directions), so the veil renders identically on a site that sets html { direction: rtl } and one that doesn't.
Here is the honest part: closed does not mean invisible. The host element is a plain div with a guessable id, sitting in the page's DOM, visible to every querySelector. A site script can call host.remove(), or hide it with CSS. Closed only protects the inside. The outside needs its own defense, and I initially got that wrong.
The Host Element Is the Leak
The first version set the host's position and size as ordinary inline styles. A site only needed one rule in its author stylesheet — #veilmate-veil-root { display: none !important } — and an author-level !important beats a non-important inline declaration. The veil was gone, and the self-heal didn't fire, because the self-heal only watched isConnected, and a hidden node is still connected.
The fix is belt-and-braces in three layers. First, every load-bearing property is written as important (HOST_STYLE in content/veil.js, trimmed):
const HOST_STYLE = {
all: 'initial', // first: wipe inherited junk
display: 'block',
position: 'fixed',
top: '0px', right: '0px', bottom: '0px', left: '0px',
width: '100%', height: '100%',
'z-index': '2147483647',
visibility: 'visible',
opacity: '1',
'pointer-events': 'auto',
transform: 'none', filter: 'none', 'clip-path': 'none',
/* …and the min/max/overflow siblings */
};
function assertHostStyles(node) {
if (!node) return;
for (const [prop, value] of Object.entries(HOST_STYLE)) {
try {
if (node.style.getPropertyValue(prop) === value &&
node.style.getPropertyPriority(prop) === 'important') continue;
node.style.setProperty(prop, value, 'important');
} catch { /* ignore */ }
}
}
Second, healing checks visibility, not just presence:
function hostHidden(node) {
try {
const cs = getComputedStyle(node);
return (
cs.display === 'none' ||
cs.visibility === 'hidden' ||
cs.opacity === '0' ||
cs.pointerEvents === 'none'
);
} catch {
return false;
}
}
Third, a MutationObserver plus a one-second ticker put things back:
observer = new MutationObserver(() => {
if (!locked || !host) return;
if (!host.isConnected) {
(document.body || document.documentElement).appendChild(host);
assertHostStyles(host);
return;
}
if (hostHidden(host)) assertHostStyles(host);
});
observer.observe(document.documentElement, { childList: true, subtree: true });
Note the subtree: true. The earlier version observed only direct children of <html> — so when a site replaced its <body> wholesale, the observer never fired and "self-healing" was decorative. The ticker exists for the same reason: SPAs rename document.title from inside route transitions, and a locked tab whose title still reads "Inbox — Alice's secret project" defeats the point. The title is re-forced to a neutral string every second, and the ticker runs even when the user turns the big clock off, because its real job is re-asserting title and host styles.
Eating Every Event Before the Page Sees One
An overlay that only looks like it covers is a screensaver. If a click passes through gaps or a keystroke lands in a focused input underneath, the page behind the veil is still live. The guard set from content/veil.js:
const BLOCKED = [
'keydown', 'keyup', 'keypress', 'mousedown', 'mouseup', 'click', 'dblclick',
'contextmenu', 'wheel', 'touchstart', 'touchmove', 'touchend',
'pointerdown', 'pointerup', 'dragstart', 'selectstart', 'copy', 'cut', 'paste'
];
function attachGuards() {
if (guardsAttached) return; // attach once; lock/unlock cycles don't stack
guardsAttached = true;
for (const type of BLOCKED) {
window.addEventListener(type, block, { capture: true, passive: false });
}
window.addEventListener('focusin', stealFocus, true);
window.addEventListener('focus', stealFocus, true);
}
Four deliberate details:
- Capture phase on
window— the guard sees every event before any page listener or React root does, andstopImmediatePropagation()pluspreventDefault()means nothing below ever fires. passive: false—wheelandtouchmovedefault to passive on window in modern Chrome, wherepreventDefault()is ignored. Without this, the page behind the veil would still scroll.- The veil itself must work — the unlock button lives inside the shadow tree, so
block()lets events through when the host is on their path:if (e.composedPath().includes(host)) return;. Events from a closed root still compose outward to the window, which is exactly why this check is needed. - Scroll is a document property, not an event — cancelling
wheeldoesn't stop a keyboard user or a programmatic scroll, so locking also pins the document:document.documentElement.style.overflow = 'hidden', with the previous value saved and restored on unlock.
Keyboard has one more hole: a canvas, iframe or rich-text editor that already holds focus dispatches keystrokes into its own document, where the top window's capture listener never sees them — the typing lands on the page behind the curtain. The fix is to not let anything else keep focus:
function stealFocus(e) {
if (!locked || !host) return;
if (typeof e.composedPath === 'function' && e.composedPath().includes(host)) return;
try {
if (e.target && typeof e.target.blur === 'function') e.target.blur();
} catch { /* ignore */ }
try {
host.focus({ preventScroll: true });
} catch { /* ignore */ }
}
The host has tabIndex = -1 precisely so it can hold focus as a sink. Combined with user-select: none on the veil and copy/cut/paste in the blocked set, a covered page is inert: clicks, scroll, typing, drag and text selection all stop at the veil. Nothing is encrypted or paused — the video behind the curtain keeps buffering — but nothing the passer-by does reaches the page.
Injection Timing and the One Percent the Veil Cannot Reach
The content script is cheap and always on:
"content_scripts": [{
"matches": ["http://*/*", "https://*/*"],
"js": ["content/veil.js"],
"run_at": "document_start",
"all_frames": false
}]
document_start matters for reloads: refresh a covered page while locked and the script boots before the site's own JS, asks the background for state, and re-covers immediately:
chrome.runtime
.sendMessage({ type: 'veil:state', url: location.href })
.then((r) => {
if (r && r.cover) show('lock', r.style);
})
.catch(() => {});
The pageshow listener repeats the question when a tab returns from bfcache. The background answers with the top-level URL it read from the sender — the page's self-reported URL is only a fallback — and a per-domain exempt list decides whether that tab gets covered at all.
all_frames: false is deliberate: the veil is one full-viewport fixed layer, so pointer input aimed at any embedded frame hits the veil instead of the frame; a per-frame overlay would multiply injection cost for no extra coverage. One click can also blur a focused frame because host.focus() takes focus across the frame boundary.
The genuine blind spots are pages a content script cannot exist on: chrome://, edge://, about:, file://, other extensions' pages, and tabs that were already open before the extension was installed. For exactly those, the background falls back to the ugly-but-correct strategy it refuses to use on normal pages — replace the page, remember the URL, restore on unlock (background.js, guardTab/unlock):
await rememberReplaced(tab.id, url).catch(() => {});
chrome.tabs.update(tab.id, { url: page }).catch(() => {
if (windowId) chrome.tabs.create({ windowId, url: page, active: true }).catch(() => {});
});
// …and in unlock(), per tab:
if (isLockPage && original) {
chrome.tabs.update(tab.id, { url: original }).catch(() => {}); // restore in place
} else if (isLockPage) {
chrome.tabs.remove(tab.id).catch(() => {}); // pure lock tab: close
} else {
chrome.tabs.sendMessage(tab.id, { type: 'veil:unlock' }).catch(() => {});
}
Yes — a replaced tab reloads, and its form state is gone. That's the honest trade: navigation is only used where overlay is impossible, so the loss is confined to pages that were never live documents to begin with (a chrome:// settings page has nothing to lose). The one page that must never be replaced or covered is the extensions management page for this extension itself — it is the exit path if the user forgets the password, since there is no recovery code and no reset endpoint. isEscapeUrl allows only chrome://extensions filtered to the extension's own id; other extensions' pages stay behind the lock.
One Click, Every Window, Everything New
Locking is a single broadcast in background.js. chrome.tabs.query({}) — no filter — returns every tab in every window, http or not:
async function paintTabs() {
const tabs = await chrome.tabs.query({});
const st = await getState();
if (!st.locked) return;
const style = await currentLockStyle();
for (const tab of tabs) {
/* …skip the lock page itself and the escape url… */
if (/^https?:/i.test(url)) {
sendLock(tab.id, style).catch(() => recordAndSwap(tab.id, url));
} else {
await recordAndSwap(tab.id, url);
}
}
}
The toolbar button, the right-click menu and every auto-lock trigger funnel into this. There is no commands block in the manifest and no keyboard shortcut is registered anywhere — a deliberate small decision: shortcut collisions with sites and the browser are guaranteed, and a lock you trigger by accident while a popup is open is worse than two clicks.
While locked is true, three listeners make sure nothing new slips past the lock screen: tabs.onCreated (a fresh tab), tabs.onUpdated filtered to info.url changes only (a typed address — title and favicon updates are ignored on purpose so a chatty YouTube tab doesn't turn the callback into a hot path), and windows.onCreated with two delayed sweeps at 300 ms and 1200 ms, because a brand-new window may not have a tab yet. That includes private windows — the manifest declares "incognito": "spanning", so if the user enables it, a new incognito window opens onto the lock page too.
The lock state itself lives in chrome.storage.local, which means closing the browser does not unlock: onStartup reads the persisted state and calls paintTabs() as the first act of the new session. Auto-lock defaults to the browser's own idle detection at 10 minutes. The lock screen shows a big clock with an optional date — six language locales ship with it, so "浏览器已锁定" is "Browser locked" in English, 中文, 日本語, Deutsch, Español and Français.
Unlock Removes; It Does Not Rebuild
The veil's unlock button does not contain a password field — fields inside injected pages are sniffable in principle, so the button only asks the background to focus the extension's own lock page:
function requestUnlockPage() {
try {
chrome.runtime.sendMessage({ type: 'veil:unlock-request' });
} catch { /* ignore */ }
}
The lock page verifies the password in the extension context: PBKDF2-SHA256 with 310,000 iterations and a random salt, stored as { salt, iter, hash }, compared in constant time. The password itself is never written to storage, never synced, never leaves the device — the only outbound request in the whole extension is license activation. After unlock, remove() in each page is the mirror image of mount: node out, observer disconnected, overflow restored, saved title restored. No reload, no restore protocol, nothing to checkpoint.
The two Pro features built on the same plumbing are worth a paragraph each. The privacy curtain reuses the overlay in a non-locking mode — right-click any page to blur it with backdrop-filter: blur(26px) and a one-line label; a click lifts it. The browser is not locked, nothing is recorded, and it is the polite version of "close this tab" when a colleague walks over. Scheduled lock is a one-shot chrome.alarms re-armed after every fire (a periodic 1440-minute alarm drifts across daylight saving), with a five-minute lateness cutoff so a missed alarm from a closed browser doesn't lock you out at boot.
What This Does Not Protect You From
The README's one-sentence product statement, which is also printed inside the settings page: VeilMate hides your tabs from people. It is not encryption, and it does not defend against someone who controls the machine. Concretely:
- A person who can disable or remove the extension can see everything. No extension can prevent that, and store policy forbids trying.
- Another browser profile, another OS user, or a task-manager kill bypasses everything — all outside the extension's reach.
- DevTools on an extension page can send the extension's own runtime messages. That's the same class of attacker as "can disable the extension", and the countermeasures (attempt counter, escalating cooldown, locked-state guards) all live in the background script so that path is no faster than guessing at the lock screen.
- An SPA that swaps the URL via
pushStateis not a navigation Chrome reports, so the exempt list is re-checked on real navigations and reloads, not mid-app. Fixing that needswebNavigation— a permission I chose not to ask for. - The page content behind the veil is still in memory, unencrypted, and keeps running. This is a curtain, not a safe.
Saying these out loud costs installs among people who wanted a safe. It buys reviews from people who understood what they bought — the table above is the difference between "browser lock" as a category and a tool with a claim you can actually keep.
The Performance Bill
Small and honest. The veil is one fixed-position div with a gradient background: layout cost is one full-viewport paint, no canvas, no animation; the clock ticker rewrites two textContent values per second. The real costs are subtler: setting overflow: hidden on the document removes the scrollbar, which reflows the page's layout once at lock and once at unlock (content that used to be 100% wide becomes 15px wider) — harmless visually because the veil covers the transition. The observer on subtree: true fires callbacks on every DOM mutation site apps make constantly; React-style apps under the veil do pay a few observer invocations per second, and this is why the handler is two cheap checks and nothing else. backdrop-filter: blur(26px) in curtain mode is the one genuinely expensive primitive here — a full-viewport blur per frame on large displays — which is fine for a feature that covers one page and is removed on click.
If You Build the Same Thing
- Decide early between cover and navigate — mixing them is fine, but only as primary and fallback, never as equals.
- Closed shadow DOM protects the inside; the host element is still page-visible and needs important inline styles, a visibility-aware
MutationObserverobserving the whole subtree, and a slow timer as final backstop. - Block at capture with
passive: false, or wheel/touch prevention is silently ignored; whitelist your own overlay viacomposedPath(). - Focus is a side channel: blur whatever holds it and park focus on the host, or keystrokes land behind the curtain.
- Persist lock state in
storage.local; "closing the browser unlocks" is a feature bug wearing a disguise. - Leave exactly one escape hatch (your own extension-management page), or a forgotten password becomes a hostage situation.
- Publish what the tool cannot defend against — it is the cheapest trust you can buy.
References
- Content scripts — Chrome Docs
- Using the Shadow DOM — MDN
- Event.stopImmediatePropagation — MDN
- chrome.idle API Reference