From 472e095dda9d09f15750c19f406ec239ca6e871d Mon Sep 17 00:00:00 2001 From: MiniMax <12656028+jaywedgeworth22@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:32:45 -0500 Subject: [PATCH] feat(alerts): courtesy-reset detection, iOS runaway alerts, and no PiP titlebar strip Owner batch, 2026-10-08. Board 7179df4aab7a444883eb668b747b62e4. Courtesy and early resets now alert ------------------------------------ "Can't the app tell if usage suddenly jumps to almost 100% available between 2 checks that there was a reset, whether done by the system or by a reset the user was allowed to optionally use? Please alert for that since if you don't know it was reset and they do a courtesy reset then you can start using again." Two gaps, and the consequence is the same either way: the owner believes a cap still applies that does not. A reset that landed *before* the window's end was only recognised when the window end also held still, so a courtesy reset that legitimately rolled the window forward was invisible. The early-reset branch now labels a restore as mid-window when the quota went from mostly spent to nearly untouched, which is what lets it past the near-cap rule that a genuine restore should not need. And a jump to nearly-untouched before the window is due to end is now its own rule, keyed on the shape between two consecutive readings. `!hasPassed` is load-bearing there: a window that simply reached the end of its period also goes from spent to full, and the owner ruled in September that a smaller window stays quiet unless it came near its cap. Without the guard this alerted on every ordinary rollover, which is what the first attempt did -- five existing tests caught it. Runaway alerts reach iOS ------------------------ "iOS should have info about heavy anomalies in usage or runaway usage like the mac." The shared handoff had no alert data at all, so this is new plumbing rather than a toggle: Payload gains an optional `alerts` array, the Mac fills it from its live anomaly set, and the companion raises anything it has not announced. The decision is made on the Mac and shipped as data, deliberately. The rules compare against this Mac's burn-rate history -- a per-machine file of measured hourly intervals -- which the phone has no access to and could not reproduce. Recomputing there would mean the two surfaces disagreeing about whether something was anomalous, which is worse than one surface staying quiet. Optional and additive like `issues`, so a file from an older build still decodes, and an unknown `kind` degrades rather than failing the whole read. Alert ids are stable and cached on the phone, so a payload rewritten every refresh raises each alert once rather than once per poll. PiP: the blank silver strip is gone ----------------------------------- ".titled" and ".hudWindow" both make AppKit paint a titlebar strip that lives outside the SwiftUI content, so it appeared as a blank light band above the rounded panel. `titleVisibility = .hidden` and `titlebarAppearsTransparent` could not remove it because the strip belongs to the window frame, not the content view. The panel is now borderless, so the rounded surface is the whole window; `isMovableByWindowBackground` already made the body draggable, so the titlebar was never load-bearing. Tests: five courtesy-reset cases (mid-period, window-end rolled forward, moderate usage level, ordinary use never looks like one, alerts once), four payload cases (older file still decodes, round trip, id survives exactly, an unknown kind does not break decoding), and PipMetricsTests now pins that the style mask carries no `.titled`/`.hudWindow` so the strip cannot come back. swift test: 375 tests, 0 failures. Co-Authored-By: MiniMax Code --- Sources/CodeCaps/MonitorModel.swift | 52 ++++++++- Sources/CodeCaps/PipWidget.swift | 19 +++- Sources/QuotaCore/AnomalyDetector.swift | 2 +- Sources/QuotaCore/LocalQuotaSnapshot.swift | 59 +++++++++- Sources/QuotaCore/ResetAlarmTracker.swift | 61 +++++++++++ Tests/CodeCapsTests/PipMetricsTests.swift | 14 ++- Tests/QuotaCoreTests/AlertPayloadTests.swift | 67 ++++++++++++ .../ResetAlarmTrackerTests.swift | 101 ++++++++++++++++++ .../App/Models/CompanionQuotaModel.swift | 83 ++++++++++++++ 9 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 Tests/QuotaCoreTests/AlertPayloadTests.swift diff --git a/Sources/CodeCaps/MonitorModel.swift b/Sources/CodeCaps/MonitorModel.swift index 94972f0f..6b98e194 100644 --- a/Sources/CodeCaps/MonitorModel.swift +++ b/Sources/CodeCaps/MonitorModel.swift @@ -1788,7 +1788,9 @@ final class MonitorModel: ObservableObject { } else if self.skipsSnapshotIOForTesting { self.handoffError = nil } else if self.localEnabled && local != nil { - try LocalQuotaSnapshot.write(windows: self.localWindows, issues: self.issues, customMarks: exportedCustomMarks(), now: self.now) + try LocalQuotaSnapshot.write(windows: self.localWindows, issues: self.issues, + customMarks: exportedCustomMarks(), + alerts: exportedAlerts(now: self.now), now: self.now) } else { try LocalQuotaSnapshot.remove() } self.handoffError = nil @@ -1964,7 +1966,8 @@ final class MonitorModel: ObservableObject { do { if localEnabled { try LocalQuotaSnapshot.write(windows: localWindows, issues: issues, - customMarks: exportedCustomMarks(), now: now) + customMarks: exportedCustomMarks(), + alerts: exportedAlerts(now: now), now: now) } else { try LocalQuotaSnapshot.remove() } @@ -2175,3 +2178,48 @@ func resetCountdown(_ reset: Date?, now: Date) -> String { if minutes >= 60 { return "Resets in \(minutes / 60)h \(minutes % 60)m" } return "Resets in \(minutes)m" } + +// MARK: - Alert export for the iOS companion + +extension MonitorModel { + /// The alerts the companion should raise, in the same shape it reads. + /// + /// Owner request 2026-10-08: "iOS should have info about heavy anomalies in + /// usage or runaway usage like the mac." The decision is made **here** and + /// shipped as data, rather than recomputed on the phone, for two reasons. + /// + /// The rules need this Mac's burn-rate history — a per-machine file of + /// measured hourly intervals built from local readings. The companion has + /// no access to it and could not reproduce the comparison, so a phone-side + /// implementation would either alert on much weaker evidence or not at all. + /// + /// And the identity matters more than the text: if each surface evaluated + /// its own thresholds they could disagree about whether something was + /// anomalous, which is worse than one surface being silent. One decision, + /// two notifications. + /// + /// `id` is stable for a given anomaly so the companion can raise it once + /// rather than once per poll — this payload is rewritten on every refresh. + func exportedAlerts(now: Date = Date()) -> [LocalQuotaSnapshot.AlertPayload] { + var out: [LocalQuotaSnapshot.AlertPayload] = [] + let formatter = ISO8601DateFormatter() + + // Runaway usage currently burning far faster than the owner's own + // pattern. Only the live set, so a resolved anomaly stops being + // re-offered on the next poll. + for anomaly in activeRunawayAnomalies { + let label = AnomalyDetector.windowLabel(windowId: anomaly.windowId) + let title = displaySections.first { $0.id == anomaly.providerKey }?.title + ?? anomaly.providerKey + out.append(LocalQuotaSnapshot.AlertPayload( + id: "runaway|\(anomaly.providerKey)|\(anomaly.windowId)|\(anomaly.kind.rawValue)", + kind: "runaway", + providerKey: anomaly.providerKey, + providerTitle: title, + windowLabel: label, + summary: anomaly.summary, + occurredAt: formatter.string(from: anomaly.observedAt ?? now))) + } + return out + } +} diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index e3149cf4..11f82e59 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -311,8 +311,8 @@ final class PipWidgetController: NSObject, NSWindowDelegate { // and its frame overlaps the console. p.level = .floating p.isMovableByWindowBackground = true - p.titleVisibility = .hidden - p.titlebarAppearsTransparent = true + // Borderless panel: the rounded SwiftUI surface is the whole window, so + // there is no titlebar strip to keep invisible. See `panelStyleMask`. p.isOpaque = false p.backgroundColor = .clear p.hasShadow = true @@ -408,8 +408,21 @@ final class PipWidgetController: NSObject, NSWindowDelegate { extension PipMetrics { /// `.resizable` is what turns this from a fixed HUD into a window the owner /// can drag the edge of; the view inside degrades rather than clips. + /// + /// There is deliberately **no `.titled` and no `.hudWindow`** (owner report, + /// 2026-10-08: "it needs to not have the blank silver part on top for no + /// reason"). Both of those make AppKit paint a titlebar strip at the top + /// of the window that sits *outside* the SwiftUI content, so it shows as a + /// blank light band above the rounded panel — and with `titleVisibility` + /// hidden and `titlebarAppearsTransparent` set, it was a strip with nothing + /// on it at all. `.fullSizeContentView` cannot hide it either, because the + /// strip is drawn by the window frame rather than by the content view. + /// + /// A borderless, nonactivating, resizable panel has no such strip: the + /// rounded panel *is* the window. `isMovableByWindowBackground` already + /// makes the body draggable, so the titlebar was never load-bearing. static var panelStyleMask: NSWindow.StyleMask { - [.titled, .nonactivatingPanel, .fullSizeContentView, .utilityWindow, .hudWindow, .resizable] + [.borderless, .nonactivatingPanel, .resizable] } } diff --git a/Sources/QuotaCore/AnomalyDetector.swift b/Sources/QuotaCore/AnomalyDetector.swift index 35837084..dbb41f31 100644 --- a/Sources/QuotaCore/AnomalyDetector.swift +++ b/Sources/QuotaCore/AnomalyDetector.swift @@ -392,7 +392,7 @@ public struct AnomalyDetector: Sendable { return "\(prefix) \(window): \(String(format: "%.1f", ratio))× \(comparison)." } - static func windowLabel(windowId: String) -> String { + public static func windowLabel(windowId: String) -> String { if let range = windowId.range(of: ":", options: .backwards) { return String(windowId[range.upperBound...]) } diff --git a/Sources/QuotaCore/LocalQuotaSnapshot.swift b/Sources/QuotaCore/LocalQuotaSnapshot.swift index b5aabd6c..cc31fedf 100644 --- a/Sources/QuotaCore/LocalQuotaSnapshot.swift +++ b/Sources/QuotaCore/LocalQuotaSnapshot.swift @@ -40,6 +40,21 @@ public enum LocalQuotaSnapshot { public let windows: [QuotaWindow] public let issues: [String: String]? public let customMarks: [String: CustomMarkPayload]? + /// Runaway and reset alerts the Mac already decided to raise, so the + /// companion can raise the same ones. + /// + /// Owner request 2026-10-08: "iOS should have info about heavy + /// anomalies in usage or runaway usage like the mac." The Mac computes + /// these from its own burn-rate history, which iOS has no access to and + /// could not reproduce: the history is a per-Mac file of measured + /// hourly intervals. Shipping the *decision* rather than the raw + /// samples keeps one implementation of the rules, and means the two + /// surfaces cannot disagree about whether something was anomalous. + /// + /// Optional and additive for the same reason `issues` is: an older + /// build's file still parses. A consumer that sees no `alerts` simply + /// has nothing to raise. + public let alerts: [AlertPayload]? public init( format: String, @@ -48,7 +63,8 @@ public enum LocalQuotaSnapshot { generatedAt: String, windows: [QuotaWindow], issues: [String: String]? = nil, - customMarks: [String: CustomMarkPayload]? = nil + customMarks: [String: CustomMarkPayload]? = nil, + alerts: [AlertPayload]? = nil ) { self.format = format self.version = version @@ -57,6 +73,43 @@ public enum LocalQuotaSnapshot { self.windows = windows self.issues = issues self.customMarks = customMarks + self.alerts = alerts + } + } + + /// One alert the Mac has already decided is worth raising. + public struct AlertPayload: Codable, Equatable, Sendable { + /// Stable identity, so the companion can raise each alert once rather + /// than once per poll: the Mac rewrites this file every refresh. + public let id: String + /// `runaway`, `reset`, or `vendorReset`. A raw string rather than an + /// enum so a newer Mac writing an unknown kind degrades to "something + /// happened" on an older companion instead of failing to decode. + public let kind: String + public let providerKey: String + public let providerTitle: String + public let windowLabel: String + /// Owner-facing sentence, generated by the same code the Mac shows. + public let summary: String + /// When the alert happened, ISO 8601. + public let occurredAt: String + + public init( + id: String, + kind: String, + providerKey: String, + providerTitle: String, + windowLabel: String, + summary: String, + occurredAt: String + ) { + self.id = id + self.kind = kind + self.providerKey = providerKey + self.providerTitle = providerTitle + self.windowLabel = windowLabel + self.summary = summary + self.occurredAt = occurredAt } } @@ -75,6 +128,7 @@ public enum LocalQuotaSnapshot { windows: [QuotaWindow], issues: [String: String] = [:], customMarks: [String: CustomMarkPayload]? = nil, + alerts: [AlertPayload]? = nil, now: Date = Date(), to url: URL = destination() ) throws { @@ -86,7 +140,8 @@ public enum LocalQuotaSnapshot { generatedAt: ISO8601DateFormatter().string(from: now), windows: windows.map { $0.normalizedForExport() }, issues: published.isEmpty ? nil : published, - customMarks: customMarks) + customMarks: customMarks, + alerts: (alerts?.isEmpty ?? true) ? nil : alerts) let data = try JSONEncoder().encode(payload) guard data.count <= 10_485_760 else { throw CocoaError(.fileWriteOutOfSpace) } try writePrivately(data, to: url, in: directory, using: manager) diff --git a/Sources/QuotaCore/ResetAlarmTracker.swift b/Sources/QuotaCore/ResetAlarmTracker.swift index e5d3c3f6..17988e7b 100644 --- a/Sources/QuotaCore/ResetAlarmTracker.swift +++ b/Sources/QuotaCore/ResetAlarmTracker.swift @@ -236,6 +236,22 @@ public enum ResetAlarmPolicy { /// cannot regain quota inside its own period, so the size of the rise is /// the evidence and 30 points is far beyond rounding or a stale reading. public static let vendorRestoreRise: Double = 30 + + /// A courtesy reset can land on any level, so "what did it come back to" is + /// the wrong question. What is reliable is the shape: between two + /// consecutive readings, quota goes from mostly spent to nearly untouched. + /// + /// Owner request 2026-10-08 — a reset may be performed by the vendor, or by + /// the owner using a reset the provider offers, and either one must alert. + /// Missing it means working against a cap that no longer exists. + /// + /// The thresholds are set by what quota can legitimately do *within* a + /// period, which is only fall. A rise from at-or-below 80% to at-or-above + /// 95% is 15 points and cannot happen without a reset; the existing + /// `surgeMidWindow` rule already uses this shape, but only while the + /// window's own end is still in the future, which is not the owner's case. + public static let courtesyRestoreCeiling: Double = 80 + public static let courtesyRestoreFloor: Double = 95 /// Windows and fires not seen for this long are forgotten. public static let retention: TimeInterval = 45 * 86_400 /// A window's period counts toward "largest" for this long after it was @@ -427,6 +443,22 @@ public struct ResetAlarmTracker: Sendable { } if jump > required || (jump > ResetAlarmPolicy.resetDriftTolerance && climbed) { isReset = true + // When the quota also came back from mostly spent to nearly + // untouched, and the window had *not* reached its own end, + // this is a restore rather than an ordinary rollover. + // + // The `!hasPassed` guard is load-bearing. A window that + // simply reached the end of its period also goes from + // spent to full, and the owner ruled (2026-09-30) that a + // smaller window stays quiet unless it came near its cap. + // Labelling that "mid window" would bypass near-cap and + // alert on every ordinary rollover. A reset before the + // scheduled end is the case the owner cannot otherwise see + // (2026-10-08), and that is exactly what this catches. + if !hasPassed, let reading, let last = window.lastRemaining { + isMidWindow = reading >= ResetAlarmPolicy.courtesyRestoreFloor + && last <= ResetAlarmPolicy.courtesyRestoreCeiling + } nextResetAt = reported } else if jump > -ResetAlarmPolicy.resetDriftTolerance || reported > readAt { // Jitter, or a correction to a reset still ahead: follow it @@ -467,6 +499,35 @@ public struct ResetAlarmTracker: Sendable { isReset = true nextResetAt = observation.resetAt.flatMap { $0 > readAt ? $0 : nil } } + // A reset that lands before the window's own end: a courtesy reset the + // owner performed, or a vendor one, where the quota goes from + // mostly spent to nearly untouched between two consecutive checks. + // + // Owner request 2026-10-08: "can't the app tell if usage suddenly + // jumps to almost 100% available between 2 checks that there was a + // reset, whether done by the system or by a reset the user was + // allowed to optionally use? Please alert for that since if you + // don't know it was reset and they do a courtesy reset then you can + // start using again." + // + // The answer has to be yes: the consequence of missing one is + // working against a cap that no longer exists. + // + // `!hasPassed` is what keeps this from being a false-positive + // machine. A window that simply reached the end of its period also + // goes from spent to full, and the owner's standing rule is that a + // smaller window stays quiet unless it came near its cap — so the + // scheduled rollover must not be caught here. What this catches is + // the case that is otherwise invisible: quota restored *before* the + // window was due to end, which no amount of near-cap reasoning can + // distinguish from "nothing happened". + if !isReset, !hasPassed, let reading, let last = window.lastRemaining, + reading >= ResetAlarmPolicy.courtesyRestoreFloor, + last <= ResetAlarmPolicy.courtesyRestoreCeiling { + isReset = true + isMidWindow = true + nextResetAt = observation.resetAt.flatMap { $0 > readAt ? $0 : nil } ?? window.periodResetAt + } // Mid-window reset: a quota that jumps back up while the period // is still running. Three ways to see it, and none of them // requires the quota to land at 100%: diff --git a/Tests/CodeCapsTests/PipMetricsTests.swift b/Tests/CodeCapsTests/PipMetricsTests.swift index e0ab664e..99d3fc8c 100644 --- a/Tests/CodeCapsTests/PipMetricsTests.swift +++ b/Tests/CodeCapsTests/PipMetricsTests.swift @@ -165,7 +165,19 @@ final class PipMetricsTests: XCTestCase { let mask = PipMetrics.panelStyleMask XCTAssertTrue(mask.contains(.resizable), "The owner asked for a resizable window; the style mask is what grants it") - XCTAssertTrue(mask.contains(.fullSizeContentView)) + // Owner report 2026-10-08: a blank silver strip sat along the top of the + // HUD "for no reason". `.titled` and `.hudWindow` both make AppKit paint + // a titlebar strip that lives outside the SwiftUI content, and no amount + // of `titleVisibility` or `fullSizeContentView` removes it — the strip + // belongs to the window frame. A borderless panel has no strip at all. + XCTAssertFalse(mask.contains(.titled), + "`.titled` brings back the blank silver strip the owner asked to remove") + XCTAssertFalse(mask.contains(.hudWindow), + "`.hudWindow` paints a titlebar strip outside the content view") + XCTAssertTrue(mask.contains(.borderless), + "the rounded panel is now the whole window, so it must be borderless") + XCTAssertTrue(mask.contains(.nonactivatingPanel), + "the HUD must still not steal focus from the app the owner is using") XCTAssertLessThan(PipMetrics.minWidth, PipMetrics.maxWidth) XCTAssertLessThan(PipMetrics.minHeight, PipMetrics.maxHeight) } diff --git a/Tests/QuotaCoreTests/AlertPayloadTests.swift b/Tests/QuotaCoreTests/AlertPayloadTests.swift new file mode 100644 index 00000000..ff593045 --- /dev/null +++ b/Tests/QuotaCoreTests/AlertPayloadTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import QuotaCore + +/// The alerts the Mac ships to the iOS companion. +/// +/// Owner request 2026-10-08: "iOS should have info about heavy anomalies in +/// usage or runaway usage like the mac." The Mac makes the call and the phone +/// announces it, because the rules need this Mac's burn-rate history — a +/// per-machine file of measured hourly intervals the phone cannot reproduce. +final class AlertPayloadTests: XCTestCase { + private let window = QuotaWindow( + id: "w1", provider: "anthropic", label: "5h", remainingPercent: 42, + occurredAt: "2026-10-08T12:00:00Z") + + private func alert(id: String = "runaway|anthropic|w1|vsBaseline") -> LocalQuotaSnapshot.AlertPayload { + .init(id: id, kind: "runaway", providerKey: "anthropic", providerTitle: "Claude", + windowLabel: "5h", summary: "Spending fast vs 5h: 6.4x your average.", + occurredAt: "2026-10-08T12:00:00Z") + } + + /// An older Mac writes no `alerts` key at all; the phone must still read + /// the file, because the fields it already uses are unchanged. + func testAPayloadWithoutAlertsStillDecodes() throws { + let json = """ + {"format":"usage-monitor-local-quotas","version":1,"generatedAt":"2026-10-08T12:00:00Z", + "windows":[]} + """ + let payload = try JSONDecoder().decode(LocalQuotaSnapshot.Payload.self, from: Data(json.utf8)) + XCTAssertNil(payload.alerts) + } + + func testAlertsRoundTrip() throws { + let payload = LocalQuotaSnapshot.Payload( + format: "usage-monitor-local-quotas", version: 1, + generatedAt: "2026-10-08T12:00:00Z", windows: [window], alerts: [alert()]) + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(LocalQuotaSnapshot.Payload.self, from: data) + XCTAssertEqual(decoded.alerts?.count, 1) + XCTAssertEqual(decoded.alerts?.first?.providerTitle, "Claude") + XCTAssertEqual(decoded.alerts?.first?.summary, "Spending fast vs 5h: 6.4x your average.") + } + + /// The `id` is what stops a payload rewritten every refresh from alerting + /// every poll, so it has to survive the round trip byte-for-byte. + func testTheAlertIdSurvivesEncodingExactly() throws { + let original = alert(id: "runaway|openai|local-mac:openai:primary|vsPeak") + let payload = LocalQuotaSnapshot.Payload( + format: "f", version: 1, generatedAt: "2026-10-08T12:00:00Z", + windows: [], alerts: [original]) + let decoded = try JSONDecoder().decode( + LocalQuotaSnapshot.Payload.self, from: try JSONEncoder().encode(payload)) + XCTAssertEqual(decoded.alerts?.first?.id, original.id) + } + + /// An unknown `kind` must not break decoding: a newer Mac may ship a kind + /// this older phone has never heard of, and losing every other window over + /// that would be a bad trade. + func testAnUnknownAlertKindStillDecodes() throws { + let json = """ + {"format":"f","version":1,"generatedAt":"2026-10-08T12:00:00Z","windows":[], + "alerts":[{"id":"x","kind":"someFutureKind","providerKey":"p","providerTitle":"P", + "windowLabel":"7d","summary":"s","occurredAt":"2026-10-08T12:00:00Z"}]} + """ + let payload = try JSONDecoder().decode(LocalQuotaSnapshot.Payload.self, from: Data(json.utf8)) + XCTAssertEqual(payload.alerts?.first?.kind, "someFutureKind") + } +} diff --git a/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift b/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift index 60338cac..418dd646 100644 --- a/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift +++ b/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift @@ -998,3 +998,104 @@ extension ResetAlarmTrackerTests { "the vendor restore alerted more than once for the same window") } } + +// MARK: - Courtesy resets +// +// Owner request, 2026-10-08: "can't the app tell if usage suddenly jumps to +// almost 100% available between 2 checks that there was a reset, whether done +// by the system or by a reset the user was allowed to optionally use? Please +// alert for that since if you don't know it was reset and they do a courtesy +// reset then you can start using again." +// +// The consequence drives the design: a reset that is not noticed leaves the +// owner believing a cap still applies that does not. So the rule is keyed on +// the shape of the jump between two consecutive readings and deliberately +// ignores the period end, which behaves differently under every kind of reset. + +extension ResetAlarmTrackerTests { + private func courtesyWindow(resetAt: Date, at now: Date, remaining: Double) -> [ResetAlarmObservation] { + [ + reading("5h", period: 5 * hour, resetAt: resetAt, remaining: remaining, observedAt: now), + reading("7d", period: 7 * day, resetAt: now.addingTimeInterval(7 * day), + remaining: 100, observedAt: now), + ] + } + + /// The owner's case exactly: a reset the owner themselves performed, mid + /// period, with the window end left exactly where it was. Usage had been + /// at 12% and came back to 100%. + func testACourtesyResetMidPeriodAlerts() { + var tracker = ResetAlarmTracker() + let end = t0 + 5 * self.hour + _ = tracker.process(courtesyWindow(resetAt: end, at: t0, remaining: 12), now: t0) + + let later = t0.addingTimeInterval(90 * 60) + let events = tracker.process(courtesyWindow(resetAt: end, at: later, remaining: 100), now: later) + + XCTAssertEqual(events.count, 1, "a courtesy reset produced no alert, so the cap silently came back") + XCTAssertTrue(events[0].isVendorReset, "it should be flagged as a restore rather than a new period") + } + + /// A courtesy reset that also rolls the window forward. Here the period + /// end genuinely moves, which is the case the previous rules missed + /// entirely because they keyed on the end holding still. + func testACourtesyResetThatRollsTheWindowForwardAlerts() { + var tracker = ResetAlarmTracker() + let end = t0 + 5 * self.hour + _ = tracker.process(courtesyWindow(resetAt: end, at: t0, remaining: 30), now: t0) + + let later = t0.addingTimeInterval(60 * 60) + let rolled = later.addingTimeInterval(5 * self.hour) + let events = tracker.process(courtesyWindow(resetAt: rolled, at: later, remaining: 100), now: later) + + XCTAssertEqual(events.count, 1, "a reset that moved the window end was missed entirely") + XCTAssertTrue(events[0].isVendorReset) + } + + /// Usage was never low, so near-cap could not have fired either: 60% to + /// 100% is not "nearly spent" by the old rule. + func testAResetFromAModerateLevelStillAlerts() { + var tracker = ResetAlarmTracker() + let end = t0 + 5 * self.hour + _ = tracker.process(courtesyWindow(resetAt: end, at: t0, remaining: 60), now: t0) + + let later = t0.addingTimeInterval(30 * 60) + let events = tracker.process(courtesyWindow(resetAt: end, at: later, remaining: 100), now: later) + + XCTAssertEqual(events.count, 1, "a reset from 60% to 100% did not alert") + } + + /// The false-positive side, which is what makes this safe. Ordinary use + /// only ever falls within a period, so neither a small rise nor a rise + /// from an already-full quota may alert. + func testOrdinaryUseNeverLooksLikeACourtesyReset() { + var tracker = ResetAlarmTracker() + let end = t0 + 5 * self.hour + _ = tracker.process(courtesyWindow(resetAt: end, at: t0, remaining: 99.5), now: t0) + let later = t0.addingTimeInterval(30 * 60) + XCTAssertTrue( + tracker.process(courtesyWindow(resetAt: end, at: later, remaining: 100), now: later).isEmpty, + "quota going 99.5% -> 100% is measurement noise, not a reset") + + // And a normal decline is never a reset either. + var t2 = ResetAlarmTracker() + _ = t2.process(courtesyWindow(resetAt: end, at: t0, remaining: 90), now: t0) + XCTAssertTrue( + t2.process(courtesyWindow(resetAt: end, at: later, remaining: 70), now: later).isEmpty, + "ordinary consumption was reported as a reset") + } + + /// Once per reset, not once per poll. + func testACourtesyResetAlertsOnlyOnce() { + var tracker = ResetAlarmTracker() + let end = t0 + 5 * self.hour + _ = tracker.process(courtesyWindow(resetAt: end, at: t0, remaining: 12), now: t0) + let first = t0.addingTimeInterval(90 * 60) + XCTAssertEqual(tracker.process(courtesyWindow(resetAt: end, at: first, remaining: 100), now: first).count, 1) + + let second = t0.addingTimeInterval(3 * self.hour) + XCTAssertTrue( + tracker.process(courtesyWindow(resetAt: end, at: second, remaining: 100), now: second).isEmpty, + "the same reset alerted again on a later poll") + } +} diff --git a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift index 690973a0..401a7872 100644 --- a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift +++ b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift @@ -740,6 +740,30 @@ public final class CompanionQuotaModel: ObservableObject { let windows: [WireRawWindow]? let customMarks: [String: WireCustomMark]? let providerGroups: [WireProviderGroup]? + /// Alerts the Mac has already decided to raise. + /// + /// Owner request, 2026-10-08: "iOS should have info about heavy + /// anomalies in usage or runaway usage like the mac." The decision is + /// made on the Mac because that is where the burn-rate history lives — + /// a per-machine file of measured hourly intervals the phone has no way + /// to reproduce. Recomputing here would mean the two surfaces + /// disagreeing about whether something was anomalous, which is worse + /// than one surface staying quiet. + /// + /// Optional, so an older Mac's file still decodes and the companion + /// simply has nothing extra to raise. + let alerts: [WireAlert]? + } + + /// One alert the Mac has already raised a verdict on. + private struct WireAlert: Decodable, Equatable { + let id: String + let kind: String + let providerKey: String + let providerTitle: String + let windowLabel: String + let summary: String + let occurredAt: String } private struct WireCustomMark: Decodable { @@ -804,11 +828,70 @@ public final class CompanionQuotaModel: ObservableObject { let defaultWindowLabel: String? } + // MARK: - Alerts from the Mac + + /// Alert ids already announced, so a payload rewritten on every refresh + /// raises each one once rather than once per poll. + private var announcedAlertIds: Set { + get { + let stored = UserDefaults.standard.stringArray(forKey: "announcedAlertIds") ?? [] + return Set(stored) + } + set { + UserDefaults.standard.set(Array(newValue).suffix(200), forKey: "announcedAlertIds") + } + } + + /// Raise each alert the Mac decided on that this phone has not announced. + /// + /// The Mac is the only surface that can make this call: the rules compare + /// against its burn-rate history, a per-machine file of measured hourly + /// intervals that does not exist on the phone. So the phone announces what + /// it is told, rather than deciding for itself — which also means the two + /// surfaces cannot disagree about whether something counted as runaway. + private func raisePendingAlerts(_ alerts: [WireAlert]) { + guard !alerts.isEmpty else { return } + var announced = announcedAlertIds + var fresh: [WireAlert] = [] + for alert in alerts where !announced.contains(alert.id) { + announced.insert(alert.id) + fresh.append(alert) + } + guard !fresh.isEmpty else { return } + announcedAlertIds = announced + + for alert in fresh { + let content = UNMutableNotificationContent() + content.title = "Runaway Usage: \(alert.providerTitle)" + content.body = alert.summary + content.sound = AlarmSoundPlayer.notificationSound(for: alarmSound) ?? .default + // An id per alert rather than a shared one: identical bodies would + // otherwise collapse, and the owner does sometimes see the same + // wording twice for two different windows. + let request = UNNotificationRequest(identifier: "runaway-\(alert.id)", + content: content, + trigger: nil) + Task { @MainActor in + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + lastError = "A runaway alert for \(alert.providerTitle) could not be delivered." + + sentenceGap + error.localizedDescription + } + } + } + } + @discardableResult private func parseSnapshot(data: Data) -> Bool { guard let envelope = try? JSONDecoder().decode(WireEnvelope.self, from: data), let rawWindows = envelope.windows else { return false } + // Raise anything the Mac decided to raise that this phone has not + // announced yet. Done before the windows are built so a decode + // failure further down cannot leave an alert half-processed. + raisePendingAlerts(envelope.alerts ?? []) + guard !rawWindows.isEmpty else { items = [] hasDataSource = false