Skip to content

Test/multi display usage tracking - #1

Merged
ma-04 merged 15 commits into
mainfrom
test/multi-display-usage-tracking
Aug 10, 2026
Merged

Test/multi display usage tracking#1
ma-04 merged 15 commits into
mainfrom
test/multi-display-usage-tracking

Conversation

@ma-04

@ma-04 ma-04 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

No description provided.

ma-04 added 15 commits August 6, 2026 16:35
Adds a second Swift helper, screen-memory-appstate, which answers a
newline-delimited command protocol on stdin. It reports the frontmost
application and, per display, that display's frontmost window with the
share of the screen it covers. It never reads kCGWindowName, so usage
tracking keeps working if screen recording permission is revoked. The
process is long-lived because usage polls every two seconds.

Three features build on it:

Per-display review. A toolbar filter narrows the day to one display and
persists across scrubbing, playback and day changes; a day where that
display has no data falls back to showing all screens and restores the
choice on return. Clicking a screenshot opens a lightbox with zoom, pan,
frame stepping, and Copy / Save / Reveal in Finder. Those three IPC
handlers take the relative path and reject anything that resolves
outside the storage root.

App exclusion. Apps listed in Settings are skipped on a display while
they are its frontmost window and cover at least a configurable share of
it, default 80%. Coverage rather than strict fullscreen equality is what
catches maximized and near-fullscreen windows. Only that display is
skipped, and skipping happens before the file write, so no JPEG, no row
and no OCR. The threshold applies live.

Usage tracking. A new Usage view shows per-app totals and an hour-by-hour
strip for the selected day. Tracking runs independently of capture, so it
continues while recording is paused, and idle time closes the open
segment. Segments are written on open and updated each tick so a crash
cannot lose an interval. Totals also feed the AI summary.

Migration 0001 only creates app_usage and its index. Existing Drizzle
databases take it incrementally; the legacy migrator needs no change
because its staging database runs the whole migration folder.

Everything degrades to a no-op when the helper binary is absent: capture,
timeline and lightbox work as before, the exclusion settings render
disabled, and the Usage view shows an empty state.
Adds a CI workflow that runs the formatter check, lint, typecheck, the
Swift helper build and a full electron-builder package on every push and
pull request, uploading the signed .dmg and .zip as an artifact. The
helper binaries are checked explicitly because a missing extraResources
entry otherwise fails late inside electron-builder.

Release stays opt-in: it keeps its v* tag trigger and gains a manual
dispatch so a build can be published from any branch, defaulting to a
prerelease. Its publish target now follows the repository running it, so
a fork releases to itself rather than failing against the upstream repo.
The legacy database runs in WAL mode, so it is three files, not one. The
migration treated it as one and lost data in two ways.

The backup was a plain copyFileSync of the main file. Any rows committed
but not yet checkpointed still live in the -wal, so a database that was
force-quit backed up short — and the row-count check couldn't catch it,
because it compared the staging database against that same lossy backup.
Both sides were missing the rows and agreed. Checkpoint first, then copy,
then verify against the live database instead.

The swap unlinked the main file and renamed staging over it, leaving the
old -wal behind for SQLite to replay into the migrated database. A repro
against real files confirms it: the swapped-in database reads back the
old rows, not the migrated ones. The swap now parks the original and its
sidecars aside by rename and removes them only once the new database is
in place, so no instant exists where neither is there. An interrupted
swap used to leave no database at all, which the next launch read as a
fresh install and silently replaced the user's history with an empty
one; recoverInterruptedSwap() now runs before that check and finishes or
unwinds the swap.
electron-builder names the release after the package.json version rather
than the git ref, so pushing v0.2.0 without bumping package.json quietly
published to v0.0.1. The workflow now reconciles the two and fails with
the fix spelled out instead of releasing under the wrong tag; a manual
run takes the version as an input and bumps package.json so the derived
tag matches.

Republishing was worse than wrong — electron-builder refuses to touch a
published release older than two hours and still exits 0, so a rerun
looked green while uploading nothing, and inside that window it would
overwrite a live release's assets instead. A guard now fails the run when
the tag is already published, manual runs default to a draft, and a
concurrency group keeps two runs from racing for the same tag.
Four ways the helper wrapper misbehaved when the child process wasn't
healthy.

