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