← Back to projects

Personal project — Windows widget, downloadable

AccountabilityApp

A Windows widget that tracks which app or window is actually in focus, down to the browser tab or open file since Chrome and VS Code both put that detail directly in their window title and runs entirely on your own machine: no account, no cloud, nothing leaves the computer very locally. It groups distracting apps and sites into categories (like Social Media or Games) with a daily time budget each based on user's choosing; going over is visible in the widget, though it doesn't interrupt what you're doing yet. It can also generate a Markdown report of tracked time, for my own use as a contract worker keeping a work log.

0Cloud services involved
v0.3.0Current shipped version
1Full feature built, tested, then cut pre-release
3Real bugs, caught only by real use

Why it needed to see more than "which app is open"

Most simple screen-time trackers stop at the app level: "Chrome was open for 3 hours." That number is close to useless, because Chrome being open is true whether you're reading documentation for work or scrolling social media: same app, completely different value. The same problem exists for a code editor: "VS Code was open" tells you nothing about what type of work was being done.

AccountabilityApp exists to close that granularity gap, while keeping everything local since this is a personal tool anyone can download and run for themselves without account, server elsewhere or data leaving the machine. That local-only constraint shaped almost every decision below, including one that mattered more than expected: how much distribution friction is worth paying just to get tab-level detail, once it turned out a simpler mechanism could get most of it for free.

What it actually does