Any request with no live child spawned one immediately, straight past the
respawn backoff — against a helper that crashes on startup, the 2s usage
poll restarted it forever. Requests now fail soft while a respawn is
pending, which is what every caller already expects, and the backoff
gets to do its job.

Exit was tracked with 'exit', which never fires when spawn itself fails,
so a binary that isn't executable or isn't a valid Mach-O left a dead
child in place and every later request timed out against it. Tracking
'close' covers both, and isAvailable() now checks the executable bit so
that case reports the feature as off instead.

stop() left the door open: pump() flipped `started` back on, so a request
racing shutdown would spawn a helper as the app was quitting.

The pipes had no 'error' listeners. Writing to a helper that just died
raises EPIPE there, and an 'error' event with nothing listening is fatal
in Node. Detaching a killed child now also drops its stdout 'data'
listener, so a line still buffered in it can't settle whichever request
the next child is serving.
Two independent holes let an excluded app land in the archive.

capture() read the front window only after desktopCapturer.getSources()
had already grabbed the pixels, so an app that was frontmost at grab time
but dismissed before the check was captured anyway; the mirror case (an
app coming forward during the grab) slipped through a before-only check.
It now reads both sides of the grab and skips the display if either read
sees an excluded app. Reading twice only means something if the second
read is fresh, so getState() takes a max age and the post-grab call
passes 0 to bypass the 1s memo the pre-grab call just filled.

In the helper, a display's frontmost window was the first window with any
non-empty intersection, so a window overlapping a neighbouring display by
a pixel became that display's dominant app and suppressed the real one.
Each window is now assigned to the single display it predominantly
occupies, computed once for all displays.

The Swift change is unverified locally: the Command Line Tools install on
this machine has an SDK/compiler mismatch, so build:swift fails. CI
builds it.
The lightbox was rendered inside whichever branch of ScreenshotViewer was
active, so playback advancing the timestamp flipped the component between
its focused and grid branches and remounted the dialog, throwing away
zoom, pan and the frame the user had arrowed to. The branches now assign
to one variable and the lightbox renders at a fixed position after it,
keyed by the shot it is showing. Opening it also stops playback, since
frames changing underneath an open lightbox was never useful.

Display labels were derived independently in ScreenshotViewer, so a
display could be "Display 2" in the toolbar and "Display 1" in the
lightbox header. The labels from useDisplayFilter are threaded through
and the local numbering is only a fallback for a display the filter has
not seen.

The coverage threshold input's min/max only coloured the field: a typed
0 or 500 was saved verbatim. It now persists only values in 1-100 and
clamps on blur, keeping the field editable while a partial value is typed.
getUsageTotals summed min(ended_at, end) - max(started_at, start) without
a floor, so a segment lying entirely outside the range contributed
negative time and could cancel out real usage. The per-segment term is
now wrapped in max(0, ...).

Both range queries filtered on ended_at >= start, which idx_app_usage_started
cannot serve, so the scan grew with the whole table. Segments close on
idle, sleep, lock and quit, so none can outlive a day; adding
started_at >= start - 1 day bounds the scan without changing the result.

Idle time was credited to the tick that noticed the idle rather than to
the last input, handing the app up to the full 120s threshold of time the
user was not there. IdleDetector exposes the idle seconds and the segment
closes at the last-active instant. Segments now carry startedAt so a
close can never write an ended_at before the start.

pickApplication returned null for both "user cancelled" and "could not
read that bundle", so a failed pick looked like a cancel and the app was
silently not added. Unreadable bundles now throw.

useAppUsage reported available: true with no segments when the load
failed, rendering a database error as "no app usage recorded for this
day". The error is carried through and shown as an error.

Adjacent bands in the hour strip whose bundle ids hashed to neighbouring
hues read as one block; each band draws a hairline of page background on
its leading edge, and its tooltip names the app.
The migrations folder and the two Swift binaries are present in any dev
checkout, so a packaging regression that drops them is invisible until a
user runs the built app and migrations fail. Both workflows now check the
packaged .app for a parseable, non-empty _journal.json, at least one .sql
migration, and both helper binaries present, executable and Mach-O.

