Skip to content

feat: run through go-widgets/application (bundle, working tray, no freeze) - #272

Merged
tannevaled merged 9 commits into
mainfrom
feat/application-lifecycle
Aug 30, 2026
Merged

feat: run through go-widgets/application (bundle, working tray, no freeze)#272
tannevaled merged 9 commits into
mainfrom
feat/application-lifecycle

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Rewires the reader onto the new go-widgets/application v0.1.0, the cross-platform app-lifecycle sibling to go-widgets/window, and fixes the three launch complaints: no Dock icon, a tray that never appeared, and the app freezing behind the vault password panel.

Why the old approach was wrong

The go-macos/statusitem tray shipped in #271 cannot appear from a bare executable"a status item is asked for by nobody" outside a .app. The real fix is to run inside a bundle, which also gives the Dock tile and the app name.

Changes

  • Delete internal/window — its Handler/Config contract and run loop were extracted into go-widgets/application. windowapp and cmd import application (aliased window, so type references are unchanged).
  • application.Runpresent_reader.go builds an application.Spec (name, identifier, the 32px appicon.Tray, a go-widgets/tray "Quit" menu) and calls application.Run, which owns the run loop and attaches the tray to it. Drops go-macos/statusitem.
  • Freeze fixapp.ActivateAfterVault reads the vault on a background goroutine (the keychain prompt no longer blocks the render thread) and applies the result — reactivating the authenticated providers, importing a browser session — on the render thread via the existing post queue. Replaces HydrateAndActivate.
  • onReady now comes from application (fired after the first frame), so windowapp drops its own SetOnReady/frame-counter.
  • cmd/bundle assembles News Reader.app via go-macos/appbundle (icns from the 512px appicon.Icon); nr-build runs it so the Dock tile + name + working tray exist. Build tooling, excluded from the coverage gate like the example plugins.

Tests

Full suite green, 100% coverage on the gated packages, -race clean, gofmt + vet clean; builds for darwin, linux, windows. The .app assembly was verified (valid Contents/{MacOS, Resources/News Reader.icns, Info.plist}).

🤖 Generated with Claude Code

tannevaled and others added 9 commits August 30, 2026 17:28
…eeze)

Replace the reader's private window glue and the non-working go-macos/statusitem
tray with go-widgets/application v0.1.0, the cross-platform app-lifecycle sibling
to go-widgets/window.

- Delete internal/window: its Handler/Config contract and run loop were extracted
  into go-widgets/application. windowapp and the cmd import application (aliased
  as `window`, so every type reference is unchanged).
- present_reader.go builds an application.Spec (name, identifier, the 32px
  appicon.Tray, and a go-widgets/tray "Quit" menu) and calls application.Run,
  which owns the run loop and attaches the tray to it. A bare executable can NOT
  own a status item, which is why the #271 statusitem tray never appeared; the
  tray now works once the binary runs inside the .app bundle.
- Drop the go-macos/statusitem code and dependency.
- Freeze fix: app.ActivateAfterVault reads the vault on a background goroutine
  (so the keychain prompt never blocks the render thread) and applies the result
  — reactivating the authenticated providers, importing a browser session — on
  the render thread via the existing post queue. The window no longer beach-balls
  behind the password panel. Replaces HydrateAndActivate.
- application.Run fires onReady after the first frame, so windowapp no longer
  carries its own SetOnReady/frame-counter.
- cmd/bundle assembles "News Reader.app" via go-macos/appbundle (icns from the
  512px appicon.Icon); nr-build runs it so the Dock tile + name + tray exist.
  Build tooling, excluded from the coverage gate like the example plugins.

Full suite green, 100% coverage on the gated packages, -race clean, gofmt + vet
clean; builds for darwin, linux, windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ady)

The tray never appeared because application v0.1.0 attached it before the window
loop and NSApplication existed; v0.1.1 attaches it from onReady, once both are up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
internal/window was removed when the reader moved to go-widgets/application; the
separate 'go test ./internal/window/...' step now finds no package and fails.
The native window loop is covered in go-widgets/application's own CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tray v0.6.0 links its NSStatusItem/DBus/win32 backend per-platform without a
build tag (the gate now selects by file shape), so the reader gets a working
tray from a plain 'go build' — no -tags tray_native.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unlocking the vault prompted once per secret (one keychain item per account
field), so a user with several sign-ins was asked several times — and with
Biometric unlock on, that was several Touch ID prompts.

Now every secret lives in a single JSON blob under one keychain account
(vaultAccount): hydrateSecrets reads it with ONE store.Get, pushSecrets writes it
with ONE store.Set. A pre-blob install is migrated on first load — the old
per-ref items are read once (the only launch that prompts per item), folded into
the blob, and deleted; idempotent thereafter. Biometric gate, the unsigned-build
fallback, the file fallback, and plaintext-on-disk migration are all preserved.
So startup is one prompt — one Touch ID with biometric unlock on.

100% coverage (TestHydrateReadsVaultOnce proves the single Get;
TestMigrateFromPerRefItems the one-time migration; corrupt-blob + write-error
paths covered); gofmt + vet clean; builds darwin/linux/windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Touch ID was fully wired but the BiometricUnlock preference defaulted OFF, so a
signed build still prompted for the keychain password and never offered Touch ID
unless the user found the toggle. Make it a tri-state *bool (like the other
opt-in-by-default settings): unset means DefaultBiometricUnlock (ON), an explicit
false persists an opt-out. Default() seeds it and Normalize() backfills an unset
field — so an existing settings file (which never wrote the old false-with-
omitempty) becomes Touch ID on, and a fresh install offers it without hunting for
a switch. On an unsigned build the write silently falls back to an ungated item,
so on-by-default is safe everywhere.

Read through Settings.BiometricUnlockEnabled(); the scene projects an explicit
value once the user has interacted. 100% coverage, gofmt + vet clean, builds
darwin/linux/windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… build)

The keychain SecAccessControl user-presence route to Touch ID needs the
data-protection keychain, whose keychain-access-groups entitlement AMFI rejects
on a self-signed app (it then fails to launch), so it always fell back to a
password. Gate the vault READ behind a LocalAuthentication (LAContext) prompt
instead: it asks the Secure Enclave for a Touch ID (or device-password)
attestation with NO keychain entitlement — only that the process is a .app
bundle, which the reader now is.

- internal/biometric: a portable Authenticate seam. darwin uses
  go-macos/localauthentication (PolicyOwner, so a Touch-ID-less Mac falls back to
  the device password); it skips gating when no owner check is available so a
  user is never locked out. Off macOS it is a no-op.
- app.ActivateAfterVault prompts once, on its background goroutine, before
  HydrateSecrets when biometric unlock is on; a denial opens the reader without
  the sign-ins rather than failing. With the single consolidated vault item that
  is ONE Touch ID at startup.
- Secrets now always store ungated (the app stops enabling the broken keychain
  user-presence path); the LAContext gate is the biometric.

100% coverage on the gated packages (the darwin LAContext call is a native
boundary in a _darwin.go file, not compiled on the linux gate); gofmt + vet
clean; builds darwin/linux/windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… password

Touch ID appeared, then a keychain password prompt — because the keychain grants
silent access only to the app signature on the item's ACL, and the item was
first written before the signature was stable. Prompting Touch ID before that
grant is in place stacks two prompts.

Hold the biometric gate until the vault has been unlocked once under this app
(new internal BiometricPrimed flag): the first unlock goes through the keychain's
own grant (a password / "Always Allow", which now sticks under the stable
signature), then the flag is set and persisted. From the next launch the keychain
read is silent and Touch ID is the ONLY prompt. Settings.HasStoredSecret gates
the priming on there actually being a secret to protect.

100% coverage (priming run, primed run allow/deny, disabled, HasStoredSecret);
gofmt + vet clean; builds darwin/linux/windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ration

Legacy per-item secrets whose ACL trusts a code signature that no longer exists
read back errSecUserCanceled (-128), so migratePerRefItems recovered nothing and
— guarded by `len(recovered)==0` — wrote no blob, leaving the per-item read path
live: every launch re-prompted for the dead items (the "3 password prompts each
time" bug).

Write the blob whenever a migration was ATTEMPTED (there are secret-carrying
accounts), even when nothing was recovered: its presence alone retires the
per-item path, so an unreadable legacy item is left behind unread instead of
re-prompting forever. Recovered secrets still go in and their old items are still
deleted. Regression guard: TestMigrateBrokenLegacyItemsStillWritesBlob.

100% coverage; gofmt + vet clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit cb727a7 into main Aug 30, 2026
13 checks passed
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