Tracking (see what's actually being used) and blocking (quota-based, visible but not yet enforced)

Tracking: one generic window-focus check, reused for the browser and the editor for free

A background poller checks whatever window has focus every 3 seconds and logs it as a session in the app's own local, encrypted database. That alone gives app-level detail like any basic tracker: "Chrome was open for 3 hours." The useful part is that Chrome and VS Code both write the active tab's title or the open file's name directly into their own window title bar, so the exact same generic check happens to yield tab-level and file-level detail for the two apps used most, without any app-specific integration for either.

Show the technical version

active-win-pos-rs::get_active_window() is polled every 3 seconds and diffed against the previous focused window; a change closes out the prior app_sessions row and opens a new one. end_crash_session() closes any row still open on startup, so a crash mid-session can't leave one open forever. The database itself is SQLCipher-encrypted via rusqlite, with the key held in the OS keyring rather than on disk.

Blocking: a daily time budget per category, visible but not (yet) enforced

Rather than blocking a fixed list of sites or apps, the app groups them into categories (Social Media, Games, more can be added) and gives each category a daily time limit, matched against both domain-style keywords and app/window-title keywords. Right now, going over the limit shows up clearly in the widget such as name, used time, a progress bar, but doesn't interrupt anything; there's no overlay or popup stopping you from continuing, since that enforcement lived in the extension that got cut (see below).

Show the technical version

Each category stores a daily limit in minutes, an enabled toggle, a manual pause toggle, and editable domain/app keyword lists. Usage is aggregated across app_sessions and whatever session is currently open, and the collapsed widget shows a live indicator for whichever enabled, unpaused category the focused app or window belongs to, refreshing on the same 3-second poll as the rest of the tracker.

Built in full, tested, then cut before release

The first version of tab-level tracking didn't get simplified but got deleted, on purpose.

A working Chrome extension + local WebSocket bridge, fully tested and then removed

The original plan for tab-level tracking was a dedicated bridge: a WebSocket server inside the Tauri app, a Chrome extension reporting every tab switch to it, and eventually a matching VS Code extension. It got built, and it worked — tab sessions recorded correctly, category quota blocking triggered an overlay and rotating deterrent popups on over-budget tabs, 12/12 extension tests passed, and a real WebSocket origin-spoofing vulnerability (any webpage's JavaScript could otherwise connect and inject forged tab events, since WebSocket isn't subject to CORS the way fetch() is) got found and fixed before it ever shipped.

It still didn't ship. Distributing a Chrome extension to real users means either publishing through the Chrome Web Store or asking every user to manually flip on Developer Mode and click "Load Unpacked", friction that wasn't worth paying once it became clear that app-level tracking already captures nearly the same signal for free, since Chrome and VS Code both write the active tab's title or the open file's name directly into their own OS window title, which the app was already reading for every other app on the system.

Show the technical version

Removed in v0.2.0: the ws://127.0.0.1:7734 WebSocket server, the tab_sessions table, and the unpacked extensions/chrome extension (blocking overlay injection, rotating deterrent popups, a 15-second heartbeat to catch users who stay on a tab without switching away). A VS Code/Cursor client was specced but never built — Chrome was proven first, and the removal decision landed before a second client was worth starting. Nothing about the removal was a rush call: the extension carried its own fixed vulnerability and a full passing test suite right up to the point it was cut, which is what made "we already have most of this for free at the app level" a comfortable trade rather than a shortcut.

Bugs that only showed up in real use, never in a unit test

None of these surfaced from reasoning about the code or from anything the test suites caught, only from actually using the feature.

Typing into the keyword editor kept bouncing the cursor to the start

The settings screen lets you edit which domains and app names count toward a category — but every keystroke reset the cursor to the beginning of the field, making it functionally impossible to type something like "x.com" one character at a time.

Show the technical version

Every onChange event called a Tauri command to save the value immediately, and the resulting store refresh re-rendered the input with the freshly-read database value mid-keystroke — overwriting whatever the user had just typed. Fixed by giving the input its own local draft state: onChange only updates the local draft, and the actual save fires once on onBlur, when the user clicks away. The lesson generalises: any input wired directly to a round-trip save on every keystroke is a cursor-jump bug waiting to happen the moment that round-trip is even slightly slower than typing speed.

A one-letter keyword quietly counted File Explorer as Social Media

The Social Media category's app keyword "x" added for the X (formerly Twitter) taskbar app was also matching the letter x anywhere inside another window title. That included "Windows Explorer," so ordinary file-browsing time was silently getting counted toward the Social Media quota. It wasn't caught by reasoning about the matching logic; it was caught by reading an actual generated report and noticing "Windows Explorer" showing up somewhere it shouldn't.

Show the technical version

contains_keyword did a plain substring match with no word-boundary check, so a single-character keyword like "x" matched inside any word containing that letter. Fixed by requiring the match not be flanked by another alphanumeric character on either side, so "x" still matches a window titled "X" but no longer matches inside "Explorer." New tests cover both the false positive and the still-intended match. The default keyword list was cleaned up in the same pass — twitter.com/twitter were stale post-rebrand duplicates of x.com.

The timer froze at zero after clearing all data until the next app switch

"Clear All Data" is meant to wipe tracked history and start clean. Instead, the widget's timer sat frozen at zero right after clearing, and only started counting again once the user switched to a different app, a confusing dead window right when the feature is supposed to prove the reset actually worked.

Show the technical version

Two separate state machines fell out of sync: the Zustand store wasn't refreshed after the database clear, and the Rust tracking thread kept holding an in-memory session pointer to a now-deleted row. Fixed on both sides as the store now calls refreshStats/refreshSessions right after clearing, and a needs_reset flag on ActivityTracker makes the Rust thread drop its stale session and start a fresh one on the very next poll tick, instead of waiting for a real app switch to notice anything changed.

Where it's honestly at

Tracking and category quota tracking both work end to end on Windows, backed by 31/31 backend tests and 35/35 frontend tests, all through app/window-title matching — no browser extension or editor plugin required after the Chrome bridge got cut. Going over a category's daily limit is visible in the widget but doesn't interrupt anything yet: no blocking overlay, no deterrent popups, that enforcement layer left with the extension. There's also no "strict mode" equivalent — pausing a category is a simple toggle with no added friction, and stays paused until manually re-enabled rather than timing out on its own. Backend commands for app-level blocking (add_blocked_app, remove_blocked_app, get_blocked_apps) already exist with no widget surface built yet — the next planned piece.

Since then, a work-timer report, and a bigger discovery

Seven more sessions building a feature to turn the tracked data into something billable and along the way, finding a real data-integrity bug that had been running silently for months.

31/31Backend tests passing
20Silent database wipes found, before the fix
2Features investigated, then deliberately not built

Turning tracked time into a report you could actually bill against

The original tracking was for self-accountability, but the same data which app or tab was in focus, and for how long, is also exactly what you'd want as a record for contract work: proof of whether a fixed-price job actually took more or less time than it was quoted for. The feature that came out of this: press download, get a plain Markdown log of everything tracked since the last download, with a header and a subtotal.

Getting the download to actually work took two attempts. The first approach appeared to fail silently with nothing visible happened on click. It turned out to have worked the whole time; the file was just never confirmed by anything the app could see. That distinction mattered enough to switch to a mechanism that could actually prove success back to the user, rather than one that merely hoped it had worked.

Show the technical version

The first attempt used a plain browser-style download (Blob + <a download>). It did work as the file was written, just discovered afterward via File Explorer rather than through any signal the app received. The real gap: a browser download click and the file finishing writing are different events, and only the click is observable from script, even in a real browser. Replaced with tauri-plugin-dialog for a native Save-As picker, paired with a Rust command that writes the file via std::fs::write — here, the invoke() promise resolving is real proof the bytes were written, since it's the app's own code doing the write. That's what made an in-app "Saved" confirmation possible at all.

Investigated tracking AI-coding sessions directly in terminal and chose not to build it

Windows Terminal is where most AI-assisted coding sessions actually happen, but the tracker had no way to see which tab was active inside it unlike Chrome or VS Code. Two different ways to close that gap got seriously investigated: reading Windows Terminal's tab state directly, and hooking into Claude Code's own session events. Both turned out to have real, structural problems once traced through an actual example, not just a hypothetical one.

Rather than force either approach to work, the honest call was to leave that gap where it is. The report now shows Windows Terminal as a single lump of time with no per-tab detail, and the user relies on their own memory to separate real work from procrastination inside that block, a real limitation, stated as one, instead of a debatable heuristic pretending to be data.

Show the technical version

Windows Terminal renders its tab titles as custom XAML content and never writes them back to the underlying Win32 window title — confirmed by reading the tracking crate's source (a plain GetWindowTextW call, working exactly as intended) and corroborated by two open Microsoft repo issues confirming no public API exists to query tab state. The Claude Code alternative — hooking UserPromptSubmit to push events to the existing WebSocket server — was specced in detail, then traced through a real sequence (prompt → switch to Chrome → switch back) and found to produce overlapping, not sequential, time ranges: Claude Code hooks only fire on Claude Code's own internal events, never on "the user alt-tabbed away," which is the one thing the existing OS-level tracker can see for free and a hook structurally cannot.

"Today" quietly started at UTC 00:00:00 no matter the local timezone

The very first generated report showed a window starting at 'the start of today aka midnight' despite the actual time. The report's fallback for "since the last report" is start-of-today when no checkpoint exists yet except "today" was being computed as UTC midnight, which in Melbourne lands mid-morning local time. The same bug quietly affected the pre-existing dashboard "today" stats too, not just the new report.

Show the technical version

Fixed with a today_start_timestamp() helper that computes local midnight via chrono::Local and resolves it back to a UTC instant properly, including the ambiguous-time case around a DST transition. Since get_sessions_today already delegated to this helper for the dashboard, the one fix corrected both call sites at once.

A months-long silent data-loss bug, traced to one missing dependency flag

While chasing down why the report's numbers looked wrong, found something much bigger: the app's encryption key kept disappearing, and every time it did, the database quietly reset itself into a blank one instead of failing loudly. By the time this was fully traced, 20 of these abandoned "unreadable" backups had piled up going back months, meaning real tracked history had been silently thrown away over and over, with the app behaving normally the entire time.

The cause turned out to be one missing setting on a dependency. The library responsible for storing the encryption key securely needed an extra flag to actually talk to Windows' real credential store; without it, it quietly fell back to a fake, memory-only version that forgets everything the moment the app closes. That fake version was already live in the app's first public release, and went unnoticed because the project's automated tests don't run on Windows, so nothing failed loudly anywhere those tests could see.

Show the technical version

keyring = "3" had no Cargo features enabled; without windows-native, keyring-3.6.3 silently compiles in its mock backend (CredentialPersistence::EntryOnly, in-memory only) instead of the real Windows Credential Manager backend, with no compile-time or runtime error either way. get_or_create_key was working exactly as designed, detecting "no key found" and moving the undecryptable database aside; the actual bug was the crate silently substituting a fake backend underneath it. Fixed by enabling windows-native, verified with two consecutive real process restarts of the compiled binary, confirming the same key both times via cmdkey and zero new .unreadable.* files. A second, related bug caught in the same pass: cargo test used the same keyring entry as the real app, so once the real backend was enabled every test run would have overwritten the production key, fixed by giving tests their own separate service identifier. The equivalent macOS gap (apple-native) was prepped the same way in a later session but hasn't been verified on real Mac hardware yet.

The widget wasn't actually quitting, and a report inherited the consequences

A generated report once showed "1h 27m" at the top but "121h 27m" in the subtotal at the bottom, a five-day discrepancy hiding inside one file. The cause: closing the widget's window (Alt+F4, the taskbar's close option) didn't quit the app at all, it just hid it, so the tracker kept running unattended in the background for days.

Fixed on both ends: closing the window now always fully exits, the same way the quit button already did, so there's no more silent background running to lose track of. Separately, the report itself now anchors its earliest possible time to when the app was actually opened, and prints the date on any row where a gap crosses midnight, so a multi-day stretch can no longer read as "the same afternoon."

Show the technical version

CloseRequested called prevent_close() and hid the window rather than exiting, so the activity tracker kept polling regardless of window visibility; the hide_to_tray command and its tray menu item were removed entirely once confirmed nothing on the frontend still called it, and CloseRequested now stops the tracker and calls app_handle.exit(0), the same path the ✖ button and tray Quit already used. Separately, AppState gained a launched_at timestamp, and generate_session_report's window now floors at checkpoint.max(launched_at), so a stale multi-day-old checkpoint can no longer pull a report's start earlier than the current run. The per-row Time column in the generated Markdown now gets a YYYY-MM-DD prefix on its first row, and again only when the date changes from the row before it.

The first time anyone opened it on a real Mac, macOS refused to launch it

The macOS build had passed every automated check, but nobody had actually opened it on a Mac. The first person to do that was an outside contributor, who reported that it wouldn't launch. The app's own code never ran: macOS blocked it within about three seconds because the build had no developer identity that Apple could verify. The automated checks couldn't catch this. They confirmed the installer file wasn't corrupt, but macOS only applies its launch check to files downloaded through a browser, and a file built by CI never is.

