@@ -68,7 +68,8 @@ final class ViewModel: ObservableObject {
6868 /// A human-readable error message, shown when the balance can't be fetched.
6969 @Published var errorMessage: String?
7070
71− /// The API key entered by the user (loaded from Keychain on init).
71+ /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
72+ /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
7273 @Published var apiKeyInput: String = ""
7374
7475 /// Timestamp of the last successful balance fetch.
@@ -105,6 +106,13 @@ final class ViewModel: ObservableObject {
105106 /// Flag to suppress the didSet during initial setup.
106107 private var isInitializingLaunchAtLogin = true
107108
109+ /// The key requests actually go out with: what is in the Keychain, not what is
110+ /// currently in the text field. Keeping these separate stops a timer or wake
111+ /// refresh from firing with a half-typed key — or, if the field has been cleared
112+ /// to paste a new one, from taking the empty-key path and wiping the balance,
113+ /// last-updated time and history out from under the user.
114+ private var activeAPIKey: String
115+
108116 private let checker: CreditsChecking
109117 private let defaults: UserDefaults
110118
@@ -129,7 +137,9 @@ final class ViewModel: ObservableObject {
129137 init(checker: CreditsChecking = CreditsChecker(), defaults: UserDefaults = .standard) {
130138 self.checker = checker
131139 self.defaults = defaults
132− apiKeyInput = KeychainHelper.load() ?? ""
140+ let storedKey = KeychainHelper.load() ?? ""
141+ apiKeyInput = storedKey
142+ activeAPIKey = storedKey
133143 // Assigning in init doesn't fire didSet, so this load doesn't re-post
134144 // the change notification or write back to defaults.
135145 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
@@ -154,12 +164,17 @@ final class ViewModel: ObservableObject {
154164 }()
155165
156166 /// Formats an integer with thousands grouping separator.
157− private func formatBalance(_ value: Int) -> String {
158− Self.balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
167+ private static func formatBalance(_ value: Int) -> String {
168+ balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
159169 }
160170
161171 /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
162− var statusBarItemText: String {
172+ ///
173+ /// Takes the balance and loading flag as parameters rather than reading the
174+ /// properties: `@Published` publishes in `willSet`, so a Combine subscriber that
175+ /// called back into the view model would see the *pre-change* value and render a
176+ /// title one update behind.
177+ static func statusBarText(balance: Int?, isLoading: Bool) -> String {
163178 if isLoading && balance == nil {
164179 return "⚡…"
165180 }
@@ -169,10 +184,15 @@ final class ViewModel: ObservableObject {
169184 return "⚡?"
170185 }
171186
187+ /// The text to show in the menu bar for the current state.
188+ var statusBarItemText: String {
189+ Self.statusBarText(balance: balance, isLoading: isLoading)
190+ }
191+
172192 /// The formatted balance string for display in the popover.
173193 var formattedBalance: String {
174194 guard let balance = balance else { return "?" }
175− return formatBalance(balance)
195+ return Self.formatBalance(balance)
176196 }
177197
178198 /// The color for the balance display based on thresholds.
@@ -226,8 +246,10 @@ final class ViewModel: ObservableObject {
226246 func refresh() {
227247 refreshTask?.cancel()
228248
229− let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
249+ // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
250+ let key = activeAPIKey
230251 guard !key.isEmpty else {
252+ refreshTask = nil
231253 balance = nil
232254 errorMessage = nil
233255 lastUpdated = nil
@@ -297,6 +319,11 @@ final class ViewModel: ObservableObject {
297319 // MARK: - Low Balance Notification
298320
299321 private func sendLowBalanceNotification(balance: Int) {
322+ // Build the body here, on the main actor. `getNotificationSettings` calls back on
323+ // a background queue, and the shared `balanceFormatter` is main-actor state — the
324+ // status bar title formats against the very same instance.
325+ let body = "Low Hyper credits: \(Self.formatBalance(balance)) remaining"
326+
300327 let center = UNUserNotificationCenter.current()
301328 center.getNotificationSettings { settings in
302329 guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
@@ -304,12 +331,13 @@ final class ViewModel: ObservableObject {
304331 }
305332 let content = UNMutableNotificationContent()
306333 content.title = "Low Hyper Credits"
307− let balanceStr = Self.balanceFormatter.string(from: NSNumber(value: balance)) ?? "\(balance)"
308− content.body = "Low Hyper credits: \(balanceStr) remaining"
334+ content.body = body
309335 content.sound = .default
310336
337+ // A fixed identifier makes a later alert replace the delivered one in place,
338+ // which updates the text without ever notifying the user again.
311339 let request = UNNotificationRequest(
312− identifier: "low-balance-alert",
340+ identifier: "low-balance-alert-\(UUID().uuidString)",
313341 content: content,
314342 trigger: nil
315343 )
@@ -329,6 +357,8 @@ final class ViewModel: ObservableObject {
329357 errorMessage = "Could not delete API key from Keychain. It may be locked."
330358 return
331359 }
360+ apiKeyInput = ""
361+ activeAPIKey = ""
332362 // refresh() with an empty key clears balance, error, history, and
333363 // cancels any in-flight task from the previous key.
334364 refresh()
@@ -338,9 +368,20 @@ final class ViewModel: ObservableObject {
338368 errorMessage = "Could not save API key to Keychain. It may be locked."
339369 return
340370 }
341− // Clear history from any previous key so the sparkline and trend
342− // don't mix data from different accounts.
343− history.removeAll()
371+ // A different key is a different account, so everything derived from the old
372+ // one has to go — not just the history behind the sparkline and trend. A
373+ // carried-over balance would be displayed as the new account's until the
374+ // fetch lands, and it would stand in as `previousBalance` for the low-balance
375+ // threshold check, which would then compare two unrelated accounts.
376+ if key != activeAPIKey {
377+ balance = nil
378+ lastUpdated = nil
379+ errorMessage = nil
380+ history.removeAll()
381+ }
382+ // Show the trimmed key that was actually stored.
383+ apiKeyInput = key
384+ activeAPIKey = key
344385 refresh()
345386 }
346387
@@ -359,10 +400,20 @@ final class ViewModel: ObservableObject {
359400 // MARK: - Launch at Login
360401
361402 func updateLaunchAtLogin(_ enabled: Bool) {
362− if enabled {
363− try? SMAppService.mainApp.register()
364− } else {
365− try? SMAppService.mainApp.unregister()
403+ do {
404+ if enabled {
405+ try SMAppService.mainApp.register()
406+ } else {
407+ try SMAppService.mainApp.unregister()
408+ }
409+ } catch {
410+ errorMessage = "Could not \(enabled ? "enable" : "disable") launch at login: "
411+ + error.localizedDescription
412+ // Snap the switch back to what the system actually did, suppressing the
413+ // didSet so this doesn't recurse into another register/unregister attempt.
414+ isInitializingLaunchAtLogin = true
415+ launchAtLogin = SMAppService.mainApp.status == .enabled
416+ isInitializingLaunchAtLogin = false
366417 }
367418 }
368419}