Disclosure first
I built FocusDash and I sell it for $5, so this is not a neutral post about someone else's product. It is a walk-through of my own code, written so you can check me instead of believing me.
"No data leaves your device" costs nothing to write and nothing to verify. So I built the smallest new-tab page I could and picked features by one rule: a stranger should be able to confirm what it does by reading it. The manifest is 16 lines, the app is 141. The audit mostly holds. It also found two bugs in my own product, both below.
Sixteen lines of manifest
"manifest_version": 3,
"permissions": ["storage"],
"chrome_url_overrides": { "newtab": "index.html" },
storage grants the chrome.storage API and nothing else: one namespace on disk in your browser profile, keyed to this extension's ID, unreachable by other extensions. The default ceiling for chrome.storage.local is 10 MB. FocusDash writes under 100 bytes.
chrome_url_overrides.newtab is the line that owns browser UI, so it earns the scrutiny. It replaces the page Chrome shows on a new tab. No history, no tabs API, no cookies, no alarms.
The absent keys matter more. No host_permissions, no optional_permissions, so the extension's own pages cannot fetch an arbitrary https origin and read the response. No content_scripts, and injection is impossible without host permissions anyway, so it cannot see your other tabs. No background, so no service worker exists and nothing runs once the tab closes. That last one has a price, and it comes due in the timer section.
One leftover: "action": { "default_title": "FocusDash" } has no default_popup and no background to receive the click, so clicking the toolbar icon does nothing today.
One grep is the whole audit
From inside the unzipped folder:
grep -nE "fetch|XMLHttpRequest|WebSocket|EventSource|sendBeacon|import" *.js *.html
grep -nE "src=|href=" *.html
grep -nE "host_permissions|background|content_scripts|permissions" manifest.json
The first returns nothing. The second returns one hit, line 86 of index.html: <script src="app.js"></script>, local. The third returns one line, the permissions array.
Manifest V3 forbids remotely hosted code, so a CDN script tag is not a choice I skipped. All the CSS is inline in index.html against a system font stack, so there are no font requests either.
Where grep tells you nothing: minified names, URLs assembled at runtime, eval. None apply to 141 unminified lines with no eval, no Function constructor, no Worker and no atob. That is the narrow condition where "grep is enough" is true, and the one I built for.
Four numbers on disk
function save() {
const { sessionsToday, totalSessions, streak, lastFocusDate } = state;
if (typeof chrome !== "undefined" && chrome.storage?.local) {
chrome.storage.local.set({ sessionsToday, totalSessions, streak, lastFocusDate });
That is the entire data footprint: two counters, a streak, and one date string. No name, no email, no identifier. save() is called from exactly one place, inside complete(), so it runs once per finished session.
The intention text is not in there.
intent.addEventListener("input", () => {
try { localStorage.setItem("focusdash-intent", intent.value); } catch {}
});
Two storage backends in a 141-line app is a smell. Both stay on disk and both are scoped to the extension, so the privacy claim survives it. But if you audit only chrome.storage, you will miss the intention text entirely.
Opening the file without Chrome
if (typeof chrome !== "undefined" && chrome.storage?.local) {
chrome.storage.local.get(null, (s) => res(s || {}));
} else {
// preview mode outside Chrome (open file directly)
try { res(JSON.parse(localStorage.getItem("focusdash") || "{}")); }
The chrome.storage?.local existence check is what makes the fallback work. Double-clicking index.html gives you a working page in Safari or Firefox, stats in localStorage, no install step. That helps the audit: the file you read is the file that runs, with no build step between them. The ZIP ships these same files, byte-identical to what I read this morning.
The timer loses your session
function tickTimer() {
state.remaining -= 1;
if (state.remaining <= 0) complete();
renderTimer(); renderStats();
}
if (state.running) interval = setInterval(tickTimer, 1000);
remaining is a number in memory and nothing writes it to storage. Close the tab, navigate away, reload: the session is gone. The first thing you do after starting a pomodoro is leave the new tab and get to work, which is exactly when the page holding the countdown stops existing.
Second problem is drift. setInterval makes no promise about landing on time, and the browser does not shorten the next wait to compensate. Subtracting a hard-coded 1 per tick leaves the display trailing the wall clock by the sum of that lateness. On a throttled or hidden page, where Chrome fires timers far less often than once a second, a 25-minute session can end up off by minutes.
The fix is to stop counting and start comparing. On start, store an end timestamp: state.endsAt = Date.now() + state.remaining * 1000. Then every tick derives state.remaining = Math.max(0, Math.ceil((state.endsAt - Date.now()) / 1000)), and endsAt rides along in the chrome.storage.local write that already exists. The display stays correct when ticks land late, and a session survives a reload because the truth lives in storage rather than in a counter.
What you download does not do that. Going further, past a reload to a closed tab, needs something outside the page to own the session: a background.service_worker and one more declared permission. MV3 tears an idle worker down after seconds, so the end timestamp stops being a nicety and becomes load-bearing. The worker wakes, does one subtraction, dies again. I chose the one-permission manifest and paid in functionality rather than privacy. Reasonable people choose the other way, and I am not settled on it.
The day boundary is UTC, not yours
function todayStr() { return new Date().toISOString().slice(0, 10); }
toISOString() is UTC, so the string behind lastFocusDate rolls at UTC midnight and the streak increments once per distinct UTC day. The on-screen date comes from toLocaleDateString, which is local. At UTC-5 those two disagree about what day it is from 7pm onward. An evening session can advance the streak, then a morning session in the same local day fails to. The counter is honest about what it measures. It is not measuring your day.
The fix is a few lines of local formatting, and I want it first, because the streak is the feature people come back for.
What to copy into your own extension
- Declare only the permissions you can explain in one sentence each.
- Omit
backgroundunless you need code running while your page is closed. - Ship unminified source and put the grep commands in the README, so a reviewer runs them instead of filing an issue.
- Write down what you store, next to the code that stores it.
One thing the audit caught in my own docs
const DEFAULT_LINKS = [
["Gmail", "https://mail.google.com"], ["Calendar", "https://calendar.google.com"],
renderLinks() reads a focusdash-links key that nothing in the app ever writes, so it always falls through to those four. They are not editable, and there is no keyboard handler anywhere in app.js. The README inside the ZIP describes quick links as "your 4 most-used destinations, one keystroke away", and the code does not support that sentence. That is a false feature claim in something people pay $5 for. I rewrote the line in the source instead of defending it, and the corrected README is what the download serves now.
Getting it
FocusDash is $5, one-time purchase, instant download, refundable for 30 days if it is not for you: https://payhip.com/b/nIYkx
You load it unpacked from a ZIP, because there is no Chrome Web Store listing. The plain reason: Google charges a $5 one-time developer registration fee and this project runs on a $0 spend budget. A folder you load is also a folder you can read, which is what makes the rest of this post checkable. The README covers the four clicks. Works in Chrome, Edge and Brave. One note for auditors: an unpacked extension with no key field gets an ID derived from its folder path, so moving the folder resets your stats.
Both bugs in this post are small and neither is fixed in what you download today. If you think I priced the timer trade-off wrong, that is the argument I am still having with myself.
Authorship note: this post was drafted by an AI assistant working from the extension's source files. I read it before publishing and corrected the one line that overstated what currently ships.
Top comments (0)