The proper fix is Apple's developer program, at US$99 a year. For a free, non-commercial tool, I chose not to pay it. Instead, the build is now signed in a free "ad-hoc" way so that macOS's manual "Open Anyway" override works, and the README labels macOS experimental and walks through the bypass. Preparing that release also turned up a repeat mistake: the v0.3.0 release built an installer still labelled 0.2.0, because the version bump was missed for the second release in a row.

Show the technical version

The system log from issue #7 (macOS 26.2, M3) showed AMFI: ... has no CMS blob? and syspolicyd GK ... (team: (null)), then ASP: Security policy would not allow process. The binary carried only the linker's automatic ad-hoc signature, and tauri.conf.json had no macOS signing section. CI's hdiutil verify only checks DMG integrity, and Gatekeeper only evaluates files with the com.apple.quarantine attribute, which browsers add on download. Fixed with "macOS": { "signingIdentity": "-" } so the whole .app bundle is ad-hoc signed. That doesn't remove the Gatekeeper prompt, but Open Anyway can now succeed. Accepted costs: the bypass is needed after every update, and permissions and the Keychain prompt may reset per build because the ad-hoc identity changes. A denied Keychain prompt does not wipe data: only keyring::Error::NoEntry triggers the move-aside path. Version drift: tauri.conf.json, package.json and Cargo.toml were still 0.2.0 (package-lock.json was still 0.1.0), and Tauri names the DMG from tauri.conf.json, not the git tag, so all five were bumped. CI now writes the DMG's SHA-256 to the run summary. That hash matches GitHub's own digest on the release asset, which proves integrity (the download is the file CI built) but not authenticity: a compromised account would get a matching digest too. Still unverified: nobody has confirmed the new build launches on a real Mac, so issue #7 stays open until the reporter retests.