Exercised against a fixture in all four states: complete, missing
journal, missing helper, and a non-Mach-O file in a helper's place.
Each carries the project-specific knowledge that is otherwise re-derived
every session: the four edits an IPC method needs, the storage-root path
rule, the kCGWindowName permission constraint, the broken local Swift
toolchain, and the bug classes this codebase actually produces.
The previous fix assigned each window to the single display it overlapped
most and let a display consider only windows homed to it. That reopened
the hole it was closing, in a worse form: a window can cover display B
entirely while having more raw area on the larger display A, so B ends up
with no dominant window at all, no bundle id reaches the exclusion check,
and B is captured with the excluded app filling it.

Concretely, a laptop at 1728x1117 beside a 2560x1440 external, tops
aligned, with a window dragged across the boundary: 100% of the laptop but
only 1.93M px there against 2.41M on the external, so the laptop reports
nothing. Before the exclusivity rule it was correctly skipped.

Replaced with a floor: a window has to occupy at least 2% of a display
before it can be that display's frontmost window. A one-pixel spill no
longer masks the app behind it, and a window filling two displays is
reported on both. Also removes the tie-break where two displays with
identical bounds left the second one permanently empty.

Still not compiled locally — the Command Line Tools install on this
machine has an SDK/compiler mismatch. CI builds it.
Number('') is 0 and Number.isFinite(0) is true, so clearing the field and
clicking away took the finite branch, clamped 0 up to 1 and saved it — the
DEFAULT_THRESHOLD fallback was unreachable. The setting applies live, so
from then on any excluded app that was merely frontmost and covered 1% of
a display suppressed that display's screenshots entirely: a small Messages
window in a corner silently blanks a whole monitor out of the archive.

An empty or unparseable field now means "no new value" and leaves the
saved one alone. type="number" also yields "" for partial entries like
"-" or "1e", which took the same path.
Day queries only look back MAX_SEGMENT_SPAN_MS for a segment that might
overlap them. That bound was justified by an argument — segments close on
idle, sleep, lock and quit — rather than enforced, so anything that keeps
the HID idle timer from reaching 120s (caffeinate -u, a jiggler, some
remote-desktop stacks) could extend one row past a day. It would then
vanish from every day it covers but the first, with no warning: the app
used all day shows zero.

UsageService now closes and reopens the segment once it reaches the span,
which makes the bound an invariant instead of an assumption. The constant
moved to shared/constants.ts so the query and the writer cannot drift.

Also corrects the previous commit message on this file: the max(0, ...)
added there is defensive, not a fix. The WHERE clause was already the full
overlap predicate, so the clamped term could only go negative on a row
with ended_at < started_at, which nothing can write.
getState(0) skipped the cache and then returned this.inFlightState
unconditionally, so a caller demanding the world *now* was handed whatever
round-trip happened to be open — possibly one that started before the
thing it was trying to observe. Capture's post-grab read depends on that
freshness, and got it only by accident of timing.

An in-flight request is the same staleness as the cache, so it now answers
to the same age limit. The finally clears the field only when it is still
the current request, since a fresher one can now replace it.

Hardening: I could not construct a sequence where the old code produced a
wrong capture decision, because the pre-grab read drains any pending
request before getSources() starts.
The check claimed to catch a helper built for the wrong architecture, but
`file | grep Mach-O` accepts an x86_64 binary in an arm64 bundle just as
happily — exactly the build that spawns, dies, and surfaces as "feature
unavailable" rather than as a broken package. It now compares lipo -archs
against an expected arch, passed explicitly by both workflows.

Exercised against fixtures in six states: complete, wrong-arch helper,
non-Mach-O helper, missing helper, empty journal, and no .sql migrations.
@ma-04
ma-04 merged commit 6e8e44f into main Aug 10, 2026
2 checks passed
@ma-04
ma-04 deleted the test/multi-display-usage-tracking branch August 10, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant