Skip to content

Review follow-ups: settings persistence, notifications, errors, tests, Swift 6 - #3

Open
pch wants to merge 15 commits into
t3/5bd0c99dfrom
t3/review-followups
Open

pch wants to merge 15 commits into
t3/5bd0c99dfrom
t3/review-followups

Conversation

@pch

@pch pch commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #2. Fixes the issues the code review left open. Each issue is its own commit:

Commit Issue
Move settings into a SettingsStore and adopt Observation 5. AppState is too big (no behavior change)
Save settings as soon as they change 1. Settings only saved on Done
Notify when runs that were waiting, pending or requested complete 2. Missed completion notifications
Surface errors from loading runs and the repository list 3. Swallowed errors
Start the app state only once 4. Startup can run more than once
Build in Swift 6 language mode 7. Strict concurrency
Add an ActionBarTests unit test target and run it in CI 6. No tests
Keep polling with the settings from when Settings opened Follow-up to 1
Track run statuses across filtered runs and failed repos Follow-up to 2 and 3
Don't lose refreshes or apply results after sign-out Races found while reviewing
Fail a multi-repo fetch only when every repo failed Follow-up to 3 (from a second review)
Don't show a cancelled poll's results or skip the next fetch Race (from a second review)
Stop the installation check from starting polling after sign-out Race (from a second review)
Apply only the latest repository list load Race (from a second review)

The refactor comes first, so the fixes are written once against the new structure.

User-visible behavior changes

  1. Settings save when you change them, and apply when you press Done. Selected repos, workflow filter mode and text, max runs per repo, and "Include runs from other users" are written to UserDefaults on every change. Before, they were only saved when you pressed Done, and a relaunch after closing the menu without Done reverted them. While Settings is open, background refreshes keep using the settings from when it was opened. Before, they picked up half-finished edits, so pressing None and then ticking repos could have a refresh replace your picks with "repos that have recent runs". Done applies the new settings and refreshes right away. If you leave Settings open and relaunch, the saved edits apply on launch. Reset restores the repo selection from when the panel was opened. That snapshot is now taken when Settings opens, not in onAppear.
  2. More completion notifications. You now get a completion notification for runs that were last seen as waiting, pending or requested and then completed. Before, only in_progress and queued counted. Status tracking also covers runs that were hidden by the workflow filter or the per-repo limit on the previous fetch, and runs in a repo that failed to load once. Each of those used to lose its "was running" status, so it never notified. Notifications are still only sent for runs shown in the menu.
  3. Partial failures are shown. If some repositories' runs fail to load (network error, unexpected status, or a response that doesn't decode) while others load, the menu shows a line above the list: "Couldn't load runs for owner/repo", or "Couldn't load runs for N repositories" with the names in a tooltip. The runs that did load still show. The full error view now appears only when every requested repo fails. Before, it also appeared when the repos that didn't fail simply had no runs (or Actions disabled), so one failing repo could hide the whole list.
  4. Bad responses are no longer shown as "no runs". A response that fails to decode used to look like a repo with no runs. It now counts as a failed repo (see 3).
  5. Repository list errors show in Settings. If loading the repo list fails, Settings shows "Couldn't load repositories: " in red under the Repositories header. The previous list is kept, as before. The error clears on the next attempt or on sign-out.
  6. No duplicate startup. If the menu bar label's onAppear fired more than once, each call validated the token again and restarted monitoring and polling. Now only the first call does anything. This should only be noticeable as fewer duplicate requests at launch.
  7. Refreshes aren't dropped. Pressing Done or "Refresh now" while a background fetch was in flight did nothing, so new settings could take a full polling interval to show. The refresh now runs right after the current fetch, and results fetched with settings that changed in the meantime are discarded.
  8. Sign-out is final. Requests still in flight when you sign out (runs, repository list, token check, installation checks including the 10-second background one) no longer write runs, a repo selection, errors or a username afterwards, and no longer start polling.
  9. Changing the polling interval mid-fetch is clean. It used to cancel the fetch in a way that could show some repos as failed or "Network error: cancelled", and skip the next fetch until the new interval passed. The cancelled fetch is now ignored and a new one starts right away.
  10. The repository list shows the latest load. Opening Settings and pressing refresh quickly no longer lets an older response overwrite a newer one, or leave a stale red error under a list that loaded. The spinner stays until the latest load finishes.

Unchanged on purpose: the polling interval still saves right away and survives sign-out. The Keychain service com.menuapp.github-actions and all UserDefaults keys are the same.

Notes

1. Why save on change instead of saving or reverting when the panel closes

  • Saving on change makes the screen, memory and disk agree. To keep half-finished edits out of polling, AppState takes a SettingsStore.RunQuery snapshot in openSettings(). Polls use it until closeSettings().
  • It matches the polling interval, which already saved immediately.
  • A MenuBarExtra window has no reliable "panel went away" moment. Closing the menu doesn't dismiss the settings view, so save-or-revert-on-close would need window notifications and would be easy to get wrong.
  • A side effect: a non-empty repo selection is now always saved. So the check for whether the selectedRepositories key exists became redundant, and an empty selection alone decides whether to fall back to all repos. In every reachable state the result is the same as before.

5. Settings store and Observation

SettingsStore (@Observable) owns the persisted preferences and the repo selection helpers. The Reset button uses the snapshot AppState takes when Settings opens. AppState is @Observable too. Views use @Environment(AppState.self), and SettingsView takes the store as @Bindable. UpdaterService stays an ObservableObject because it bridges Sparkle's KVO property through Combine.

@Observable runs didSet for assignments made in init. The store guards against that so loading doesn't write values back. A test covers it.

3. Errors

fetchRecentRuns now returns RecentRuns(runs:failedRepos:). The existing rules still apply: an invalid token or a rate limit on any repo throws, and if every repo fails, the first error is thrown.

7. Swift 6

Both targets build in Swift 6 language mode, so complete concurrency checking is on. The only thing it flagged was the NSWindow.didBecomeKeyNotification observer mutating NSPanel.collectionBehavior, which is main actor-isolated, from a Sendable block. AppDelegate is now explicitly @MainActor and uses a selector-based observer.

The UNUserNotificationCenterDelegate methods are nonisolated, which they effectively already were. UserNotifications doesn't promise to call its delegate on the main thread, and an isolated conformance would trap at runtime if it didn't.

6. Tests

  • 46 tests in 6 suites, using Swift Testing. They cover RunStatus(from:), filtered(by:patterns:), limited(toRunsPerRepo:), overallStatus, the RunHistory notification rules, when a multi-repo fetch fails outright (GitHubAPIError.failingWholeFetch), RelativeTime.format, model decoding, and SettingsStore. AppState and the networking in GitHubAPIClient aren't unit-tested: the first reads the Keychain, the second needs a URLSession stub.
  • The test bundle has no test host. It compiles the model files it tests itself, so running the tests never launches the app. That matters because a launched, differently signed build can get errSecAuthFailed reading the Keychain item, and KeychainService.loadToken() then deletes the real token.
  • To test the app's real decoder configuration, it moved to JSONDecoder.gitHubAPI().
  • Settings tests use a separate defaults suite, com.ptrchm.actionbar.tests.
  • CI runs xcodebuild … CODE_SIGNING_ALLOWED=NO test after the Release build. make test does the same locally.

Verification

  • xcodebuild -scheme ActionBar -configuration Release -destination 'generic/platform=macOS' CODE_SIGNING_ALLOWED=NO build: succeeds with no warnings.
  • xcodebuild -scheme ActionBar -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test: 46 tests pass.
  • The app was not launched, for the Keychain reason above. Not checked by eye: the UI changes (the partial-failure line in the menu and the repo list error in Settings), the menu updating after the @Observable switch, and the Swift 6 runtime isolation checks. For those, run a signed build: open and close Settings, get a notification and click it, and check for updates.

🤖 Generated with Claude Code

pch and others added 15 commits October 21, 2026 22:14
AppState handled login, polling, settings and repo selection. The
persisted preferences and the repo selection helpers now live in a
small SettingsStore, and the snapshot used by the Reset button is
view state in SettingsView.

Both classes use @observable instead of ObservableObject; views read
AppState from the environment and bind to the store with @bindable.
UpdaterService stays an ObservableObject because it bridges Sparkle's
KVO property with Combine.

No behavior change: same UserDefaults keys, same save points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edits in the settings panel were applied in memory right away but only
written to UserDefaults when Done was pressed, so closing the menu
without Done left the app running with settings that a relaunch would
lose. Each setting now saves itself when it changes, like the polling
interval already did. Done just closes the panel and refreshes.

Since a non-empty selection is now always saved, checking whether the
selectedRepositories key exists is redundant; an empty selection alone
decides whether to fall back to all repos.

The Reset button still restores the selection from when the panel was
opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completion notifications only fired for runs previously seen as
in_progress or queued. RunStatus also treats waiting, pending and
requested as queued, and runs in those states completed silently.
The rule now uses RunStatus.isActive, and lives in
[WorkflowRun].newlyCompleted(since:) so it can be tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workflow runs response that failed to decode made fetchRunsForRepo
return [] as if the repo had no runs. It now throws decodingError like
the other requests. fetchRecentRuns still returns partial results when
only some repos fail, but now reports which ones did, and the menu
shows "Couldn't load runs for <repo>" (or "for N repositories", with
the names in a tooltip) above the list.

fetchAvailableRepos ignored errors entirely, leaving an empty or stale
list with no explanation. It still keeps the previous list on failure,
but settings now shows the error under the repository header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
appState.start() runs from the MenuBarExtra label's onAppear, which can
fire more than once. Each extra call validated the token again and
started another monitoring pass, restarting polling. start() now
ignores every call after the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swift 6 turns on complete concurrency checking. The only thing it
flagged was AppDelegate mutating NSPanel.collectionBehavior, which is
main actor-isolated, from the Sendable block-based observer.

AppDelegate is now explicitly @mainactor, and the window observer is a
selector-based observer calling a main actor method (windows become key
on the main thread). The UNUserNotificationCenterDelegate methods are
nonisolated, as they effectively were before: UserNotifications doesn't
promise to call them on the main thread, and an isolated conformance
would trap at runtime if it didn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swift Testing coverage for the pure logic:
- RunStatus(from:) for every status and conclusion, and isActive
- [WorkflowRun].filtered(by:patterns:), limited(toRunsPerRepo:),
  overallStatus and the newlyCompleted(since:) notification rule
- RelativeTime.format
- Decoding the API and OAuth models with the app's decoder
- SettingsStore loading, saving on change, and the sign-out reset

The bundle has no test host. It compiles the model files it tests
itself, so running the tests never launches the app. A launched,
differently signed app could read the Keychain item, get
errSecAuthFailed and delete the user's token. The JSON decoder setup
moves to JSONDecoder.gitHubAPI() so the tests use the app's real
configuration.

The test target is in the shared scheme, CI runs it after the Release
build, and there's a `make test` target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings now save as they change, so a poll during editing used half-finished
values. With an empty selection (after pressing None) it would replace the
user's in-progress picks with the repos that have recent runs. Polls now use a
snapshot taken when Settings opens, and Done applies the new settings. The
Reset button uses the same snapshot instead of one taken in onAppear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The notification history only kept the visible runs, and was rebuilt from
scratch on each fetch. A run hidden for one fetch (by the filter or the
per-repo limit) or in a repo that failed to load for one fetch lost its
"was running" status, so it never notified when it finished. RunHistory now
records every fetched run and keeps the last status for repos that failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refresh asked for while a fetch was in flight (like the one Done triggers)
was dropped, so new settings could wait a full polling interval. It now runs
once the current fetch finishes, and results fetched with settings that have
since changed are discarded. Requests that finish after sign-out no longer
write runs, the repo selection, errors or the username, or start polling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fetch threw the first per-repo error whenever no runs came back, so one
failing repo next to repos with no runs (or Actions disabled) replaced the
menu with a full error instead of the partial-failure line. Now it only fails
outright when every requested repo failed, or on an invalid token or rate
limit. The rule moves next to GitHubAPIError so it can be unit tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing the polling interval cancels the poll task mid-fetch. Its unfinished
repos came back as network errors, so the menu could show some repos as
failed, or "Network error: cancelled", and the new poll's first fetch was
skipped because the old one was still in flight. A cancelled fetch now throws
CancellationError and is ignored, and the new poll waits for the old one to
end before fetching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A background installation check already in flight at sign-out could still
call installationFound() and start polling, including for the next account
when that account has no installation yet. The check now stops if the task
was cancelled or the session changed, and waiting for an installation stops
any poll that's running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening Settings and pressing refresh started two loads, and whichever
finished last won. An older failure could show the red error under a list
that had loaded, or an older list could replace a newer one, and the spinner
stopped when the first one finished. A new load now cancels the previous
one, and only the current load updates the list, the error and the spinner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the AppDelegate conflict with "Only set the panel collection
behavior when it's missing": keeps the selector-based observer that Swift 6
needs and adds the check that skips panels which already have the behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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