chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
/// Canonical adaptive-refresh decision table shared by the app and offline replay tooling.
|
||||
/// Platform adapters normalize their thermal signals before calling this type; thresholds and
|
||||
/// delays live here only.
|
||||
package struct AdaptiveRefreshPolicyCore: Sendable {
|
||||
package struct Input: Sendable, Equatable {
|
||||
package let now: Date
|
||||
package let lastMenuOpenAt: Date?
|
||||
package let lowPowerModeEnabled: Bool
|
||||
package let thermalPressure: ThermalPressure
|
||||
|
||||
package init(
|
||||
now: Date,
|
||||
lastMenuOpenAt: Date?,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalPressure: ThermalPressure)
|
||||
{
|
||||
self.now = now
|
||||
self.lastMenuOpenAt = lastMenuOpenAt
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalPressure = thermalPressure
|
||||
}
|
||||
}
|
||||
|
||||
package enum ThermalPressure: Sendable, Equatable {
|
||||
case nominal
|
||||
case constrained
|
||||
}
|
||||
|
||||
package enum Reason: String, Sendable, Equatable {
|
||||
case recentInteraction
|
||||
case warm
|
||||
case idle
|
||||
case longIdle
|
||||
case constrained
|
||||
}
|
||||
|
||||
package struct Decision: Sendable, Equatable {
|
||||
package let delay: Duration
|
||||
package let reason: Reason
|
||||
|
||||
fileprivate init(delay: Duration, reason: Reason) {
|
||||
self.delay = delay
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
private static let recentInteractionThreshold: TimeInterval = 5 * 60
|
||||
private static let warmThreshold: TimeInterval = 60 * 60
|
||||
private static let idleThreshold: TimeInterval = 4 * 60 * 60
|
||||
|
||||
private static let recentInteractionDelay: Duration = .seconds(2 * 60)
|
||||
private static let warmDelay: Duration = .seconds(5 * 60)
|
||||
private static let idleDelay: Duration = .seconds(15 * 60)
|
||||
private static let longIdleDelay: Duration = .seconds(30 * 60)
|
||||
private static let constrainedDelay: Duration = .seconds(30 * 60)
|
||||
|
||||
/// Representative cadence for consumers that need one interval but cannot access live state.
|
||||
package static let nominalIntervalForHeuristics: TimeInterval = 5 * 60
|
||||
|
||||
package init() {}
|
||||
|
||||
package func nextDelay(for input: Input) -> Decision {
|
||||
if input.lowPowerModeEnabled || input.thermalPressure == .constrained {
|
||||
return Decision(delay: Self.constrainedDelay, reason: .constrained)
|
||||
}
|
||||
|
||||
guard let lastMenuOpenAt = input.lastMenuOpenAt else {
|
||||
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
|
||||
}
|
||||
|
||||
// A future or clock-adjusted timestamp yields a negative age, which reads as recent.
|
||||
let age = input.now.timeIntervalSince(lastMenuOpenAt)
|
||||
|
||||
if age <= Self.recentInteractionThreshold {
|
||||
return Decision(delay: Self.recentInteractionDelay, reason: .recentInteraction)
|
||||
}
|
||||
if age <= Self.warmThreshold {
|
||||
return Decision(delay: Self.warmDelay, reason: .warm)
|
||||
}
|
||||
if age < Self.idleThreshold {
|
||||
return Decision(delay: Self.idleDelay, reason: .idle)
|
||||
}
|
||||
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
|
||||
enum ReplayPolicyName: String, CaseIterable, Sendable {
|
||||
case adaptive
|
||||
case adaptiveActivity = "adaptive-activity"
|
||||
case fixed2Minutes = "fixed-2m"
|
||||
case fixed5Minutes = "fixed-5m"
|
||||
case fixed15Minutes = "fixed-15m"
|
||||
case fixed30Minutes = "fixed-30m"
|
||||
case manual
|
||||
|
||||
var policy: any ReplayPolicy {
|
||||
switch self {
|
||||
case .adaptive:
|
||||
AdaptiveReplayPolicy()
|
||||
case .adaptiveActivity:
|
||||
CodingActivityAdaptivePolicy()
|
||||
case .fixed2Minutes:
|
||||
FixedIntervalPolicy(minutes: 2)
|
||||
case .fixed5Minutes:
|
||||
FixedIntervalPolicy(minutes: 5)
|
||||
case .fixed15Minutes:
|
||||
FixedIntervalPolicy(minutes: 15)
|
||||
case .fixed30Minutes:
|
||||
FixedIntervalPolicy(minutes: 30)
|
||||
case .manual:
|
||||
ManualPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
static var expectedValues: String {
|
||||
allCases.map(\.rawValue).joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIArguments {
|
||||
case run(
|
||||
tracePath: String,
|
||||
policyNames: [ReplayPolicyName],
|
||||
jsonOutput: Bool,
|
||||
gapGraceSeconds: TimeInterval?)
|
||||
case help(exitCode: Int32)
|
||||
case invalid(message: String)
|
||||
|
||||
static func parse(_ arguments: [String]) -> Self {
|
||||
if arguments.contains("-h") || arguments.contains("--help") {
|
||||
return .help(exitCode: EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
var tracePath: String?
|
||||
var policyNames: [ReplayPolicyName] = []
|
||||
var jsonOutput = false
|
||||
var gapGraceSeconds: TimeInterval? = ReplayTraceSegmenter.defaultGraceSeconds
|
||||
var index = 0
|
||||
while index < arguments.count {
|
||||
let argument = arguments[index]
|
||||
switch argument {
|
||||
case "--json":
|
||||
jsonOutput = true
|
||||
case "--raw-wall-clock":
|
||||
gapGraceSeconds = nil
|
||||
case "--gap-grace":
|
||||
index += 1
|
||||
guard index < arguments.count,
|
||||
let seconds = TimeInterval(arguments[index]),
|
||||
seconds >= 0,
|
||||
seconds.isFinite
|
||||
else { return .invalid(message: "--gap-grace requires non-negative finite seconds") }
|
||||
gapGraceSeconds = seconds
|
||||
case "--policy":
|
||||
index += 1
|
||||
guard index < arguments.count else { return .invalid(message: "--policy requires a value") }
|
||||
let rawPolicyName = arguments[index]
|
||||
guard let policyName = ReplayPolicyName(rawValue: rawPolicyName) else {
|
||||
return .invalid(
|
||||
message: "unknown policy '\(rawPolicyName)' (expected: \(ReplayPolicyName.expectedValues))")
|
||||
}
|
||||
policyNames.append(policyName)
|
||||
default:
|
||||
guard tracePath == nil else {
|
||||
return .invalid(message: "unexpected argument '\(argument)'")
|
||||
}
|
||||
tracePath = argument
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
guard let tracePath else {
|
||||
return .help(exitCode: EXIT_FAILURE)
|
||||
}
|
||||
return .run(
|
||||
tracePath: tracePath,
|
||||
policyNames: policyNames.isEmpty ? ReplayPolicyName.allCases : policyNames,
|
||||
jsonOutput: jsonOutput,
|
||||
gapGraceSeconds: gapGraceSeconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
|
||||
/// Thin CLI shell over `AdaptiveReplayKit`: parses a trace path and a policy name, runs the
|
||||
/// replay, and prints the resulting `ReplayMetrics`. All parsing/replay/metrics logic lives in
|
||||
/// the library — this file only routes arguments to it and formats the result.
|
||||
enum AdaptiveReplayCLI {
|
||||
static func main() {
|
||||
let arguments = CLIArguments.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
|
||||
switch arguments {
|
||||
case let .help(exitCode):
|
||||
print(Self.helpText)
|
||||
exit(exitCode)
|
||||
case let .invalid(message):
|
||||
FileHandle.standardError.write(Data("error: \(message)\n\n\(Self.helpText)\n".utf8))
|
||||
exit(EXIT_FAILURE)
|
||||
case let .run(tracePath, policyNames, jsonOutput, gapGraceSeconds):
|
||||
Self.run(
|
||||
tracePath: tracePath,
|
||||
policyNames: policyNames,
|
||||
jsonOutput: jsonOutput,
|
||||
gapGraceSeconds: gapGraceSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private static func run(
|
||||
tracePath: String,
|
||||
policyNames: [ReplayPolicyName],
|
||||
jsonOutput: Bool,
|
||||
gapGraceSeconds: TimeInterval?)
|
||||
{
|
||||
let records: [AdaptiveRefreshTraceRecord]
|
||||
do {
|
||||
records = try AdaptiveRefreshTraceParser.parse(contentsOf: URL(fileURLWithPath: tracePath))
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("error: failed to parse trace: \(error)\n".utf8))
|
||||
exit(EXIT_FAILURE)
|
||||
}
|
||||
|
||||
let policies = policyNames.map(\.policy)
|
||||
|
||||
let results = policies.map { policy in
|
||||
gapGraceSeconds.map {
|
||||
ReplayEngine.runSegmented(trace: records, policy: policy, graceSeconds: $0)
|
||||
} ?? ReplayEngine.run(trace: records, policy: policy)
|
||||
}
|
||||
let activityCoverage = ActivityCoverageStats.compute(from: records)
|
||||
let recordedScheduleAudit = RecordedScheduleAuditor.audit(records)
|
||||
|
||||
if jsonOutput {
|
||||
print(Self.renderJSON(
|
||||
results,
|
||||
activityCoverage: activityCoverage,
|
||||
recordedScheduleAudit: recordedScheduleAudit,
|
||||
gapGraceSeconds: gapGraceSeconds))
|
||||
} else {
|
||||
print(Self.renderTable(results))
|
||||
print(Self.renderActivityCoverage(activityCoverage))
|
||||
print(Self.renderRecordedScheduleAudit(recordedScheduleAudit))
|
||||
if let gapGraceSeconds, let first = results.first {
|
||||
print(String(
|
||||
format: "segmentation: %d segments, %.2fh excluded (legacy heuristic, %.0fs grace)",
|
||||
first.segmentCount,
|
||||
first.excludedGapSeconds / 3600,
|
||||
gapGraceSeconds))
|
||||
} else {
|
||||
print("segmentation: disabled (raw wall clock)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func renderRecordedScheduleAudit(_ audit: RecordedScheduleAudit) -> String {
|
||||
"recorded schedule: \(audit.recordedAdvanceCount) advances, "
|
||||
+ "\(audit.acceptedEvaluationCount)/\(audit.evaluatedCount) evaluations accepted, "
|
||||
+ "payload=\(audit.payloadMismatchCount) decision=\(audit.decisionMismatchCount) "
|
||||
+ "menu-link=\(audit.menuLinkMismatchCount) mismatches, "
|
||||
+ "ambiguous=\(audit.ambiguousComparisonCount)"
|
||||
}
|
||||
|
||||
/// Reports coverage of optional activity observations already present in the input trace.
|
||||
private static func renderActivityCoverage(_ stats: ActivityCoverageStats) -> String {
|
||||
guard stats.decisionCount > 0 else {
|
||||
return "activity telemetry: no decision events in trace"
|
||||
}
|
||||
let sampledSummary = String(
|
||||
format: "%d/%d decisions sampled (%.0f%%)",
|
||||
stats.sampledCount,
|
||||
stats.decisionCount,
|
||||
stats.sampledFraction * 100)
|
||||
let activeSummary = String(
|
||||
format: "%d/%d active coding at decision time (%.0f%%)",
|
||||
stats.activeCount,
|
||||
stats.sampledCount,
|
||||
stats.activeFraction * 100)
|
||||
return "activity telemetry: \(sampledSummary), \(activeSummary)"
|
||||
}
|
||||
|
||||
private static func renderTable(_ results: [ReplayMetrics]) -> String {
|
||||
var lines: [String] = []
|
||||
let header = [
|
||||
"policy", "refreshes", "per24h", "sim advances", "active >5m", "staleness p50",
|
||||
"staleness p95", "constrained ok",
|
||||
]
|
||||
lines.append(header.joined(separator: "\t"))
|
||||
for metrics in results {
|
||||
let staleness = metrics.stalenessAtMenuOpen
|
||||
lines.append([
|
||||
metrics.policyName,
|
||||
String(metrics.totalRefreshCount),
|
||||
String(format: "%.2f", metrics.refreshCountPer24h),
|
||||
String(metrics.interactionAdvanceCount),
|
||||
"\(metrics.codingActiveDelayViolationCount)/\(metrics.codingActiveDecisionCount)",
|
||||
staleness.map { String(format: "%.0fs", $0.median) } ?? "n/a",
|
||||
staleness.map { String(format: "%.0fs", $0.p95) } ?? "n/a",
|
||||
metrics.constrainedCompliance
|
||||
.isCompliant ? "yes" : "NO (\(metrics.constrainedCompliance.violationCount))",
|
||||
].joined(separator: "\t"))
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func renderJSON(
|
||||
_ results: [ReplayMetrics],
|
||||
activityCoverage: ActivityCoverageStats,
|
||||
recordedScheduleAudit: RecordedScheduleAudit,
|
||||
gapGraceSeconds: TimeInterval?) -> String
|
||||
{
|
||||
let policies = results.map { metrics -> [String: Any] in
|
||||
var dict: [String: Any] = [
|
||||
"policy": metrics.policyName,
|
||||
"simulatedSpanSeconds": metrics.simulatedSpanSeconds,
|
||||
"totalRefreshCount": metrics.totalRefreshCount,
|
||||
"refreshCountPer24h": metrics.refreshCountPer24h,
|
||||
"interactionAdvanceCount": metrics.interactionAdvanceCount,
|
||||
"codingActiveDecisionCount": metrics.codingActiveDecisionCount,
|
||||
"codingActiveDelayViolationCount": metrics.codingActiveDelayViolationCount,
|
||||
"segmentCount": metrics.segmentCount,
|
||||
"excludedGapSeconds": metrics.excludedGapSeconds,
|
||||
"boundaryCensoredMenuOpenCount": metrics.boundaryCensoredMenuOpenCount,
|
||||
"constrainedDecisionCount": metrics.constrainedCompliance.constrainedDecisionCount,
|
||||
"constrainedViolationCount": metrics.constrainedCompliance.violationCount,
|
||||
"constrainedCompliant": metrics.constrainedCompliance.isCompliant,
|
||||
]
|
||||
if let staleness = metrics.stalenessAtMenuOpen {
|
||||
dict["stalenessMeanSeconds"] = staleness.mean
|
||||
dict["stalenessMedianSeconds"] = staleness.median
|
||||
dict["stalenessP95Seconds"] = staleness.p95
|
||||
dict["stalenessSampleCount"] = staleness.sampleCount
|
||||
}
|
||||
return dict
|
||||
}
|
||||
let segmentation: [String: Any] = [
|
||||
"mode": gapGraceSeconds == nil ? "rawWallClock" : "legacyGapHeuristic",
|
||||
"gapGraceSeconds": gapGraceSeconds.map { $0 as Any } ?? NSNull(),
|
||||
]
|
||||
let payload: [String: Any] = [
|
||||
"policies": policies,
|
||||
"activityCoverage": [
|
||||
"decisionCount": activityCoverage.decisionCount,
|
||||
"sampledCount": activityCoverage.sampledCount,
|
||||
"activeCount": activityCoverage.activeCount,
|
||||
"sampledFraction": activityCoverage.sampledFraction,
|
||||
"activeFraction": activityCoverage.activeFraction,
|
||||
],
|
||||
"recordedScheduleAudit": [
|
||||
"recordedAdvanceCount": recordedScheduleAudit.recordedAdvanceCount,
|
||||
"evaluatedCount": recordedScheduleAudit.evaluatedCount,
|
||||
"acceptedEvaluationCount": recordedScheduleAudit.acceptedEvaluationCount,
|
||||
"rejectedEvaluationCount": recordedScheduleAudit.rejectedEvaluationCount,
|
||||
"payloadMismatchCount": recordedScheduleAudit.payloadMismatchCount,
|
||||
"decisionMismatchCount": recordedScheduleAudit.decisionMismatchCount,
|
||||
"menuLinkMismatchCount": recordedScheduleAudit.menuLinkMismatchCount,
|
||||
"ambiguousComparisonCount": recordedScheduleAudit.ambiguousComparisonCount,
|
||||
"isValid": recordedScheduleAudit.isValid,
|
||||
],
|
||||
"segmentation": segmentation,
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(
|
||||
withJSONObject: payload,
|
||||
options: [.prettyPrinted, .sortedKeys]),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "{}"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static let helpText = """
|
||||
Usage: AdaptiveReplayCLI <trace.jsonl> [--policy <name>]... [--gap-grace <seconds>] [--raw-wall-clock] [--json]
|
||||
|
||||
Replays a JSONL adaptive-refresh trace against one or more refresh-timing policies and prints
|
||||
per-policy metrics over automatically segmented observed time. Simulated advances are
|
||||
counterfactual policy events; recorded live schedule evaluations are audited separately.
|
||||
|
||||
Policies:
|
||||
adaptive The shared production adaptive policy. Advances on menu-open interactions,
|
||||
same as UsageStore.noteMenuOpened(at:).
|
||||
adaptive-activity Experimental adaptive table capped at 5m after observed coding activity.
|
||||
fixed-2m Fixed 2 minute cadence. Unaffected by menu-open interactions.
|
||||
fixed-5m Fixed 5 minute cadence.
|
||||
fixed-15m Fixed 15 minute cadence.
|
||||
fixed-30m Fixed 30 minute cadence.
|
||||
manual Never refreshes (degenerate floor).
|
||||
|
||||
Defaults to comparing all seven policies when --policy is omitted.
|
||||
|
||||
Options:
|
||||
--policy <name> Restrict to one listed policy; repeat to compare a specific subset.
|
||||
--gap-grace <s> Split legacy gaps this many seconds after the last timer deadline (default 300).
|
||||
--raw-wall-clock Disable gap segmentation; useful only for auditing the old behavior.
|
||||
--json Print a machine-readable report including replay, activity, and audit data.
|
||||
-h, --help Print this help text.
|
||||
"""
|
||||
}
|
||||
|
||||
AdaptiveReplayCLI.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// Informational summary of optional coding-activity observations in a trace's `decision` events.
|
||||
/// It reports how many decisions carried activity data and how many sampled decisions were below
|
||||
/// `activeThresholdSeconds` for either CLI.
|
||||
public struct ActivityCoverageStats: Sendable, Equatable {
|
||||
public let decisionCount: Int
|
||||
public let sampledCount: Int
|
||||
public let activeCount: Int
|
||||
|
||||
public init(decisionCount: Int, sampledCount: Int, activeCount: Int) {
|
||||
self.decisionCount = decisionCount
|
||||
self.sampledCount = sampledCount
|
||||
self.activeCount = activeCount
|
||||
}
|
||||
|
||||
/// Fraction of `decision` events that carried at least one non-nil activity field.
|
||||
public var sampledFraction: Double {
|
||||
self.decisionCount == 0 ? 0 : Double(self.sampledCount) / Double(self.decisionCount)
|
||||
}
|
||||
|
||||
/// Fraction of the *sampled* decisions (not all decisions) that looked like active coding.
|
||||
public var activeFraction: Double {
|
||||
self.sampledCount == 0 ? 0 : Double(self.activeCount) / Double(self.sampledCount)
|
||||
}
|
||||
|
||||
/// - Parameter activeThresholdSeconds: below this many seconds since the newest transcript
|
||||
/// write, a CLI counts as "active coding at decision time". Defaults to 5 minutes.
|
||||
public static func compute(
|
||||
from records: [AdaptiveRefreshTraceRecord],
|
||||
activeThresholdSeconds: TimeInterval = 300) -> Self
|
||||
{
|
||||
var sampledCount = 0
|
||||
var activeCount = 0
|
||||
var decisionCount = 0
|
||||
for record in records where record.kind == .decision {
|
||||
decisionCount += 1
|
||||
let codexSeconds = record.codexActivitySeconds
|
||||
let claudeSeconds = record.claudeActivitySeconds
|
||||
guard codexSeconds != nil || claudeSeconds != nil else { continue }
|
||||
sampledCount += 1
|
||||
let isActive = (codexSeconds ?? .infinity) < activeThresholdSeconds
|
||||
|| (claudeSeconds ?? .infinity) < activeThresholdSeconds
|
||||
if isActive {
|
||||
activeCount += 1
|
||||
}
|
||||
}
|
||||
return Self(decisionCount: decisionCount, sampledCount: sampledCount, activeCount: activeCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
|
||||
/// The event kinds a trace records. `decision` events capture a full policy tick (the
|
||||
/// signals it saw plus what it chose). `menuOpen` and `refreshCompleted` capture the two
|
||||
/// ground-truth events the replay engine anchors a simulation to, independent of any candidate
|
||||
/// policy. `timerAdvanced` captures the one place live behavior *isn't* a plain tick loop: when
|
||||
/// opening the menu makes `UsageStore.noteMenuOpened(at:)` pull the next adaptive refresh forward
|
||||
/// (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`). Recording it separately
|
||||
/// from `decision` lets a trace answer "did an advance happen, and to when" without relying on
|
||||
/// fragile inference from decision-timestamp gaps.
|
||||
public enum AdaptiveRefreshTraceEventKind: String, Sendable, Codable {
|
||||
case decision
|
||||
case menuOpen
|
||||
case refreshCompleted
|
||||
case timerAdvanced
|
||||
/// Every live advance comparison, including the cases correctly rejected because the current
|
||||
/// timer was already earlier. This is distinct from counterfactual replay advances.
|
||||
case timerAdvanceEvaluated
|
||||
}
|
||||
|
||||
/// One line of a JSONL adaptive-refresh trace. Field presence depends on `kind`: `decision`
|
||||
/// records populate `menuAgeSeconds`, `lowPowerModeEnabled`, `thermalState`, `reason`, and
|
||||
/// `delaySeconds`, plus optional activity observations supplied by the input trace
|
||||
/// (`codexActivitySeconds`/`claudeActivitySeconds`, the seconds-since-newest-transcript fields,
|
||||
/// and the per-file intensity fields alongside them); timer advance records populate
|
||||
/// `previousScheduledAt`, `candidateScheduledAt`, `reason`, and `delaySeconds`; `menuOpen` and
|
||||
/// `refreshCompleted` carry only `kind` and `timestamp`.
|
||||
public struct AdaptiveRefreshTraceRecord: Sendable, Codable, Equatable {
|
||||
public let kind: AdaptiveRefreshTraceEventKind
|
||||
public let timestamp: Date
|
||||
public let menuAgeSeconds: TimeInterval?
|
||||
public let lowPowerModeEnabled: Bool?
|
||||
public let thermalState: ReplayThermalState?
|
||||
public let reason: String?
|
||||
public let delaySeconds: TimeInterval?
|
||||
/// Timer advance records only: the adaptive timer's scheduled refresh time before the
|
||||
/// comparison, or `nil` when no refresh had been scheduled yet (matches
|
||||
/// `UsageStore.shouldAdvanceAdaptiveTimer`'s "always advance when nothing is scheduled" rule).
|
||||
public let previousScheduledAt: Date?
|
||||
/// Timer advance records only: the candidate refresh time, i.e. the menu-open timestamp plus
|
||||
/// the freshly computed decision's delay.
|
||||
public let candidateScheduledAt: Date?
|
||||
/// `timerAdvanceEvaluated` only: whether the live schedule comparison accepted the candidate.
|
||||
public let timerAdvanceAccepted: Bool?
|
||||
/// `timerAdvanceEvaluated` only: `previousScheduledAt - candidateScheduledAt`, captured before
|
||||
/// whole-second ISO-8601 serialization. Positive means the candidate was earlier. Optional for
|
||||
/// compatibility with traces recorded before exact comparison deltas were added.
|
||||
public let scheduleLeadSeconds: TimeInterval?
|
||||
/// `timerAdvanceEvaluated` only: whether another refresh was in flight at comparison time.
|
||||
public let refreshInFlight: Bool?
|
||||
/// `decision` only: seconds since the newest observed Codex session transcript modification,
|
||||
/// or `nil` when unavailable. Optional so old trace lines without this field keep decoding.
|
||||
public let codexActivitySeconds: TimeInterval?
|
||||
/// `decision` only: the Claude Code counterpart of `codexActivitySeconds`.
|
||||
public let claudeActivitySeconds: TimeInterval?
|
||||
/// `decision` only: how long the newest Codex transcript has been
|
||||
/// growing (its mtime minus its creationDate), or `nil` when unavailable. Not a separate
|
||||
/// session-age field — age is `codexActivitySeconds` + `codexSessionDurationSeconds`.
|
||||
public let codexSessionDurationSeconds: TimeInterval?
|
||||
/// `decision` only: the Claude Code counterpart of `codexSessionDurationSeconds`.
|
||||
public let claudeSessionDurationSeconds: TimeInterval?
|
||||
/// `decision` only: size in bytes of the newest Codex transcript, as a stateless raw value.
|
||||
public let codexTranscriptBytes: Int64?
|
||||
/// `decision` only: the Claude Code counterpart of `codexTranscriptBytes`.
|
||||
public let claudeTranscriptBytes: Int64?
|
||||
/// `decision` only: count of Codex `.jsonl` transcripts modified in the observation window.
|
||||
public let codexActiveTranscriptCount: Int?
|
||||
/// `decision` only: the Claude Code counterpart of `codexActiveTranscriptCount`.
|
||||
public let claudeActiveTranscriptCount: Int?
|
||||
|
||||
public init(
|
||||
kind: AdaptiveRefreshTraceEventKind,
|
||||
timestamp: Date,
|
||||
menuAgeSeconds: TimeInterval? = nil,
|
||||
lowPowerModeEnabled: Bool? = nil,
|
||||
thermalState: ReplayThermalState? = nil,
|
||||
reason: String? = nil,
|
||||
delaySeconds: TimeInterval? = nil,
|
||||
previousScheduledAt: Date? = nil,
|
||||
candidateScheduledAt: Date? = nil,
|
||||
timerAdvanceAccepted: Bool? = nil,
|
||||
scheduleLeadSeconds: TimeInterval? = nil,
|
||||
refreshInFlight: Bool? = nil,
|
||||
codexActivitySeconds: TimeInterval? = nil,
|
||||
claudeActivitySeconds: TimeInterval? = nil,
|
||||
codexSessionDurationSeconds: TimeInterval? = nil,
|
||||
claudeSessionDurationSeconds: TimeInterval? = nil,
|
||||
codexTranscriptBytes: Int64? = nil,
|
||||
claudeTranscriptBytes: Int64? = nil,
|
||||
codexActiveTranscriptCount: Int? = nil,
|
||||
claudeActiveTranscriptCount: Int? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.timestamp = timestamp
|
||||
self.menuAgeSeconds = menuAgeSeconds
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalState = thermalState
|
||||
self.reason = reason
|
||||
self.delaySeconds = delaySeconds
|
||||
self.previousScheduledAt = previousScheduledAt
|
||||
self.candidateScheduledAt = candidateScheduledAt
|
||||
self.timerAdvanceAccepted = timerAdvanceAccepted
|
||||
self.scheduleLeadSeconds = scheduleLeadSeconds
|
||||
self.refreshInFlight = refreshInFlight
|
||||
self.codexActivitySeconds = codexActivitySeconds
|
||||
self.claudeActivitySeconds = claudeActivitySeconds
|
||||
self.codexSessionDurationSeconds = codexSessionDurationSeconds
|
||||
self.claudeSessionDurationSeconds = claudeSessionDurationSeconds
|
||||
self.codexTranscriptBytes = codexTranscriptBytes
|
||||
self.claudeTranscriptBytes = claudeTranscriptBytes
|
||||
self.codexActiveTranscriptCount = codexActiveTranscriptCount
|
||||
self.claudeActiveTranscriptCount = claudeActiveTranscriptCount
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public static func decision(
|
||||
timestamp: Date,
|
||||
menuAgeSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ReplayThermalState,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval,
|
||||
codexActivitySeconds: TimeInterval? = nil,
|
||||
claudeActivitySeconds: TimeInterval? = nil,
|
||||
codexSessionDurationSeconds: TimeInterval? = nil,
|
||||
claudeSessionDurationSeconds: TimeInterval? = nil,
|
||||
codexTranscriptBytes: Int64? = nil,
|
||||
claudeTranscriptBytes: Int64? = nil,
|
||||
codexActiveTranscriptCount: Int? = nil,
|
||||
claudeActiveTranscriptCount: Int? = nil) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .decision,
|
||||
timestamp: timestamp,
|
||||
menuAgeSeconds: menuAgeSeconds,
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalState: thermalState,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
codexActivitySeconds: codexActivitySeconds,
|
||||
claudeActivitySeconds: claudeActivitySeconds,
|
||||
codexSessionDurationSeconds: codexSessionDurationSeconds,
|
||||
claudeSessionDurationSeconds: claudeSessionDurationSeconds,
|
||||
codexTranscriptBytes: codexTranscriptBytes,
|
||||
claudeTranscriptBytes: claudeTranscriptBytes,
|
||||
codexActiveTranscriptCount: codexActiveTranscriptCount,
|
||||
claudeActiveTranscriptCount: claudeActiveTranscriptCount)
|
||||
}
|
||||
|
||||
public static func menuOpen(timestamp: Date) -> Self {
|
||||
Self(kind: .menuOpen, timestamp: timestamp)
|
||||
}
|
||||
|
||||
public static func refreshCompleted(timestamp: Date) -> Self {
|
||||
Self(kind: .refreshCompleted, timestamp: timestamp)
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - timestamp: When the menu open that triggered the advance occurred.
|
||||
/// - previousScheduledAt: The timer's scheduled refresh time immediately before the advance.
|
||||
/// - candidateScheduledAt: The refresh time the timer advanced to (`timestamp + delaySeconds`).
|
||||
/// - reason: The freshly computed decision's reason (e.g. `"recentInteraction"`).
|
||||
/// - delaySeconds: The freshly computed decision's delay.
|
||||
public static func timerAdvanced(
|
||||
timestamp: Date,
|
||||
previousScheduledAt: Date?,
|
||||
candidateScheduledAt: Date,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .timerAdvanced,
|
||||
timestamp: timestamp,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
previousScheduledAt: previousScheduledAt,
|
||||
candidateScheduledAt: candidateScheduledAt)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public static func timerAdvanceEvaluated(
|
||||
timestamp: Date,
|
||||
previousScheduledAt: Date?,
|
||||
candidateScheduledAt: Date,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval,
|
||||
accepted: Bool,
|
||||
refreshInFlight: Bool) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: timestamp,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
previousScheduledAt: previousScheduledAt,
|
||||
candidateScheduledAt: candidateScheduledAt,
|
||||
timerAdvanceAccepted: accepted,
|
||||
scheduleLeadSeconds: previousScheduledAt.map { $0.timeIntervalSince(candidateScheduledAt) },
|
||||
refreshInFlight: refreshInFlight)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// A malformed trace line, with enough context to find and fix it.
|
||||
public struct AdaptiveRefreshTraceParseError: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
public let lineNumber: Int
|
||||
public let content: String
|
||||
public let underlyingDescription: String
|
||||
|
||||
public init(lineNumber: Int, content: String, underlyingDescription: String) {
|
||||
self.lineNumber = lineNumber
|
||||
self.content = content
|
||||
self.underlyingDescription = underlyingDescription
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"trace line \(self.lineNumber) is malformed: \(self.underlyingDescription) (content: \(self.content))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses newline-delimited JSON adaptive-refresh traces.
|
||||
///
|
||||
/// Deliberate choice: a malformed line **fails the whole parse** rather than being silently
|
||||
/// skipped. A trace is acceptance evidence — if a line is corrupt (truncated write, disk-full
|
||||
/// mid-append, hand-edited fixture with a typo), the honest answer is "this trace is untrustworthy
|
||||
/// as a whole", not "here are metrics computed from however much of it happened to parse". A
|
||||
/// silently-shortened trace would still produce a superficially plausible replay report, which is
|
||||
/// worse than a loud failure: it hides exactly the kind of gap that would bias staleness/refresh
|
||||
/// counts. Callers that genuinely want best-effort parsing can catch the error and fall back to
|
||||
/// `AdaptiveRefreshTraceParser.parseTolerantly`, which skips bad lines and returns what parsed.
|
||||
public enum AdaptiveRefreshTraceParser {
|
||||
public static func parse(_ text: String) throws -> [AdaptiveRefreshTraceRecord] {
|
||||
let decoder = Self.makeDecoder()
|
||||
var records: [AdaptiveRefreshTraceRecord] = []
|
||||
for (index, line) in text.split(
|
||||
omittingEmptySubsequences: false,
|
||||
whereSeparator: \.isNewline).enumerated()
|
||||
{
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
guard let data = trimmed.data(using: .utf8) else {
|
||||
throw AdaptiveRefreshTraceParseError(
|
||||
lineNumber: index + 1,
|
||||
content: trimmed,
|
||||
underlyingDescription: "not valid UTF-8")
|
||||
}
|
||||
do {
|
||||
try records.append(decoder.decode(AdaptiveRefreshTraceRecord.self, from: data))
|
||||
} catch {
|
||||
throw AdaptiveRefreshTraceParseError(
|
||||
lineNumber: index + 1,
|
||||
content: trimmed,
|
||||
underlyingDescription: String(describing: error))
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
public static func parse(contentsOf url: URL) throws -> [AdaptiveRefreshTraceRecord] {
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
return try self.parse(text)
|
||||
}
|
||||
|
||||
/// Best-effort variant: skips lines that fail to parse instead of throwing. Not the default —
|
||||
/// see the type-level documentation for why silent skipping is the wrong default for
|
||||
/// acceptance-evidence traces. Exists for callers (future exploratory tooling) that explicitly
|
||||
/// want partial data over none.
|
||||
public static func parseTolerantly(_ text: String) -> [AdaptiveRefreshTraceRecord] {
|
||||
let decoder = Self.makeDecoder()
|
||||
var records: [AdaptiveRefreshTraceRecord] = []
|
||||
for line in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { continue }
|
||||
if let record = try? decoder.decode(AdaptiveRefreshTraceRecord.self, from: data) {
|
||||
records.append(record)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private static func makeDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
|
||||
/// Replay adapter for the same canonical policy core used by the CodexBar app.
|
||||
public struct AdaptiveReplayPolicy: ReplayPolicy, Sendable {
|
||||
public let name = "adaptive"
|
||||
|
||||
/// Matches `UsageStore.noteMenuOpened(at:)`'s adaptive-only advance guard: this is the one
|
||||
/// baseline that actually models the interaction-advance path, so it is the only one that
|
||||
/// overrides the protocol's `false` default.
|
||||
public let advancesOnInteraction = true
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
|
||||
now: input.now,
|
||||
lastMenuOpenAt: input.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: input.lowPowerModeEnabled,
|
||||
thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal))
|
||||
return ReplayPolicyDecision(
|
||||
delaySeconds: TimeInterval(decision.delay.components.seconds),
|
||||
reason: decision.reason.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed-cadence baseline: always waits the same interval, regardless of signals. Used to
|
||||
/// compare the adaptive policy against the flat refresh frequencies CodexBar also offers
|
||||
/// (2/5/15/30 minutes). Never advances on interaction (`advancesOnInteraction` stays the protocol
|
||||
/// default of `false`), matching the real app: fixed-cadence refresh frequencies never wire up
|
||||
/// `noteMenuOpened`'s advance check.
|
||||
public struct FixedIntervalPolicy: ReplayPolicy, Sendable {
|
||||
public let name: String
|
||||
private let intervalSeconds: TimeInterval
|
||||
|
||||
public init(minutes: Int) {
|
||||
self.name = "fixed-\(minutes)m"
|
||||
self.intervalSeconds = TimeInterval(minutes) * 60
|
||||
}
|
||||
|
||||
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
ReplayPolicyDecision(delaySeconds: self.intervalSeconds, reason: "fixed")
|
||||
}
|
||||
}
|
||||
|
||||
/// The degenerate floor: never schedules a refresh. A trace replayed against this policy always
|
||||
/// reports zero refreshes, which is the point — it establishes the worst-case staleness bound the
|
||||
/// other policies are compared against.
|
||||
public struct ManualPolicy: ReplayPolicy, Sendable {
|
||||
public let name = "manual"
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
ReplayPolicyDecision(delaySeconds: nil, reason: "manual")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// Replay-only candidate used to test whether stat-only coding activity can close the accepted
|
||||
/// "active work is never slower than five minutes" gap. It is not a production policy approval.
|
||||
public struct CodingActivityAdaptivePolicy: ReplayPolicy, Sendable {
|
||||
public let name = "adaptive-activity"
|
||||
public let advancesOnInteraction = true
|
||||
|
||||
private let base = AdaptiveReplayPolicy()
|
||||
private static let activeThreshold: TimeInterval = 5 * 60
|
||||
private static let activeDelayCap: TimeInterval = 5 * 60
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
let baseDecision = self.base.decide(input)
|
||||
guard !input.isConstrained,
|
||||
let activityAge = input.codingActivityAgeSeconds,
|
||||
activityAge < Self.activeThreshold,
|
||||
let baseDelay = baseDecision.delaySeconds,
|
||||
baseDelay > Self.activeDelayCap
|
||||
else {
|
||||
return baseDecision
|
||||
}
|
||||
return ReplayPolicyDecision(delaySeconds: Self.activeDelayCap, reason: "codingActivity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# AdaptiveReplayKit
|
||||
|
||||
`AdaptiveReplayKit` is an offline harness for comparing refresh-timing policies against an
|
||||
explicit JSONL trace. `AdaptiveReplayCLI` is the command-line wrapper around the library.
|
||||
|
||||
## Scope
|
||||
|
||||
The replay targets do not import `CodexBar` or `CodexBarCore`; they share only the package-internal,
|
||||
Foundation-only `AdaptiveRefreshCore` target with the app. They do not record app behavior, scan
|
||||
Codex or Claude transcript directories, write trace files, call providers, or change the production
|
||||
refresh policy. Trace capture and lifecycle management are deliberately outside this PR; callers
|
||||
provide an existing trace path to the CLI.
|
||||
|
||||
Optional activity fields in the trace schema are inputs only. The replay kit never discovers or
|
||||
collects them. Old records without those fields continue to decode.
|
||||
|
||||
## Components
|
||||
|
||||
- `AdaptiveRefreshTrace.swift` defines the version-tolerant trace schema.
|
||||
- `AdaptiveRefreshTraceParser.swift` parses JSONL strictly by default. The tolerant entry point is
|
||||
available for exploratory work that explicitly accepts skipped malformed records.
|
||||
- `AdaptiveRefreshCore` owns the production decision table. `ReplayPolicy.swift`,
|
||||
`BaselinePolicies.swift`, and `CandidatePolicies.swift` provide replay adapters, fixed/manual
|
||||
baselines, and the replay-only activity candidate.
|
||||
- `ReplayEngine.swift` and `ReplayMetrics.swift` calculate simulated refresh cadence, menu-open
|
||||
staleness, interaction advances, and constrained-state compliance.
|
||||
- `ReplayTraceSegmentation.swift` excludes legacy deadline-overrun gaps with an explicit heuristic
|
||||
and reports the excluded duration.
|
||||
- `RecordedScheduleAudit.swift` audits recorded timer-advance events independently from the replay
|
||||
clock.
|
||||
- `Sources/AdaptiveReplayCLI` formats table or JSON reports.
|
||||
|
||||
`interactionAdvanceCount` is counterfactual. Replay assumes a zero-duration refresh, while the
|
||||
live app waits for provider work and may already have a refresh in flight. Recorded schedule events
|
||||
therefore have a separate audit instead of a direct count comparison.
|
||||
|
||||
The legacy gap heuristic cannot distinguish sleep or reboot from a long refresh or event-loop
|
||||
stall. Reports expose the segment count, grace interval, and excluded time rather than assigning a
|
||||
cause.
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
public struct RecordedScheduleAudit: Sendable, Equatable {
|
||||
public let recordedAdvanceCount: Int
|
||||
public let evaluatedCount: Int
|
||||
public let acceptedEvaluationCount: Int
|
||||
public let rejectedEvaluationCount: Int
|
||||
public let payloadMismatchCount: Int
|
||||
public let decisionMismatchCount: Int
|
||||
public let menuLinkMismatchCount: Int
|
||||
public let ambiguousComparisonCount: Int
|
||||
|
||||
public var isValid: Bool {
|
||||
self.payloadMismatchCount == 0
|
||||
&& self.decisionMismatchCount == 0
|
||||
&& self.menuLinkMismatchCount == 0
|
||||
&& self.ambiguousComparisonCount == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Audits the live schedule records without equating them to ReplayEngine's counterfactual clock.
|
||||
public enum RecordedScheduleAuditor {
|
||||
public static func audit(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
timestampTolerance: TimeInterval = 1) -> RecordedScheduleAudit
|
||||
{
|
||||
let sorted = records.sorted { $0.timestamp < $1.timestamp }
|
||||
let menuTimestamps = sorted.filter { $0.kind == .menuOpen }.map(\.timestamp)
|
||||
let advances = sorted.filter { $0.kind == .timerAdvanced }
|
||||
let evaluations = sorted.filter { $0.kind == .timerAdvanceEvaluated }
|
||||
|
||||
var payloadMismatchCount = advances.count(where: { !Self.payloadIsValid($0) })
|
||||
let evaluationOutcomes = evaluations.map(Self.evaluationOutcome)
|
||||
let decisionMismatchCount = evaluationOutcomes.count(where: { $0 == .mismatch })
|
||||
let ambiguousComparisonCount = evaluationOutcomes.count(where: { $0 == .ambiguous })
|
||||
// Every evaluation is caused by one menu open. Before evaluation records existed, an
|
||||
// accepted advance was the only causal record, so retain those legacy advances as linkage
|
||||
// events. Modern accepted advances are reconciled against evaluations below instead of
|
||||
// consuming the same menu open twice.
|
||||
let legacyAdvances = evaluations.first.map { firstEvaluation in
|
||||
advances.filter { $0.timestamp < firstEvaluation.timestamp }
|
||||
} ?? advances
|
||||
let menuLinkMismatchCount = Self.unmatchedEventCount(
|
||||
evaluations + legacyAdvances,
|
||||
menuTimestamps: menuTimestamps,
|
||||
timestampTolerance: timestampTolerance)
|
||||
|
||||
if let firstEvaluationAt = evaluations.first?.timestamp {
|
||||
let accepted = evaluations.filter { $0.timerAdvanceAccepted == true }
|
||||
let auditableAdvances = advances.filter { $0.timestamp >= firstEvaluationAt }
|
||||
payloadMismatchCount += Self.scheduleMultiplicityDifference(accepted, auditableAdvances)
|
||||
}
|
||||
|
||||
return RecordedScheduleAudit(
|
||||
recordedAdvanceCount: advances.count,
|
||||
evaluatedCount: evaluations.count,
|
||||
acceptedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == true }),
|
||||
rejectedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == false }),
|
||||
payloadMismatchCount: payloadMismatchCount,
|
||||
decisionMismatchCount: decisionMismatchCount,
|
||||
menuLinkMismatchCount: menuLinkMismatchCount,
|
||||
ambiguousComparisonCount: ambiguousComparisonCount)
|
||||
}
|
||||
|
||||
private static func payloadIsValid(_ record: AdaptiveRefreshTraceRecord) -> Bool {
|
||||
guard let candidate = record.candidateScheduledAt,
|
||||
let delay = record.delaySeconds,
|
||||
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
|
||||
else { return false }
|
||||
// Whole-second legacy timestamps can collapse a sub-second accepted lead to equality.
|
||||
return record.previousScheduledAt.map { candidate <= $0 } ?? true
|
||||
}
|
||||
|
||||
private enum EvaluationOutcome: Equatable {
|
||||
case valid
|
||||
case mismatch
|
||||
case ambiguous
|
||||
}
|
||||
|
||||
private static func evaluationOutcome(_ record: AdaptiveRefreshTraceRecord) -> EvaluationOutcome {
|
||||
guard let accepted = record.timerAdvanceAccepted,
|
||||
let candidate = record.candidateScheduledAt,
|
||||
let delay = record.delaySeconds,
|
||||
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
|
||||
else { return .mismatch }
|
||||
guard let previous = record.previousScheduledAt else { return accepted ? .valid : .mismatch }
|
||||
if candidate != previous {
|
||||
return accepted == (candidate < previous) ? .valid : .mismatch
|
||||
}
|
||||
guard let lead = record.scheduleLeadSeconds else { return .ambiguous }
|
||||
return accepted == (lead > 0) ? .valid : .mismatch
|
||||
}
|
||||
|
||||
private struct ScheduleKey: Hashable {
|
||||
let timestamp: Date
|
||||
let previousScheduledAt: Date?
|
||||
let candidateScheduledAt: Date?
|
||||
let reason: String?
|
||||
let delaySeconds: TimeInterval?
|
||||
}
|
||||
|
||||
private static func scheduleMultiplicityDifference(
|
||||
_ lhs: [AdaptiveRefreshTraceRecord],
|
||||
_ rhs: [AdaptiveRefreshTraceRecord]) -> Int
|
||||
{
|
||||
func counts(_ records: [AdaptiveRefreshTraceRecord]) -> [ScheduleKey: Int] {
|
||||
Dictionary(grouping: records, by: scheduleKey).mapValues(\.count)
|
||||
}
|
||||
let lhsCounts = counts(lhs)
|
||||
let rhsCounts = counts(rhs)
|
||||
return Set(lhsCounts.keys).union(rhsCounts.keys).reduce(0) { difference, key in
|
||||
difference + abs(lhsCounts[key, default: 0] - rhsCounts[key, default: 0])
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum one-to-one matching for sorted points with a symmetric tolerance window. Extra
|
||||
/// menu opens are valid because fixed/manual modes do not emit schedule evaluations; only an
|
||||
/// event without its own causal menu open is a mismatch.
|
||||
private static func unmatchedEventCount(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
menuTimestamps: [Date],
|
||||
timestampTolerance: TimeInterval) -> Int
|
||||
{
|
||||
let eventTimestamps = records.map(\.timestamp).sorted()
|
||||
var eventIndex = 0
|
||||
var menuIndex = 0
|
||||
var unmatched = 0
|
||||
|
||||
while eventIndex < eventTimestamps.count, menuIndex < menuTimestamps.count {
|
||||
let eventTimestamp = eventTimestamps[eventIndex]
|
||||
let menuTimestamp = menuTimestamps[menuIndex]
|
||||
if menuTimestamp < eventTimestamp.addingTimeInterval(-timestampTolerance) {
|
||||
menuIndex += 1
|
||||
} else if menuTimestamp > eventTimestamp.addingTimeInterval(timestampTolerance) {
|
||||
unmatched += 1
|
||||
eventIndex += 1
|
||||
} else {
|
||||
eventIndex += 1
|
||||
menuIndex += 1
|
||||
}
|
||||
}
|
||||
return unmatched + eventTimestamps.count - eventIndex
|
||||
}
|
||||
|
||||
private static func scheduleKey(_ record: AdaptiveRefreshTraceRecord) -> ScheduleKey {
|
||||
ScheduleKey(
|
||||
timestamp: record.timestamp,
|
||||
previousScheduledAt: record.previousScheduledAt,
|
||||
candidateScheduledAt: record.candidateScheduledAt,
|
||||
reason: record.reason,
|
||||
delaySeconds: record.delaySeconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import Foundation
|
||||
|
||||
/// Simulates the live timer loop (`decide` → sleep → refresh → `decide` → ...) over a trace's
|
||||
/// observed span for a given `ReplayPolicy`, pure and deterministic: the same trace and policy
|
||||
/// always produce the same `ReplayMetrics`, since every input the policy sees comes from the
|
||||
/// trace, never from a live clock.
|
||||
///
|
||||
/// Ground truth vs. reconstructed signal: `menuOpen` events are ground truth — a menu either
|
||||
/// opened at a timestamp or it didn't, independent of any policy. `lowPowerModeEnabled` and
|
||||
/// `thermalState`, by contrast, are only *sampled* at the timestamps the trace's original
|
||||
/// `decision` events happened to occur at (whatever policy produced the trace). When a candidate
|
||||
/// policy's own tick times fall between those samples, the engine holds the most recent known
|
||||
/// value (step function). This is the phase-1 approximation: without a continuous power/thermal
|
||||
/// signal in the trace, "most recent sample" is the best available reconstruction. Before the
|
||||
/// first known sample, the earliest available sample is used (hold-first).
|
||||
///
|
||||
/// Interaction advances: this is a *counterfactual* replay, not a literal replay of whatever the
|
||||
/// recording policy happened to do — each candidate policy gets its own tick schedule computed
|
||||
/// fresh from `policy.decide(_:)`. To reproduce `UsageStore.noteMenuOpened(at:)`'s "pull the timer
|
||||
/// forward" behavior (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`) for
|
||||
/// *any* candidate policy, every `menuOpen` event that falls inside a policy's current tick window
|
||||
/// is independently re-evaluated: if `policy.advancesOnInteraction` and the decision computed as of
|
||||
/// that menu open would land earlier than the already-scheduled next tick, the schedule advances to
|
||||
/// that earlier time, exactly like `startTimer(preservingResetBoundaryRefresh: true)` replacing a
|
||||
/// pending sleep with a shorter one. Recorded `timerAdvanced` events are audited separately: their
|
||||
/// count is not expected to equal
|
||||
/// this counterfactual schedule because live refresh work has non-zero duration and can coalesce.
|
||||
public enum ReplayEngine {
|
||||
/// Safety valve against a pathological policy (e.g. a zero-or-negative delay bug) turning a
|
||||
/// long trace into an unbounded loop.
|
||||
private static let maxIterations = 2_000_000
|
||||
|
||||
/// The trace-derived, replay-invariant inputs the simulation loop reads on every tick:
|
||||
/// menu-open ground truth plus the sampled power/thermal signal, both precomputed and sorted
|
||||
/// once per `run` so the per-tick lookups stay O(log n).
|
||||
private struct TraceSignals {
|
||||
let menuOpenTimestamps: [Date]
|
||||
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)]
|
||||
let signalTimestamps: [Date]
|
||||
let activitySamples: [ActivityObservation]
|
||||
let activityTimestamps: [Date]
|
||||
}
|
||||
|
||||
private struct ActivityObservation {
|
||||
let timestamp: Date
|
||||
let lastCodingActivityAt: Date?
|
||||
}
|
||||
|
||||
public static func run(trace: [AdaptiveRefreshTraceRecord], policy: some ReplayPolicy) -> ReplayMetrics {
|
||||
self.runDetailed(trace: trace, policy: policy).metrics
|
||||
}
|
||||
|
||||
static func runDetailed(
|
||||
trace: [AdaptiveRefreshTraceRecord],
|
||||
policy: some ReplayPolicy,
|
||||
stalenessStartAt: Date? = nil) -> ReplayRun
|
||||
{
|
||||
guard let start = trace.map(\.timestamp).min(), let end = trace.map(\.timestamp).max() else {
|
||||
return ReplayRun(
|
||||
metrics: ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: 0,
|
||||
totalRefreshCount: 0,
|
||||
refreshCountPer24h: 0,
|
||||
stalenessAtMenuOpen: nil,
|
||||
constrainedCompliance: ConstrainedCompliance(constrainedDecisionCount: 0, violationCount: 0)),
|
||||
stalenessSamples: [])
|
||||
}
|
||||
|
||||
let menuOpenTimestamps = trace
|
||||
.filter { $0.kind == .menuOpen }
|
||||
.map(\.timestamp)
|
||||
.sorted()
|
||||
|
||||
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)] = trace
|
||||
.filter { $0.kind == .decision }
|
||||
.compactMap { record in
|
||||
guard let lowPower = record.lowPowerModeEnabled, let thermal = record.thermalState else {
|
||||
return nil
|
||||
}
|
||||
return (timestamp: record.timestamp, lowPower: lowPower, thermal: thermal)
|
||||
}
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
let activitySamples = trace
|
||||
.filter { $0.kind == .decision }
|
||||
.map { record in
|
||||
let activityDates = [record.codexActivitySeconds, record.claudeActivitySeconds]
|
||||
.compactMap(\.self)
|
||||
.map { record.timestamp.addingTimeInterval(-max(0, $0)) }
|
||||
return ActivityObservation(
|
||||
timestamp: record.timestamp,
|
||||
lastCodingActivityAt: activityDates.max())
|
||||
}
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
let signals = TraceSignals(
|
||||
menuOpenTimestamps: menuOpenTimestamps,
|
||||
signalSamples: signalSamples,
|
||||
signalTimestamps: signalSamples.map(\.timestamp),
|
||||
activitySamples: activitySamples,
|
||||
activityTimestamps: activitySamples.map(\.timestamp))
|
||||
|
||||
var cursor = start
|
||||
var refreshTimestamps: [Date] = []
|
||||
var constrainedDecisionCount = 0
|
||||
var violationCount = 0
|
||||
var interactionAdvanceCount = 0
|
||||
var codingActiveDecisionCount = 0
|
||||
var codingActiveDelayViolationCount = 0
|
||||
var iterations = 0
|
||||
// Monotonic pointer into `menuOpenTimestamps`: the scan below considers each menu open for
|
||||
// an advance at most once, in the single tick window (cursor, next] it falls into.
|
||||
var menuOpenScanIndex = 0
|
||||
|
||||
while cursor <= end, iterations < self.maxIterations {
|
||||
iterations += 1
|
||||
let (lowPower, thermal) = self.signal(
|
||||
signals.signalSamples,
|
||||
timestamps: signals.signalTimestamps,
|
||||
at: cursor)
|
||||
let input = ReplayPolicyInput(
|
||||
now: cursor,
|
||||
lastMenuOpenAt: self.lastValue(menuOpenTimestamps, atOrBefore: cursor),
|
||||
lastCodingActivityAt: self.lastActivity(
|
||||
signals.activitySamples,
|
||||
timestamps: signals.activityTimestamps,
|
||||
at: cursor),
|
||||
lowPowerModeEnabled: lowPower,
|
||||
thermalState: thermal)
|
||||
let decision = policy.decide(input)
|
||||
|
||||
if input.isConstrained {
|
||||
constrainedDecisionCount += 1
|
||||
if let delay = decision.delaySeconds, delay < 1800 {
|
||||
violationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if !input.isConstrained,
|
||||
let activityAge = input.codingActivityAgeSeconds,
|
||||
activityAge < 5 * 60
|
||||
{
|
||||
codingActiveDecisionCount += 1
|
||||
if decision.delaySeconds.map({ $0 <= 0 || $0 > 5 * 60 }) ?? true {
|
||||
codingActiveDelayViolationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard let delay = decision.delaySeconds, delay > 0 else { break }
|
||||
var next = cursor.addingTimeInterval(delay)
|
||||
|
||||
if policy.advancesOnInteraction {
|
||||
let advanced = self.applyInteractionAdvances(
|
||||
policy: policy,
|
||||
signals: signals,
|
||||
scanIndex: &menuOpenScanIndex,
|
||||
windowStart: cursor,
|
||||
scheduledAt: next)
|
||||
next = advanced.scheduledAt
|
||||
interactionAdvanceCount += advanced.advanceCount
|
||||
}
|
||||
|
||||
guard next <= end else { break }
|
||||
refreshTimestamps.append(next)
|
||||
cursor = next
|
||||
}
|
||||
|
||||
let span = end.timeIntervalSince(start)
|
||||
let refreshCountPer24h = span > 0 ? Double(refreshTimestamps.count) * 86400 / span : 0
|
||||
|
||||
let stalenessMenuTimestamps = stalenessStartAt.map { start in
|
||||
menuOpenTimestamps.filter { $0 >= start }
|
||||
} ?? menuOpenTimestamps
|
||||
let stalenessSamples = stalenessMenuTimestamps.isEmpty ? [] : self.stalenessSamples(
|
||||
menuOpenTimestamps: stalenessMenuTimestamps,
|
||||
refreshTimestamps: refreshTimestamps,
|
||||
initialFreshAt: stalenessStartAt ?? start)
|
||||
|
||||
return ReplayRun(
|
||||
metrics: ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: span,
|
||||
totalRefreshCount: refreshTimestamps.count,
|
||||
refreshCountPer24h: refreshCountPer24h,
|
||||
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
|
||||
constrainedCompliance: ConstrainedCompliance(
|
||||
constrainedDecisionCount: constrainedDecisionCount,
|
||||
violationCount: violationCount),
|
||||
interactionAdvanceCount: interactionAdvanceCount,
|
||||
codingActiveDecisionCount: codingActiveDecisionCount,
|
||||
codingActiveDelayViolationCount: codingActiveDelayViolationCount),
|
||||
stalenessSamples: stalenessSamples)
|
||||
}
|
||||
|
||||
/// Re-evaluates every not-yet-scanned menu open that falls in `(windowStart, scheduledAt]`
|
||||
/// against `policy`, mirroring `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`:
|
||||
/// a menu open at time `T` computes `policy.decide(now: T, lastMenuOpenAt: T, ...)` (age zero,
|
||||
/// exactly as `noteMenuOpened(at:)` does with `self.lastMenuOpenAt = date` already applied), and
|
||||
/// if the resulting candidate (`T + delay`) lands earlier than the currently scheduled refresh,
|
||||
/// the schedule advances to that candidate. Later menu opens in the same window are then
|
||||
/// compared against the *advanced* schedule, same as a real second interaction tightening an
|
||||
/// already-shortened sleep. Returns the (possibly advanced) scheduled time plus how many
|
||||
/// advances were taken in this window.
|
||||
private static func applyInteractionAdvances(
|
||||
policy: some ReplayPolicy,
|
||||
signals: TraceSignals,
|
||||
scanIndex: inout Int,
|
||||
windowStart: Date,
|
||||
scheduledAt: Date) -> (scheduledAt: Date, advanceCount: Int)
|
||||
{
|
||||
var next = scheduledAt
|
||||
var advanceCount = 0
|
||||
while scanIndex < signals.menuOpenTimestamps.count {
|
||||
let menuOpenAt = signals.menuOpenTimestamps[scanIndex]
|
||||
guard menuOpenAt > windowStart else {
|
||||
scanIndex += 1
|
||||
continue
|
||||
}
|
||||
guard menuOpenAt <= next else { break }
|
||||
|
||||
let (lowPower, thermal) = self.signal(
|
||||
signals.signalSamples,
|
||||
timestamps: signals.signalTimestamps,
|
||||
at: menuOpenAt)
|
||||
let advanceDecision = policy.decide(ReplayPolicyInput(
|
||||
now: menuOpenAt,
|
||||
lastMenuOpenAt: menuOpenAt,
|
||||
lastCodingActivityAt: self.lastActivity(
|
||||
signals.activitySamples,
|
||||
timestamps: signals.activityTimestamps,
|
||||
at: menuOpenAt),
|
||||
lowPowerModeEnabled: lowPower,
|
||||
thermalState: thermal))
|
||||
scanIndex += 1
|
||||
|
||||
guard let advanceDelay = advanceDecision.delaySeconds, advanceDelay > 0 else { continue }
|
||||
let candidate = menuOpenAt.addingTimeInterval(advanceDelay)
|
||||
if candidate < next {
|
||||
next = candidate
|
||||
advanceCount += 1
|
||||
}
|
||||
}
|
||||
return (next, advanceCount)
|
||||
}
|
||||
|
||||
private static func stalenessSamples(
|
||||
menuOpenTimestamps: [Date],
|
||||
refreshTimestamps: [Date],
|
||||
initialFreshAt: Date) -> [Double]
|
||||
{
|
||||
menuOpenTimestamps.map { menuOpenAt in
|
||||
let simulatedRefresh = self.lastValue(refreshTimestamps, atOrBefore: menuOpenAt)
|
||||
let freshestAt = simulatedRefresh.map { max($0, initialFreshAt) } ?? initialFreshAt
|
||||
return menuOpenAt.timeIntervalSince(freshestAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func lastActivity(
|
||||
_ samples: [ActivityObservation],
|
||||
timestamps: [Date],
|
||||
at time: Date) -> Date?
|
||||
{
|
||||
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
|
||||
return samples[index].lastCodingActivityAt
|
||||
}
|
||||
|
||||
/// Binds the most recent power/thermal sample at or before `time` (hold-last), falling back
|
||||
/// to the earliest known sample when `time` precedes every sample (hold-first), and to
|
||||
/// nominal/not-low-power when no samples exist at all.
|
||||
private static func signal(
|
||||
_ samples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)],
|
||||
timestamps: [Date],
|
||||
at time: Date) -> (Bool, ReplayThermalState)
|
||||
{
|
||||
guard !samples.isEmpty else { return (false, .nominal) }
|
||||
if let index = self.lastIndex(timestamps, atOrBefore: time) {
|
||||
return (samples[index].lowPower, samples[index].thermal)
|
||||
}
|
||||
return (samples[0].lowPower, samples[0].thermal)
|
||||
}
|
||||
|
||||
private static func lastValue(_ timestamps: [Date], atOrBefore time: Date) -> Date? {
|
||||
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
|
||||
return timestamps[index]
|
||||
}
|
||||
|
||||
/// Binary search for the last index whose timestamp is `<= time`, assuming `timestamps` is
|
||||
/// sorted ascending. O(log n) so a long trace (thousands of decisions) stays fast to replay.
|
||||
private static func lastIndex(_ timestamps: [Date], atOrBefore time: Date) -> Int? {
|
||||
var low = 0
|
||||
var high = timestamps.count - 1
|
||||
var result: Int?
|
||||
while low <= high {
|
||||
let mid = (low + high) / 2
|
||||
if timestamps[mid] <= time {
|
||||
result = mid
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Mean/median/p95 of staleness (seconds since the last simulated refresh) observed at each
|
||||
/// historical menu-open event. `p95` uses nearest-rank: samples are sorted ascending and index
|
||||
/// `ceil(0.95 * n) - 1` (clamped to the last index) is reported — the same convention most
|
||||
/// dashboards use for small-to-medium sample counts, and simple enough to hand-verify in tests.
|
||||
public struct StalenessStats: Sendable, Equatable {
|
||||
public let mean: Double
|
||||
public let median: Double
|
||||
public let p95: Double
|
||||
public let sampleCount: Int
|
||||
|
||||
public init(mean: Double, median: Double, p95: Double, sampleCount: Int) {
|
||||
self.mean = mean
|
||||
self.median = median
|
||||
self.p95 = p95
|
||||
self.sampleCount = sampleCount
|
||||
}
|
||||
|
||||
init?(samples: [Double]) {
|
||||
guard !samples.isEmpty else { return nil }
|
||||
let sorted = samples.sorted()
|
||||
self.init(
|
||||
mean: sorted.reduce(0, +) / Double(sorted.count),
|
||||
median: Self.percentile(sorted, fraction: 0.5),
|
||||
p95: Self.percentile(sorted, fraction: 0.95),
|
||||
sampleCount: sorted.count)
|
||||
}
|
||||
|
||||
private static func percentile(_ sorted: [Double], fraction: Double) -> Double {
|
||||
let rank = Int((fraction * Double(sorted.count)).rounded(.up))
|
||||
return sorted[max(0, min(sorted.count - 1, rank - 1))]
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a policy honored the "never refresh faster than 30 minutes while constrained (low
|
||||
/// power or serious/critical thermal)" rule at every simulated decision point where the input was
|
||||
/// constrained.
|
||||
public struct ConstrainedCompliance: Sendable, Equatable {
|
||||
public let constrainedDecisionCount: Int
|
||||
public let violationCount: Int
|
||||
|
||||
public init(constrainedDecisionCount: Int, violationCount: Int) {
|
||||
self.constrainedDecisionCount = constrainedDecisionCount
|
||||
self.violationCount = violationCount
|
||||
}
|
||||
|
||||
public var isCompliant: Bool {
|
||||
self.violationCount == 0
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplayMetrics: Sendable, Equatable {
|
||||
public let policyName: String
|
||||
public let simulatedSpanSeconds: TimeInterval
|
||||
public let totalRefreshCount: Int
|
||||
public let refreshCountPer24h: Double
|
||||
public let stalenessAtMenuOpen: StalenessStats?
|
||||
public let constrainedCompliance: ConstrainedCompliance
|
||||
/// How many of `totalRefreshCount` were pulled forward by a menu-open interaction rather than
|
||||
/// firing on the policy's own previously scheduled cadence — i.e. how many times
|
||||
/// `ReplayEngine.run` took the `advancesOnInteraction` branch for this policy. Always `0` for
|
||||
/// policies that report `advancesOnInteraction == false` (see `ReplayPolicy`).
|
||||
public let interactionAdvanceCount: Int
|
||||
/// Unconstrained replayed decisions with a known transcript-write observation under five minutes old.
|
||||
public let codingActiveDecisionCount: Int
|
||||
/// Unconstrained active decisions whose selected delay exceeded the five-minute acceptance cap.
|
||||
public let codingActiveDelayViolationCount: Int
|
||||
/// Number of independently simulated awake/run segments contributing to these metrics.
|
||||
public let segmentCount: Int
|
||||
/// Wall-clock time excluded after an expected timer deadline because the app was unobserved.
|
||||
public let excludedGapSeconds: TimeInterval
|
||||
/// Menu opens before a segment's first recorded refresh, excluded equally for every policy.
|
||||
public let boundaryCensoredMenuOpenCount: Int
|
||||
|
||||
public init(
|
||||
policyName: String,
|
||||
simulatedSpanSeconds: TimeInterval,
|
||||
totalRefreshCount: Int,
|
||||
refreshCountPer24h: Double,
|
||||
stalenessAtMenuOpen: StalenessStats?,
|
||||
constrainedCompliance: ConstrainedCompliance,
|
||||
interactionAdvanceCount: Int = 0,
|
||||
codingActiveDecisionCount: Int = 0,
|
||||
codingActiveDelayViolationCount: Int = 0,
|
||||
segmentCount: Int = 1,
|
||||
excludedGapSeconds: TimeInterval = 0,
|
||||
boundaryCensoredMenuOpenCount: Int = 0)
|
||||
{
|
||||
self.policyName = policyName
|
||||
self.simulatedSpanSeconds = simulatedSpanSeconds
|
||||
self.totalRefreshCount = totalRefreshCount
|
||||
self.refreshCountPer24h = refreshCountPer24h
|
||||
self.stalenessAtMenuOpen = stalenessAtMenuOpen
|
||||
self.constrainedCompliance = constrainedCompliance
|
||||
self.interactionAdvanceCount = interactionAdvanceCount
|
||||
self.codingActiveDecisionCount = codingActiveDecisionCount
|
||||
self.codingActiveDelayViolationCount = codingActiveDelayViolationCount
|
||||
self.segmentCount = segmentCount
|
||||
self.excludedGapSeconds = excludedGapSeconds
|
||||
self.boundaryCensoredMenuOpenCount = boundaryCensoredMenuOpenCount
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplayRun: Sendable {
|
||||
let metrics: ReplayMetrics
|
||||
let stalenessSamples: [Double]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
// Replay harness for the adaptive refresh policy shipped in the `CodexBar` app target. The app
|
||||
// and replay adapter both call `AdaptiveRefreshPolicyCore`; these types only normalize replay
|
||||
// inputs and report replay-friendly output.
|
||||
|
||||
/// Coarse thermal-pressure signal matching the two `ProcessInfo.ThermalState` cases the policy
|
||||
/// distinguishes (`.serious`/`.critical` vs everything else), expressed independently so this
|
||||
/// library never needs Darwin-only APIs and can build on any platform.
|
||||
public enum ReplayThermalState: String, Sendable, Codable, CaseIterable {
|
||||
case nominal
|
||||
case fair
|
||||
case serious
|
||||
case critical
|
||||
|
||||
public var isConstrained: Bool {
|
||||
self == .serious || self == .critical
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs a refresh-timing policy needs to decide how long to wait before the next refresh.
|
||||
/// Replay-specific policy input. Platform-independent fields map into the shared policy core.
|
||||
public struct ReplayPolicyInput: Sendable, Equatable {
|
||||
public let now: Date
|
||||
public let lastMenuOpenAt: Date?
|
||||
/// Most recent transcript write reconstructed from the latest activity observation available
|
||||
/// at or before `now`. This is nil when that observation could not see either CLI.
|
||||
public let lastCodingActivityAt: Date?
|
||||
public let lowPowerModeEnabled: Bool
|
||||
public let thermalState: ReplayThermalState
|
||||
|
||||
public init(
|
||||
now: Date,
|
||||
lastMenuOpenAt: Date?,
|
||||
lastCodingActivityAt: Date? = nil,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
self.now = now
|
||||
self.lastMenuOpenAt = lastMenuOpenAt
|
||||
self.lastCodingActivityAt = lastCodingActivityAt
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalState = thermalState
|
||||
}
|
||||
|
||||
/// Whether this input represents a power/thermal-constrained moment, independent of which
|
||||
/// policy is deciding. Used by the replay engine to score constrained-tier compliance without
|
||||
/// depending on any single policy's own notion of "constrained".
|
||||
public var isConstrained: Bool {
|
||||
self.lowPowerModeEnabled || self.thermalState.isConstrained
|
||||
}
|
||||
|
||||
public var codingActivityAgeSeconds: TimeInterval? {
|
||||
self.lastCodingActivityAt.map { max(0, self.now.timeIntervalSince($0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A policy's decision: how long to wait, and a short human-readable reason code for reporting.
|
||||
/// `delaySeconds == nil` means "never schedule another refresh" — the degenerate floor used by
|
||||
/// `ManualPolicy`.
|
||||
public struct ReplayPolicyDecision: Sendable, Equatable {
|
||||
public let delaySeconds: TimeInterval?
|
||||
public let reason: String
|
||||
|
||||
public init(delaySeconds: TimeInterval?, reason: String) {
|
||||
self.delaySeconds = delaySeconds
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/// A pure, deterministic function from `ReplayPolicyInput` to `ReplayPolicyDecision`.
|
||||
public protocol ReplayPolicy: Sendable {
|
||||
var name: String { get }
|
||||
|
||||
/// Whether opening the menu can pull this policy's next refresh forward, mirroring
|
||||
/// `UsageStore.noteMenuOpened(at:)`'s guard on `settings.refreshFrequency == .adaptive`: in the
|
||||
/// real app, only adaptive mode ever advances the timer from an interaction — fixed-cadence and
|
||||
/// manual modes just record `lastMenuOpenAt` and let the existing schedule run. Defaults to
|
||||
/// `false` so baseline policies (`FixedIntervalPolicy`, `ManualPolicy`) need no override; only
|
||||
/// policies that actually model the adaptive table set this to `true`.
|
||||
var advancesOnInteraction: Bool { get }
|
||||
|
||||
func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision
|
||||
}
|
||||
|
||||
extension ReplayPolicy {
|
||||
public var advancesOnInteraction: Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
public struct ReplayTraceSegment: Sendable, Equatable {
|
||||
public let records: [AdaptiveRefreshTraceRecord]
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
|
||||
var replayRecords: [AdaptiveRefreshTraceRecord] {
|
||||
guard self.records.last?.timestamp != self.end else { return self.records }
|
||||
return self.records + [.refreshCompleted(timestamp: self.end)]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaySegmentationReport: Sendable, Equatable {
|
||||
public let segments: [ReplayTraceSegment]
|
||||
public let excludedGapSeconds: TimeInterval
|
||||
public let breakCount: Int
|
||||
public let graceSeconds: TimeInterval
|
||||
|
||||
public var includedSpanSeconds: TimeInterval {
|
||||
self.segments.reduce(0) { $0 + max(0, $1.end.timeIntervalSince($1.start)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits legacy traces only when observation resumes well after the last timer deadline. The
|
||||
/// normal scheduled wait remains inside the preceding segment; only overdue wall time is excluded.
|
||||
public enum ReplayTraceSegmenter {
|
||||
public static let defaultGraceSeconds: TimeInterval = 5 * 60
|
||||
|
||||
public static func automatic(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
graceSeconds: TimeInterval = Self.defaultGraceSeconds) -> ReplaySegmentationReport
|
||||
{
|
||||
let sorted = records.sorted { $0.timestamp < $1.timestamp }
|
||||
guard let first = sorted.first else {
|
||||
return ReplaySegmentationReport(
|
||||
segments: [], excludedGapSeconds: 0, breakCount: 0, graceSeconds: graceSeconds)
|
||||
}
|
||||
|
||||
var segments: [ReplayTraceSegment] = []
|
||||
var currentRecords: [AdaptiveRefreshTraceRecord] = []
|
||||
var currentStart = first.timestamp
|
||||
var expectedDeadline: Date?
|
||||
var excludedGapSeconds: TimeInterval = 0
|
||||
|
||||
for record in sorted {
|
||||
if let deadline = expectedDeadline,
|
||||
record.timestamp.timeIntervalSince(deadline) > graceSeconds,
|
||||
!currentRecords.isEmpty
|
||||
{
|
||||
let end = max(currentRecords.last!.timestamp, deadline)
|
||||
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: end))
|
||||
excludedGapSeconds += max(0, record.timestamp.timeIntervalSince(end))
|
||||
currentRecords = []
|
||||
currentStart = record.timestamp
|
||||
expectedDeadline = nil
|
||||
}
|
||||
|
||||
currentRecords.append(record)
|
||||
if record.kind == .decision, let delay = record.delaySeconds, delay > 0 {
|
||||
expectedDeadline = record.timestamp.addingTimeInterval(delay)
|
||||
} else if record.kind == .timerAdvanced, let candidate = record.candidateScheduledAt {
|
||||
expectedDeadline = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if let last = currentRecords.last {
|
||||
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: last.timestamp))
|
||||
}
|
||||
return ReplaySegmentationReport(
|
||||
segments: segments,
|
||||
excludedGapSeconds: excludedGapSeconds,
|
||||
breakCount: max(0, segments.count - 1),
|
||||
graceSeconds: graceSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension ReplayEngine {
|
||||
public static func runSegmented(
|
||||
trace: [AdaptiveRefreshTraceRecord],
|
||||
policy: some ReplayPolicy,
|
||||
graceSeconds: TimeInterval = ReplayTraceSegmenter.defaultGraceSeconds) -> ReplayMetrics
|
||||
{
|
||||
let report = ReplayTraceSegmenter.automatic(trace, graceSeconds: graceSeconds)
|
||||
let stalenessStarts = report.segments.map { segment in
|
||||
segment.records.first(where: { $0.kind == .refreshCompleted })?.timestamp
|
||||
}
|
||||
let runs = zip(report.segments, stalenessStarts).map { segment, stalenessStart in
|
||||
self.runDetailed(
|
||||
trace: segment.replayRecords,
|
||||
policy: policy,
|
||||
stalenessStartAt: stalenessStart ?? .distantFuture)
|
||||
}
|
||||
let boundaryCensoredMenuOpenCount = zip(report.segments, stalenessStarts).reduce(0) { partial, pair in
|
||||
let (segment, stalenessStart) = pair
|
||||
return partial + segment.records.count(where: { record in
|
||||
record.kind == .menuOpen && (stalenessStart.map { record.timestamp < $0 } ?? true)
|
||||
})
|
||||
}
|
||||
let span = report.includedSpanSeconds
|
||||
let refreshCount = runs.reduce(0) { $0 + $1.metrics.totalRefreshCount }
|
||||
let stalenessSamples = runs.flatMap(\.stalenessSamples)
|
||||
return ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: span,
|
||||
totalRefreshCount: refreshCount,
|
||||
refreshCountPer24h: span > 0 ? Double(refreshCount) * 86400 / span : 0,
|
||||
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
|
||||
constrainedCompliance: ConstrainedCompliance(
|
||||
constrainedDecisionCount: runs.reduce(0) {
|
||||
$0 + $1.metrics.constrainedCompliance.constrainedDecisionCount
|
||||
},
|
||||
violationCount: runs.reduce(0) { $0 + $1.metrics.constrainedCompliance.violationCount }),
|
||||
interactionAdvanceCount: runs.reduce(0) { $0 + $1.metrics.interactionAdvanceCount },
|
||||
codingActiveDecisionCount: runs.reduce(0) { $0 + $1.metrics.codingActiveDecisionCount },
|
||||
codingActiveDelayViolationCount: runs.reduce(0) {
|
||||
$0 + $1.metrics.codingActiveDelayViolationCount
|
||||
},
|
||||
segmentCount: report.segments.count,
|
||||
excludedGapSeconds: report.excludedGapSeconds,
|
||||
boundaryCensoredMenuOpenCount: boundaryCensoredMenuOpenCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module CSQLite3 [system] {
|
||||
header "shim.h"
|
||||
link "sqlite3"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#include <sqlite3.h>
|
||||
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
func showAbout() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–"
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
|
||||
let versionString = build.isEmpty ? version : "\(version) (\(build))"
|
||||
let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String
|
||||
let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String
|
||||
|
||||
let separator = NSAttributedString(string: " · ", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
])
|
||||
|
||||
func makeLink(_ title: String, urlString: String) -> NSAttributedString {
|
||||
NSAttributedString(string: title, attributes: [
|
||||
.link: URL(string: urlString) as Any,
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
])
|
||||
}
|
||||
|
||||
let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n")
|
||||
credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar"))
|
||||
credits.append(separator)
|
||||
credits.append(makeLink("Website", urlString: "https://codexbar.app"))
|
||||
credits.append(separator)
|
||||
credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete"))
|
||||
credits.append(separator)
|
||||
credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me"))
|
||||
if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) {
|
||||
var builtLine = "Built \(formatted)"
|
||||
if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" {
|
||||
builtLine += " (\(gitCommit)"
|
||||
#if DEBUG
|
||||
builtLine += " DEBUG BUILD"
|
||||
#endif
|
||||
builtLine += ")"
|
||||
}
|
||||
credits.append(NSAttributedString(string: "\n\(builtLine)", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
]))
|
||||
}
|
||||
|
||||
let options: [NSApplication.AboutPanelOptionKey: Any] = [
|
||||
.applicationName: "CodexBar",
|
||||
.applicationVersion: versionString,
|
||||
.version: versionString,
|
||||
.credits: credits,
|
||||
.applicationIcon: (NSApplication.shared.applicationIconImage ?? NSImage()) as Any,
|
||||
]
|
||||
|
||||
NSApp.orderFrontStandardAboutPanel(options: options)
|
||||
|
||||
// Remove the focus ring around the app icon in the standard About panel for a cleaner look.
|
||||
if let aboutPanel = NSApp.windows.first(where: { $0.className.contains("About") }) {
|
||||
removeFocusRings(in: aboutPanel.contentView)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedBuildTimestamp(_ timestamp: String) -> String? {
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
guard let date = parser.date(from: timestamp) else { return timestamp }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
formatter.locale = .current
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func removeFocusRings(in view: NSView?) {
|
||||
guard let view else { return }
|
||||
if let imageView = view as? NSImageView {
|
||||
imageView.focusRingType = .none
|
||||
}
|
||||
for subview in view.subviews {
|
||||
removeFocusRings(in: subview)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
|
||||
/// Decides how long to wait before the next automatic usage refresh.
|
||||
/// Pure by construction: every signal arrives via `Input`, so the same
|
||||
/// input always yields the same `Decision` with no clock or system reads.
|
||||
struct AdaptiveRefreshPolicy: Sendable {
|
||||
struct Input: Sendable, Equatable {
|
||||
let now: Date
|
||||
let lastMenuOpenAt: Date?
|
||||
let lowPowerModeEnabled: Bool
|
||||
let thermalState: ProcessInfo.ThermalState
|
||||
}
|
||||
|
||||
typealias Reason = AdaptiveRefreshPolicyCore.Reason
|
||||
typealias Decision = AdaptiveRefreshPolicyCore.Decision
|
||||
|
||||
/// Representative cadence for consumers that need a single interval but cannot reach live
|
||||
/// signals (`ProviderRegistry` builds provider specs before a `UsageStore` exists). Matches
|
||||
/// `warmDelay`: the steady-state cadence while the user is active, which is when
|
||||
/// interval-derived heuristics such as the persistent-CLI-session idle window matter most.
|
||||
static let nominalIntervalForHeuristics = AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics
|
||||
|
||||
func nextDelay(for input: Input) -> Decision {
|
||||
AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
|
||||
now: input.now,
|
||||
lastMenuOpenAt: input.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: input.lowPowerModeEnabled,
|
||||
thermalPressure: Self.isConstrained(input.thermalState) ? .constrained : .nominal))
|
||||
}
|
||||
|
||||
private static func isConstrained(_ state: ProcessInfo.ThermalState) -> Bool {
|
||||
state == .serious || state == .critical
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct AgentSessionRemoteRefreshGate {
|
||||
private(set) var generation = 0
|
||||
private(set) var isInFlight = false
|
||||
private(set) var isPending = false
|
||||
|
||||
mutating func settingsDidChange() {
|
||||
self.generation += 1
|
||||
self.isPending = self.isInFlight
|
||||
}
|
||||
|
||||
mutating func begin() -> Int? {
|
||||
guard !self.isInFlight else {
|
||||
return nil
|
||||
}
|
||||
self.isInFlight = true
|
||||
self.isPending = false
|
||||
return self.generation
|
||||
}
|
||||
|
||||
mutating func finish(generation: Int) -> (shouldPublish: Bool, shouldRetry: Bool) {
|
||||
self.isInFlight = false
|
||||
let outcome = (generation == self.generation, self.isPending)
|
||||
self.isPending = false
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AgentSessionsStore {
|
||||
private let settings: SettingsStore
|
||||
private let localScanner: LocalAgentSessionScanner
|
||||
private let remoteFetcher: RemoteSessionFetcher
|
||||
@ObservationIgnored private var localRefreshTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var remoteRefreshTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var localRefreshInFlight = false
|
||||
@ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate()
|
||||
@ObservationIgnored var onUpdate: (@MainActor () -> Void)?
|
||||
|
||||
private(set) var localSessions: [AgentSession] = []
|
||||
private(set) var remoteHosts: [RemoteSessionHostResult] = []
|
||||
private(set) var lastUpdatedAt: Date?
|
||||
|
||||
init(
|
||||
settings: SettingsStore,
|
||||
localScanner: LocalAgentSessionScanner = LocalAgentSessionScanner(),
|
||||
remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher())
|
||||
{
|
||||
self.settings = settings
|
||||
self.localScanner = localScanner
|
||||
self.remoteFetcher = remoteFetcher
|
||||
}
|
||||
|
||||
var totalCount: Int {
|
||||
self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.localRefreshTask == nil, self.remoteRefreshTask == nil else { return }
|
||||
self.localRefreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.refreshLocal()
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
}
|
||||
}
|
||||
self.remoteRefreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.refreshRemote()
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.localRefreshTask?.cancel()
|
||||
self.remoteRefreshTask?.cancel()
|
||||
self.localRefreshTask = nil
|
||||
self.remoteRefreshTask = nil
|
||||
}
|
||||
|
||||
func settingsDidChange() {
|
||||
self.remoteRefreshGate.settingsDidChange()
|
||||
guard self.settings.agentSessionsEnabled else {
|
||||
self.localSessions = []
|
||||
self.remoteHosts = []
|
||||
self.onUpdate?()
|
||||
return
|
||||
}
|
||||
guard !SettingsStore.isRunningTests else { return }
|
||||
Task { [weak self] in
|
||||
await self?.refreshLocal()
|
||||
await self?.refreshRemote()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshOnMenuOpen() {
|
||||
guard self.settings.agentSessionsEnabled, !SettingsStore.isRunningTests else { return }
|
||||
Task { [weak self] in
|
||||
await self?.refreshLocal()
|
||||
await self?.refreshRemote()
|
||||
}
|
||||
}
|
||||
|
||||
func focus(_ session: AgentSession, remoteHost: String?) {
|
||||
if let remoteHost {
|
||||
Task {
|
||||
await self.remoteFetcher.focus(sessionID: session.id, host: remoteHost)
|
||||
}
|
||||
} else {
|
||||
_ = SessionWindowFocuser.focus(session)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshLocal() async {
|
||||
guard self.settings.agentSessionsEnabled, !self.localRefreshInFlight else { return }
|
||||
self.localRefreshInFlight = true
|
||||
let sessions = await self.localScanner.scan()
|
||||
self.localRefreshInFlight = false
|
||||
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
|
||||
self.localSessions = sessions
|
||||
self.lastUpdatedAt = Date()
|
||||
self.onUpdate?()
|
||||
}
|
||||
|
||||
private func refreshRemote() async {
|
||||
guard self.settings.agentSessionsEnabled else { return }
|
||||
guard var generation = self.remoteRefreshGate.begin() else { return }
|
||||
while self.settings.agentSessionsEnabled {
|
||||
var hosts = self.manualHosts
|
||||
await hosts.append(contentsOf: self.remoteFetcher.discoveredHosts())
|
||||
let results = await self.remoteFetcher.fetch(hosts: hosts)
|
||||
let outcome = self.remoteRefreshGate.finish(generation: generation)
|
||||
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
|
||||
if outcome.shouldPublish {
|
||||
self.remoteHosts = results
|
||||
self.lastUpdatedAt = Date()
|
||||
self.onUpdate?()
|
||||
}
|
||||
guard outcome.shouldRetry, let nextGeneration = self.remoteRefreshGate.begin() else { return }
|
||||
generation = nextGeneration
|
||||
}
|
||||
}
|
||||
|
||||
private var manualHosts: [String] {
|
||||
self.settings.agentSessionsManualHosts
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class AppNotifications {
|
||||
static let shared = AppNotifications()
|
||||
|
||||
private let centerProvider: @Sendable () -> UNUserNotificationCenter
|
||||
private let logger = CodexBarLog.logger(LogCategories.notifications)
|
||||
private var authorizationTask: Task<Bool, Never>?
|
||||
|
||||
init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) {
|
||||
self.centerProvider = centerProvider
|
||||
}
|
||||
|
||||
func requestAuthorizationOnStartup() {
|
||||
guard !Self.isRunningUnderTests else { return }
|
||||
_ = self.ensureAuthorizationTask()
|
||||
}
|
||||
|
||||
func post(
|
||||
idPrefix: String,
|
||||
title: String,
|
||||
body: String,
|
||||
badge: NSNumber? = nil,
|
||||
soundEnabled: Bool = true)
|
||||
{
|
||||
guard !Self.isRunningUnderTests else { return }
|
||||
let center = self.centerProvider()
|
||||
let logger = self.logger
|
||||
|
||||
Task { @MainActor in
|
||||
let granted = await self.ensureAuthorized()
|
||||
guard granted else {
|
||||
logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix])
|
||||
return
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = soundEnabled ? .default : nil
|
||||
content.badge = badge
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil)
|
||||
|
||||
logger.info("posting", metadata: ["prefix": idPrefix])
|
||||
do {
|
||||
try await center.add(request)
|
||||
} catch {
|
||||
let errorText = String(describing: error)
|
||||
logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func ensureAuthorizationTask() -> Task<Bool, Never> {
|
||||
if let authorizationTask { return authorizationTask }
|
||||
let task = Task { @MainActor in
|
||||
await self.requestAuthorization()
|
||||
}
|
||||
self.authorizationTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
private func ensureAuthorized() async -> Bool {
|
||||
await self.ensureAuthorizationTask().value
|
||||
}
|
||||
|
||||
private func requestAuthorization() async -> Bool {
|
||||
if let existing = await self.notificationAuthorizationStatus() {
|
||||
if existing == .authorized || existing == .provisional {
|
||||
return true
|
||||
}
|
||||
if existing == .denied {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let center = self.centerProvider()
|
||||
return await withCheckedContinuation { continuation in
|
||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? {
|
||||
let center = self.centerProvider()
|
||||
return await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var isRunningUnderTests: Bool {
|
||||
// Swift Testing doesn't always set XCTest env vars, and removing XCTest imports from
|
||||
// the test target can make NSClassFromString("XCTestCase") return nil. If we're not
|
||||
// running inside an app bundle, treat it as "tests/headless" to avoid crashes when
|
||||
// accessing UNUserNotificationCenter.
|
||||
if Bundle.main.bundleURL.pathExtension != "app" { return true }
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if env["XCTestConfigurationFilePath"] != nil { return true }
|
||||
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
|
||||
if env["SWIFT_TESTING"] != nil { return true }
|
||||
return NSClassFromString("XCTestCase") != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
enum ChartBarHoverSelection {
|
||||
static func accepts(distanceFromBarCenter: CGFloat, barHalfWidth: CGFloat, selectableCount: Int) -> Bool {
|
||||
selectableCount <= 1 || distanceFromBarCenter <= barHalfWidth
|
||||
}
|
||||
|
||||
static func nextCalendarDay(after date: Date, calendar: Calendar = .current) -> Date {
|
||||
calendar.date(byAdding: .day, value: 1, to: date) ?? date.addingTimeInterval(86400)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import CodexBarCore
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct ClaudeLoginRunner {
|
||||
static let loginArguments = ["auth", "login", "--claudeai"]
|
||||
private static let successMarkers = ["Successfully logged in", "Login successful", "Logged in successfully"]
|
||||
|
||||
enum Phase {
|
||||
case requesting
|
||||
case waitingBrowser
|
||||
}
|
||||
|
||||
struct Result {
|
||||
enum Outcome {
|
||||
case success
|
||||
case timedOut
|
||||
case failed(status: Int32)
|
||||
case missingBinary
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
let outcome: Outcome
|
||||
let output: String
|
||||
let authLink: String?
|
||||
}
|
||||
|
||||
static func run(
|
||||
timeout: TimeInterval = 120,
|
||||
binary: String = "claude",
|
||||
environment: [String: String]? = nil,
|
||||
onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result
|
||||
{
|
||||
await Task(priority: .userInitiated) {
|
||||
onPhaseChange(.requesting)
|
||||
do {
|
||||
let runResult = try self.runPTY(
|
||||
timeout: timeout,
|
||||
binary: binary,
|
||||
environment: environment,
|
||||
onPhaseChange: onPhaseChange)
|
||||
let link = self.firstLink(in: runResult.output)
|
||||
switch runResult.completion {
|
||||
case .processExited(status: 0):
|
||||
return Result(outcome: .success, output: runResult.output, authLink: link)
|
||||
case let .processExited(status):
|
||||
return Result(outcome: .failed(status: status), output: runResult.output, authLink: link)
|
||||
case .outputCondition where self.successMarkers.contains(where: runResult.output.contains):
|
||||
return Result(outcome: .success, output: runResult.output, authLink: link)
|
||||
case .outputCondition, .idleTimeout, .deadlineExceeded:
|
||||
return Result(outcome: .timedOut, output: runResult.output, authLink: link)
|
||||
}
|
||||
} catch LoginError.binaryNotFound {
|
||||
return Result(outcome: .missingBinary, output: "", authLink: nil)
|
||||
} catch let LoginError.timedOut(text) {
|
||||
return Result(outcome: .timedOut, output: text, authLink: self.firstLink(in: text))
|
||||
} catch {
|
||||
return Result(outcome: .launchFailed(error.localizedDescription), output: "", authLink: nil)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - PTY runner
|
||||
|
||||
private enum LoginError: Error {
|
||||
case binaryNotFound
|
||||
case timedOut(text: String)
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
private struct PTYRunResult {
|
||||
let output: String
|
||||
let completion: TTYCommandRunner.Result.Completion
|
||||
}
|
||||
|
||||
private static func runPTY(
|
||||
timeout: TimeInterval,
|
||||
binary: String,
|
||||
environment: [String: String]?,
|
||||
onPhaseChange: @escaping @Sendable (Phase) -> Void) throws -> PTYRunResult
|
||||
{
|
||||
let runner = TTYCommandRunner()
|
||||
var options = TTYCommandRunner.Options(rows: 50, cols: 160, timeout: timeout)
|
||||
options.extraArgs = self.loginArguments
|
||||
options.baseEnvironment = environment
|
||||
options.stopOnURL = false // keep running until CLI confirms
|
||||
options.stopOnSubstrings = self.successMarkers
|
||||
options.sendOnSubstrings = ["press ENTER to open in browser": "\r"]
|
||||
options.settleAfterStop = 0.35
|
||||
options.returnOnEmptyProcessExit = true
|
||||
do {
|
||||
let result = try runner.run(
|
||||
binary: binary,
|
||||
send: "",
|
||||
options: options,
|
||||
onURLDetected: { onPhaseChange(.waitingBrowser) })
|
||||
return PTYRunResult(output: result.text, completion: result.completion)
|
||||
} catch TTYCommandRunner.Error.binaryNotFound {
|
||||
throw LoginError.binaryNotFound
|
||||
} catch TTYCommandRunner.Error.timedOut {
|
||||
throw LoginError.timedOut(text: "")
|
||||
} catch let TTYCommandRunner.Error.launchFailed(msg) {
|
||||
throw LoginError.launchFailed(msg)
|
||||
} catch {
|
||||
throw LoginError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstLink(in text: String) -> String? {
|
||||
let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: nsRange),
|
||||
let range = Range(match.range, in: text) else { return nil }
|
||||
var url = String(text[range])
|
||||
while let last = url.unicodeScalars.last,
|
||||
CharacterSet(charactersIn: ".,;:)]}>\"'").contains(last)
|
||||
{
|
||||
url.unicodeScalars.removeLast()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
enum MenuPasteboardCopy {
|
||||
typealias DeferredAction = @MainActor @Sendable () -> Void
|
||||
typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void
|
||||
typealias Writer = @MainActor @Sendable (String) -> Void
|
||||
|
||||
static func perform(
|
||||
_ text: String,
|
||||
scheduler: Scheduler = Self.schedule,
|
||||
writer: @escaping Writer = Self.write,
|
||||
completion: @escaping DeferredAction = {})
|
||||
{
|
||||
scheduler {
|
||||
writer(text)
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
private static func schedule(_ action: @escaping DeferredAction) {
|
||||
DispatchQueue.main.async(execute: action)
|
||||
}
|
||||
|
||||
private static func write(_ text: String) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
struct ClickToCopyOverlay: NSViewRepresentable {
|
||||
let copyText: String
|
||||
|
||||
func makeNSView(context: Context) -> ClickToCopyView {
|
||||
ClickToCopyView(copyText: self.copyText)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: ClickToCopyView, context: Context) {
|
||||
// Guard against no-op writes to avoid AppKit view invalidation on every
|
||||
// parent card SwiftUI diff (each MenuCardView body re-eval runs through
|
||||
// .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even
|
||||
// when copyText is unchanged).
|
||||
guard nsView.copyText != self.copyText else { return }
|
||||
nsView.copyText = self.copyText
|
||||
}
|
||||
}
|
||||
|
||||
final class ClickToCopyView: NSView {
|
||||
var copyText: String
|
||||
private let copyAction: (String) -> Void
|
||||
|
||||
init(
|
||||
copyText: String,
|
||||
copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) })
|
||||
{
|
||||
self.copyText = copyText
|
||||
self.copyAction = copyAction
|
||||
super.init(frame: .zero)
|
||||
self.wantsLayer = false
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
_ = event
|
||||
self.copyAction(self.copyText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum CodexAccountHealth: Equatable {
|
||||
case ok
|
||||
case needsReauth
|
||||
case workspaceDeactivated
|
||||
case missingAuth
|
||||
case unavailable
|
||||
|
||||
var label: String? {
|
||||
switch self {
|
||||
case .ok:
|
||||
nil
|
||||
case .needsReauth:
|
||||
"Needs re-auth"
|
||||
case .workspaceDeactivated:
|
||||
"Workspace deactivated"
|
||||
case .missingAuth:
|
||||
"Missing auth"
|
||||
case .unavailable:
|
||||
"Unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
static func status(for account: CodexVisibleAccount, error: String?) -> CodexAccountHealth {
|
||||
if let error {
|
||||
return self.status(forError: error)
|
||||
}
|
||||
if account.authenticationHealthLabel != nil {
|
||||
return .missingAuth
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
static func status(forError error: String) -> CodexAccountHealth {
|
||||
let normalized = error.lowercased()
|
||||
if normalized.contains("deactivated") {
|
||||
return .workspaceDeactivated
|
||||
}
|
||||
if normalized.contains("expired") ||
|
||||
normalized.contains("revoked") ||
|
||||
normalized.contains("unauthorized") ||
|
||||
normalized.contains("401")
|
||||
{
|
||||
return .needsReauth
|
||||
}
|
||||
if normalized.contains("missing"), normalized.contains("auth") {
|
||||
return .missingAuth
|
||||
}
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
enum CodexAccountPresentationOrdering {
|
||||
static func orderedAccounts(
|
||||
_ accounts: [CodexVisibleAccount],
|
||||
snapshots: [CodexAccountUsageSnapshot],
|
||||
activeVisibleAccountID: String?)
|
||||
-> [CodexVisibleAccount]
|
||||
{
|
||||
guard accounts.count > 1 else { return accounts }
|
||||
let snapshotByID = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) })
|
||||
let rankedAccounts = accounts.enumerated().map { index, account in
|
||||
RankedAccount(
|
||||
account: account,
|
||||
rank: Rank(
|
||||
account: account,
|
||||
snapshot: snapshotByID[account.id],
|
||||
activeVisibleAccountID: activeVisibleAccountID,
|
||||
originalIndex: index))
|
||||
}
|
||||
let grouped = Dictionary(grouping: rankedAccounts, by: { Self.workspaceSortKey(for: $0.account) })
|
||||
return grouped.values.sorted { lhs, rhs in
|
||||
(lhs.map(\.rank).min() ?? .last) < (rhs.map(\.rank).min() ?? .last)
|
||||
}.flatMap { group in
|
||||
group.sorted { lhs, rhs in lhs.rank < rhs.rank }.map(\.account)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RankedAccount {
|
||||
let account: CodexVisibleAccount
|
||||
let rank: Rank
|
||||
}
|
||||
|
||||
private struct Rank: Comparable {
|
||||
static let last = Rank(bucket: Int.max, availabilityScore: -.greatestFiniteMagnitude, originalIndex: Int.max)
|
||||
|
||||
let bucket: Int
|
||||
let availabilityScore: Double
|
||||
let displaySort: String
|
||||
let originalIndex: Int
|
||||
|
||||
private init(bucket: Int, availabilityScore: Double, originalIndex: Int) {
|
||||
self.bucket = bucket
|
||||
self.availabilityScore = availabilityScore
|
||||
self.displaySort = ""
|
||||
self.originalIndex = originalIndex
|
||||
}
|
||||
|
||||
init(
|
||||
account: CodexVisibleAccount,
|
||||
snapshot: CodexAccountUsageSnapshot?,
|
||||
activeVisibleAccountID: String?,
|
||||
originalIndex: Int)
|
||||
{
|
||||
self.originalIndex = originalIndex
|
||||
self.displaySort = account.menuDisplayName.lowercased()
|
||||
|
||||
if account.id == activeVisibleAccountID {
|
||||
self.bucket = 0
|
||||
} else {
|
||||
let health = CodexAccountHealth.status(for: account, error: snapshot?.error)
|
||||
if health != .ok {
|
||||
self.bucket = health == .missingAuth ? 4 : 3
|
||||
} else if let availability = Self.availability(snapshot?.snapshot), availability <= 0 {
|
||||
self.bucket = 2
|
||||
} else {
|
||||
self.bucket = 1
|
||||
}
|
||||
}
|
||||
self.availabilityScore = Self.availability(snapshot?.snapshot) ?? -1
|
||||
}
|
||||
|
||||
static func < (lhs: Rank, rhs: Rank) -> Bool {
|
||||
if lhs.bucket != rhs.bucket { return lhs.bucket < rhs.bucket }
|
||||
if lhs.availabilityScore != rhs.availabilityScore {
|
||||
return lhs.availabilityScore > rhs.availabilityScore
|
||||
}
|
||||
if lhs.displaySort != rhs.displaySort { return lhs.displaySort < rhs.displaySort }
|
||||
return lhs.originalIndex < rhs.originalIndex
|
||||
}
|
||||
|
||||
private static func availability(_ snapshot: UsageSnapshot?) -> Double? {
|
||||
guard let snapshot else { return nil }
|
||||
let session = snapshot.primary?.remainingPercent
|
||||
let weekly = snapshot.secondary?.remainingPercent
|
||||
return switch (session, weekly) {
|
||||
case let (.some(session), .some(weekly)):
|
||||
min(session, weekly)
|
||||
case let (.some(session), .none):
|
||||
session
|
||||
case let (.none, .some(weekly)):
|
||||
weekly
|
||||
case (.none, .none):
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func workspaceSortKey(for account: CodexVisibleAccount) -> String {
|
||||
if let workspaceAccountID = account.workspaceAccountID, !workspaceAccountID.isEmpty {
|
||||
return workspaceAccountID.lowercased()
|
||||
}
|
||||
return account.menuWorkspaceLabel?.lowercased() ?? "personal"
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexAccountWorkspaceSection: Equatable {
|
||||
let title: String
|
||||
let accounts: [CodexVisibleAccount]
|
||||
}
|
||||
|
||||
extension [CodexVisibleAccount] {
|
||||
func codexWorkspaceSections() -> [CodexAccountWorkspaceSection] {
|
||||
guard !self.isEmpty else { return [] }
|
||||
var sections: [CodexAccountWorkspaceSection] = []
|
||||
for account in self {
|
||||
let title = account.menuWorkspaceLabel ?? "Personal"
|
||||
if let index = sections.firstIndex(where: { $0.title == title }) {
|
||||
var accounts = sections[index].accounts
|
||||
accounts.append(account)
|
||||
sections[index] = CodexAccountWorkspaceSection(title: title, accounts: accounts)
|
||||
} else {
|
||||
sections.append(CodexAccountWorkspaceSection(title: title, accounts: [account]))
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct CodexSystemAccountPromotionUserFacingError: Error, Equatable {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CodexAccountPromotionCoordinator {
|
||||
let service: CodexAccountPromotionService
|
||||
weak var managedAccountCoordinator: ManagedCodexAccountCoordinator?
|
||||
private(set) var isAuthenticatingLiveAccount = false
|
||||
private(set) var isPromotingSystemAccount = false
|
||||
private(set) var userFacingError: CodexSystemAccountPromotionUserFacingError?
|
||||
|
||||
init(
|
||||
service: CodexAccountPromotionService,
|
||||
managedAccountCoordinator: ManagedCodexAccountCoordinator? = nil)
|
||||
{
|
||||
self.service = service
|
||||
self.managedAccountCoordinator = managedAccountCoordinator
|
||||
}
|
||||
|
||||
convenience init(
|
||||
settingsStore: SettingsStore,
|
||||
usageStore: UsageStore,
|
||||
managedAccountCoordinator: ManagedCodexAccountCoordinator? = nil)
|
||||
{
|
||||
self.init(
|
||||
service: CodexAccountPromotionService(settingsStore: settingsStore, usageStore: usageStore),
|
||||
managedAccountCoordinator: managedAccountCoordinator)
|
||||
}
|
||||
|
||||
func promote(managedAccountID: UUID)
|
||||
async -> Result<CodexAccountPromotionResult, CodexSystemAccountPromotionUserFacingError>
|
||||
{
|
||||
self.userFacingError = nil
|
||||
|
||||
guard !self.isInteractionBlocked() else {
|
||||
let error = Self.interactionBlockedError()
|
||||
self.userFacingError = error
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
self.isPromotingSystemAccount = true
|
||||
defer { self.isPromotingSystemAccount = false }
|
||||
|
||||
do {
|
||||
let result = try await self.service.promoteManagedAccount(id: managedAccountID)
|
||||
return .success(result)
|
||||
} catch {
|
||||
let mapped = Self.mapUserFacingError(error)
|
||||
self.userFacingError = mapped
|
||||
return .failure(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
self.userFacingError = nil
|
||||
}
|
||||
|
||||
func setLiveReauthenticationInProgress(_ isInProgress: Bool) {
|
||||
self.isAuthenticatingLiveAccount = isInProgress
|
||||
}
|
||||
|
||||
func isInteractionBlocked() -> Bool {
|
||||
self.isPromotingSystemAccount ||
|
||||
self.isAuthenticatingLiveAccount ||
|
||||
self.managedAccountCoordinator?.hasConflictingManagedAccountOperationInFlight == true
|
||||
}
|
||||
|
||||
private static func interactionBlockedError() -> CodexSystemAccountPromotionUserFacingError {
|
||||
CodexSystemAccountPromotionUserFacingError(
|
||||
title: L("Could not switch system account"),
|
||||
message: L("Finish the current managed account change before switching the system account."))
|
||||
}
|
||||
|
||||
static func mapUserFacingError(_ error: Error) -> CodexSystemAccountPromotionUserFacingError {
|
||||
let title = L("Could not switch system account")
|
||||
|
||||
if let error = error as? CodexAccountPromotionError {
|
||||
let message = switch error {
|
||||
case .targetManagedAccountNotFound:
|
||||
L("That account is no longer available in CodexBar. Refresh the account list and try again.")
|
||||
case .targetManagedAccountAuthMissing:
|
||||
L("CodexBar could not find saved auth for that account. Re-authenticate it and try again.")
|
||||
case .targetManagedAccountAuthUnreadable:
|
||||
L("CodexBar could not read saved auth for that account. Re-authenticate it and try again.")
|
||||
case .liveAccountUnreadable:
|
||||
L("CodexBar could not read the current system account on this Mac.")
|
||||
case .liveAccountMissingIdentityForPreservation:
|
||||
L("CodexBar could not safely preserve the current system account before switching.")
|
||||
case .liveAccountAPIKeyOnlyUnsupported:
|
||||
L("CodexBar can't replace a system account that is signed in with an API key only setup.")
|
||||
case .displacedLiveManagedAccountConflict:
|
||||
L(
|
||||
"CodexBar found another managed account that already uses the current system account. " +
|
||||
"Resolve the duplicate account before switching.")
|
||||
case .displacedLiveImportFailed:
|
||||
L("CodexBar could not save the current system account before switching.")
|
||||
case .managedStoreCommitFailed:
|
||||
L("CodexBar could not update managed account storage.")
|
||||
case .liveAuthSwapFailed:
|
||||
L("CodexBar could not replace the live Codex auth on this Mac.")
|
||||
}
|
||||
|
||||
return CodexSystemAccountPromotionUserFacingError(title: title, message: message)
|
||||
}
|
||||
|
||||
return CodexSystemAccountPromotionUserFacingError(title: title, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
private struct CodexPreparedImportedAccount {
|
||||
let account: ManagedCodexAccount
|
||||
let homeURL: URL
|
||||
}
|
||||
|
||||
struct CodexDisplacedLivePreservationExecutionResult: Equatable {
|
||||
let displacedLiveDisposition: CodexAccountPromotionResult.DisplacedLiveDisposition
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct CodexDisplacedLivePreservationExecutor {
|
||||
private let store: any ManagedCodexAccountStoring
|
||||
private let homeFactory: any ManagedCodexHomeProducing
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(
|
||||
store: any ManagedCodexAccountStoring,
|
||||
homeFactory: any ManagedCodexHomeProducing,
|
||||
fileManager: FileManager = .default)
|
||||
{
|
||||
self.store = store
|
||||
self.homeFactory = homeFactory
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func execute(
|
||||
plan: CodexDisplacedLivePreservationPlan,
|
||||
context: PreparedPromotionContext) throws
|
||||
-> CodexDisplacedLivePreservationExecutionResult
|
||||
{
|
||||
/*
|
||||
Safety contract:
|
||||
- This executor never swaps live auth. The caller must do that only after success.
|
||||
- Import cleanup is best-effort and leaves no orphaned managed home on failure.
|
||||
- Refresh/repair may copy auth before store commit, matching current behavior.
|
||||
*/
|
||||
switch plan {
|
||||
case .none:
|
||||
return CodexDisplacedLivePreservationExecutionResult(displacedLiveDisposition: .none)
|
||||
|
||||
case let .reject(reason):
|
||||
throw self.error(for: reason)
|
||||
|
||||
case .importNew:
|
||||
let importedAccount = try self.importDisplacedLiveAccount(from: context)
|
||||
return try self.commitImportedAccount(importedAccount)
|
||||
|
||||
case let .refreshExisting(destination, _),
|
||||
let .repairExisting(destination, _):
|
||||
guard destination.persisted.id != context.target.persisted.id else {
|
||||
throw CodexAccountPromotionError.managedStoreCommitFailed
|
||||
}
|
||||
|
||||
let refreshed = try self.refreshExistingManagedAccount(destination, from: context)
|
||||
return CodexDisplacedLivePreservationExecutionResult(
|
||||
displacedLiveDisposition: .alreadyManaged(managedAccountID: refreshed.id))
|
||||
}
|
||||
}
|
||||
|
||||
private func error(for reason: CodexDisplacedLivePreservationRejectReason) -> CodexAccountPromotionError {
|
||||
switch reason {
|
||||
case .liveUnreadable:
|
||||
.liveAccountUnreadable
|
||||
case .liveAPIKeyOnlyUnsupported:
|
||||
.liveAccountAPIKeyOnlyUnsupported
|
||||
case .liveIdentityMissingForPreservation:
|
||||
.liveAccountMissingIdentityForPreservation
|
||||
case .conflictingReadableManagedHome:
|
||||
.displacedLiveManagedAccountConflict
|
||||
}
|
||||
}
|
||||
|
||||
private func importDisplacedLiveAccount(
|
||||
from context: PreparedPromotionContext) throws
|
||||
-> CodexPreparedImportedAccount
|
||||
{
|
||||
guard case let .readable(liveAuthMaterial) = context.live.homeState else {
|
||||
throw CodexAccountPromotionError.displacedLiveImportFailed
|
||||
}
|
||||
|
||||
let importedHomeURL = self.homeFactory.makeHomeURL()
|
||||
let importedAccountID = Self.accountID(for: importedHomeURL)
|
||||
|
||||
do {
|
||||
try self.fileManager.createDirectory(at: importedHomeURL, withIntermediateDirectories: true)
|
||||
try self.writeManagedAuthData(liveAuthMaterial.rawData, to: importedHomeURL)
|
||||
|
||||
guard let liveAuthIdentity = context.live.authIdentity,
|
||||
let email = liveAuthIdentity.email,
|
||||
liveAuthIdentity.identity != .unresolved
|
||||
else {
|
||||
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
return CodexPreparedImportedAccount(
|
||||
account: ManagedCodexAccount(
|
||||
id: importedAccountID,
|
||||
email: email,
|
||||
providerAccountID: liveAuthIdentity.providerAccountID,
|
||||
workspaceLabel: liveAuthIdentity.workspaceLabel,
|
||||
workspaceAccountID: liveAuthIdentity.workspaceAccountID,
|
||||
authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData),
|
||||
managedHomePath: importedHomeURL.path,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastAuthenticatedAt: now),
|
||||
homeURL: importedHomeURL)
|
||||
} catch let error as CodexAccountPromotionError {
|
||||
try? self.removeManagedHomeIfSafe(importedHomeURL)
|
||||
throw error
|
||||
} catch {
|
||||
try? self.removeManagedHomeIfSafe(importedHomeURL)
|
||||
throw CodexAccountPromotionError.displacedLiveImportFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func commitImportedAccount(_ importedAccount: CodexPreparedImportedAccount) throws
|
||||
-> CodexDisplacedLivePreservationExecutionResult
|
||||
{
|
||||
do {
|
||||
let latestManagedAccounts = try self.store.loadAccounts()
|
||||
try self.store.storeAccounts(ManagedCodexAccountSet(
|
||||
version: latestManagedAccounts.version,
|
||||
accounts: latestManagedAccounts.accounts + [importedAccount.account]))
|
||||
return try self.resolveImportedAccountAfterCommit(importedAccount)
|
||||
} catch let error as CodexAccountPromotionError {
|
||||
try? self.removeManagedHomeIfSafe(importedAccount.homeURL)
|
||||
throw error
|
||||
} catch {
|
||||
try? self.removeManagedHomeIfSafe(importedAccount.homeURL)
|
||||
throw CodexAccountPromotionError.managedStoreCommitFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveImportedAccountAfterCommit(_ importedAccount: CodexPreparedImportedAccount) throws
|
||||
-> CodexDisplacedLivePreservationExecutionResult
|
||||
{
|
||||
let persistedManagedAccounts = try self.store.loadAccounts()
|
||||
if persistedManagedAccounts.account(id: importedAccount.account.id) != nil {
|
||||
return CodexDisplacedLivePreservationExecutionResult(
|
||||
displacedLiveDisposition: .imported(managedAccountID: importedAccount.account.id))
|
||||
}
|
||||
|
||||
guard let existingManagedAccount = self.repairDestination(
|
||||
in: persistedManagedAccounts,
|
||||
for: importedAccount.account)
|
||||
else {
|
||||
throw CodexAccountPromotionError.managedStoreCommitFailed
|
||||
}
|
||||
|
||||
let repairedManagedAccount = ManagedCodexAccount(
|
||||
id: existingManagedAccount.id,
|
||||
email: importedAccount.account.email,
|
||||
providerAccountID: importedAccount.account.providerAccountID,
|
||||
workspaceLabel: importedAccount.account.workspaceLabel,
|
||||
workspaceAccountID: importedAccount.account.workspaceAccountID,
|
||||
authFingerprint: importedAccount.account.authFingerprint,
|
||||
managedHomePath: importedAccount.homeURL.path,
|
||||
createdAt: existingManagedAccount.createdAt,
|
||||
updatedAt: importedAccount.account.updatedAt,
|
||||
lastAuthenticatedAt: importedAccount.account.lastAuthenticatedAt)
|
||||
try self.store.storeAccounts(ManagedCodexAccountSet(
|
||||
version: persistedManagedAccounts.version,
|
||||
accounts: persistedManagedAccounts.accounts.map { account in
|
||||
guard account.id == existingManagedAccount.id else { return account }
|
||||
return repairedManagedAccount
|
||||
}))
|
||||
if existingManagedAccount.managedHomePath != importedAccount.homeURL.path {
|
||||
try? self.removeManagedHomeIfSafe(
|
||||
URL(fileURLWithPath: existingManagedAccount.managedHomePath, isDirectory: true))
|
||||
}
|
||||
|
||||
return CodexDisplacedLivePreservationExecutionResult(
|
||||
displacedLiveDisposition: .alreadyManaged(managedAccountID: existingManagedAccount.id))
|
||||
}
|
||||
|
||||
private func repairDestination(
|
||||
in persistedManagedAccounts: ManagedCodexAccountSet,
|
||||
for importedAccount: ManagedCodexAccount) -> ManagedCodexAccount?
|
||||
{
|
||||
if let providerAccountID = importedAccount.providerAccountID {
|
||||
return persistedManagedAccounts.account(
|
||||
email: importedAccount.email,
|
||||
providerAccountID: providerAccountID)
|
||||
}
|
||||
|
||||
let normalizedEmail = importedAccount.email
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return persistedManagedAccounts.accounts.first {
|
||||
$0.email == normalizedEmail && $0.providerAccountID == nil
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshExistingManagedAccount(
|
||||
_ destination: PreparedStoredManagedAccount,
|
||||
from context: PreparedPromotionContext) throws
|
||||
-> ManagedCodexAccount
|
||||
{
|
||||
guard case let .readable(liveAuthMaterial) = context.live.homeState else {
|
||||
throw CodexAccountPromotionError.managedStoreCommitFailed
|
||||
}
|
||||
guard let liveAuthIdentity = context.live.authIdentity else {
|
||||
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
|
||||
}
|
||||
|
||||
do {
|
||||
let latestManagedAccounts = try self.store.loadAccounts()
|
||||
guard let persistedManagedAccount = latestManagedAccounts.account(id: destination.persisted.id) else {
|
||||
throw CodexAccountPromotionError.managedStoreCommitFailed
|
||||
}
|
||||
|
||||
let email = liveAuthIdentity.email
|
||||
?? (liveAuthIdentity.providerAccountID != nil ? persistedManagedAccount.email : nil)
|
||||
guard let email, liveAuthIdentity.identity != .unresolved else {
|
||||
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
let refreshedManagedAccount = ManagedCodexAccount(
|
||||
id: persistedManagedAccount.id,
|
||||
email: email,
|
||||
providerAccountID: liveAuthIdentity.providerAccountID ?? persistedManagedAccount.providerAccountID,
|
||||
workspaceLabel: liveAuthIdentity.workspaceLabel ?? persistedManagedAccount.workspaceLabel,
|
||||
workspaceAccountID: liveAuthIdentity.workspaceAccountID ?? persistedManagedAccount.workspaceAccountID,
|
||||
authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData),
|
||||
managedHomePath: persistedManagedAccount.managedHomePath,
|
||||
createdAt: persistedManagedAccount.createdAt,
|
||||
updatedAt: now,
|
||||
lastAuthenticatedAt: now)
|
||||
|
||||
let refreshedHomeURL = URL(fileURLWithPath: persistedManagedAccount.managedHomePath, isDirectory: true)
|
||||
do {
|
||||
try self.homeFactory.validateManagedHomeForDeletion(refreshedHomeURL)
|
||||
} catch {
|
||||
throw CodexAccountPromotionError.displacedLiveImportFailed
|
||||
}
|
||||
|
||||
try self.fileManager.createDirectory(at: refreshedHomeURL, withIntermediateDirectories: true)
|
||||
try self.writeManagedAuthData(liveAuthMaterial.rawData, to: refreshedHomeURL)
|
||||
try self.store.storeAccounts(ManagedCodexAccountSet(
|
||||
version: latestManagedAccounts.version,
|
||||
accounts: latestManagedAccounts.accounts.map { account in
|
||||
guard account.id == persistedManagedAccount.id else { return account }
|
||||
return refreshedManagedAccount
|
||||
}))
|
||||
return refreshedManagedAccount
|
||||
} catch let error as CodexAccountPromotionError {
|
||||
throw error
|
||||
} catch {
|
||||
throw CodexAccountPromotionError.managedStoreCommitFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func writeManagedAuthData(_ data: Data, to homeURL: URL) throws {
|
||||
let authFileURL = CodexAccountPromotionService.authFileURL(for: homeURL)
|
||||
try data.write(to: authFileURL, options: .atomic)
|
||||
try self.fileManager.setAttributes(
|
||||
[.posixPermissions: NSNumber(value: Int16(0o600))],
|
||||
ofItemAtPath: authFileURL.path)
|
||||
}
|
||||
|
||||
private func removeManagedHomeIfSafe(_ homeURL: URL) throws {
|
||||
try self.homeFactory.validateManagedHomeForDeletion(homeURL)
|
||||
if self.fileManager.fileExists(atPath: homeURL.path) {
|
||||
try self.fileManager.removeItem(at: homeURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func accountID(for homeURL: URL) -> UUID {
|
||||
UUID(uuidString: homeURL.lastPathComponent) ?? UUID()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum CodexDisplacedLivePreservationNoneReason: Equatable {
|
||||
case liveMissing
|
||||
case targetMatchesLiveAuthIdentity
|
||||
}
|
||||
|
||||
enum CodexDisplacedLivePreservationRejectReason: Equatable {
|
||||
case liveUnreadable
|
||||
case liveAPIKeyOnlyUnsupported
|
||||
case liveIdentityMissingForPreservation
|
||||
case conflictingReadableManagedHome
|
||||
}
|
||||
|
||||
enum CodexDisplacedLivePreservationImportReason: Equatable {
|
||||
case noExistingManagedDestination
|
||||
}
|
||||
|
||||
enum CodexDisplacedLivePreservationRefreshReason: Equatable {
|
||||
case readableHomeIdentityMatch
|
||||
case readableHomeIdentityMatchUsingPersistedEmailFallback
|
||||
}
|
||||
|
||||
enum CodexDisplacedLivePreservationRepairReason: Equatable {
|
||||
case persistedProviderMatchWithMissingHome
|
||||
case persistedProviderMatchWithUnreadableHome
|
||||
case persistedLegacyEmailMatch
|
||||
}
|
||||
|
||||
enum CodexDisplacedLivePreservationPlan {
|
||||
case none(reason: CodexDisplacedLivePreservationNoneReason)
|
||||
case reject(reason: CodexDisplacedLivePreservationRejectReason)
|
||||
case importNew(reason: CodexDisplacedLivePreservationImportReason)
|
||||
case refreshExisting(
|
||||
destination: PreparedStoredManagedAccount,
|
||||
reason: CodexDisplacedLivePreservationRefreshReason)
|
||||
case repairExisting(
|
||||
destination: PreparedStoredManagedAccount,
|
||||
reason: CodexDisplacedLivePreservationRepairReason)
|
||||
}
|
||||
|
||||
struct CodexDisplacedLivePreservationPlanner {
|
||||
func makePlan(context: PreparedPromotionContext) -> CodexDisplacedLivePreservationPlan {
|
||||
switch context.live.homeState {
|
||||
case .missing:
|
||||
return .none(reason: .liveMissing)
|
||||
case .unreadable:
|
||||
return .reject(reason: .liveUnreadable)
|
||||
case .apiKeyOnly:
|
||||
return .reject(reason: .liveAPIKeyOnlyUnsupported)
|
||||
case .readable:
|
||||
break
|
||||
}
|
||||
|
||||
guard let liveAuthIdentity = context.live.authIdentity else {
|
||||
return .reject(reason: .liveIdentityMissingForPreservation)
|
||||
}
|
||||
|
||||
if let targetAuthIdentity = context.target.authIdentity,
|
||||
CodexIdentityMatcher.matches(
|
||||
targetAuthIdentity.identity,
|
||||
lhsEmail: targetAuthIdentity.email,
|
||||
liveAuthIdentity.identity,
|
||||
rhsEmail: liveAuthIdentity.email)
|
||||
{
|
||||
return .none(reason: .targetMatchesLiveAuthIdentity)
|
||||
}
|
||||
|
||||
let candidates = context.storedManagedAccounts.filter { $0.persisted.id != context.target.persisted.id }
|
||||
if let destination = self.findReadableHomeMatch(in: candidates, liveAuthIdentity: liveAuthIdentity) {
|
||||
let reason: CodexDisplacedLivePreservationRefreshReason =
|
||||
if liveAuthIdentity.email == nil {
|
||||
.readableHomeIdentityMatchUsingPersistedEmailFallback
|
||||
} else {
|
||||
.readableHomeIdentityMatch
|
||||
}
|
||||
return .refreshExisting(destination: destination, reason: reason)
|
||||
}
|
||||
|
||||
if self.hasConflictingReadableHome(in: candidates, liveAuthIdentity: liveAuthIdentity) {
|
||||
return .reject(reason: .conflictingReadableManagedHome)
|
||||
}
|
||||
|
||||
if let repaired = self.findPersistedRepairMatch(in: candidates, liveAuthIdentity: liveAuthIdentity) {
|
||||
return .repairExisting(destination: repaired.destination, reason: repaired.reason)
|
||||
}
|
||||
|
||||
guard liveAuthIdentity.identity != .unresolved, liveAuthIdentity.email != nil else {
|
||||
return .reject(reason: .liveIdentityMissingForPreservation)
|
||||
}
|
||||
|
||||
return .importNew(reason: .noExistingManagedDestination)
|
||||
}
|
||||
|
||||
private func findReadableHomeMatch(
|
||||
in candidates: [PreparedStoredManagedAccount],
|
||||
liveAuthIdentity: PreparedIdentity)
|
||||
-> PreparedStoredManagedAccount?
|
||||
{
|
||||
candidates.first { candidate in
|
||||
guard let candidateAuthIdentity = candidate.authIdentity else { return false }
|
||||
return CodexIdentityMatcher.matches(
|
||||
candidateAuthIdentity.identity,
|
||||
lhsEmail: candidateAuthIdentity.email,
|
||||
liveAuthIdentity.identity,
|
||||
rhsEmail: liveAuthIdentity.email)
|
||||
}
|
||||
}
|
||||
|
||||
private func findPersistedRepairMatch(
|
||||
in candidates: [PreparedStoredManagedAccount],
|
||||
liveAuthIdentity: PreparedIdentity)
|
||||
-> (destination: PreparedStoredManagedAccount, reason: CodexDisplacedLivePreservationRepairReason)?
|
||||
{
|
||||
switch liveAuthIdentity.identity {
|
||||
case let .providerAccount(id):
|
||||
let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id)
|
||||
if let destination = candidates.first(where: {
|
||||
guard $0.persisted.providerAccountID == providerAccountID else { return false }
|
||||
guard let liveEmail = liveAuthIdentity.email else { return true }
|
||||
return $0.persisted.email == liveEmail
|
||||
}),
|
||||
let reason = self.providerRepairReason(for: destination)
|
||||
{
|
||||
return (destination, reason)
|
||||
}
|
||||
|
||||
if let liveEmail = liveAuthIdentity.email,
|
||||
let destination = candidates.first(where: {
|
||||
$0.persisted.providerAccountID == nil && $0.persisted.email == liveEmail
|
||||
})
|
||||
{
|
||||
return (destination, .persistedLegacyEmailMatch)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case let .emailOnly(normalizedEmail):
|
||||
guard let destination = candidates.first(where: {
|
||||
$0.persisted.providerAccountID == nil && $0.persisted.email == normalizedEmail
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
return (destination, .persistedLegacyEmailMatch)
|
||||
|
||||
case .unresolved:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func hasConflictingReadableHome(
|
||||
in candidates: [PreparedStoredManagedAccount],
|
||||
liveAuthIdentity: PreparedIdentity)
|
||||
-> Bool
|
||||
{
|
||||
guard case let .providerAccount(id) = liveAuthIdentity.identity else {
|
||||
return false
|
||||
}
|
||||
|
||||
let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id)
|
||||
return candidates.contains { candidate in
|
||||
guard candidate.persisted.providerAccountID == providerAccountID else { return false }
|
||||
if let liveEmail = liveAuthIdentity.email, candidate.persisted.email != liveEmail {
|
||||
return false
|
||||
}
|
||||
guard case .readable = candidate.homeState else { return false }
|
||||
guard let candidateAuthIdentity = candidate.authIdentity else { return false }
|
||||
return !CodexIdentityMatcher.matches(
|
||||
candidateAuthIdentity.identity,
|
||||
lhsEmail: candidateAuthIdentity.email,
|
||||
liveAuthIdentity.identity,
|
||||
rhsEmail: liveAuthIdentity.email)
|
||||
}
|
||||
}
|
||||
|
||||
private func providerRepairReason(
|
||||
for destination: PreparedStoredManagedAccount)
|
||||
-> CodexDisplacedLivePreservationRepairReason?
|
||||
{
|
||||
switch destination.homeState {
|
||||
case .missing:
|
||||
.persistedProviderMatchWithMissingHome
|
||||
case .unreadable:
|
||||
.persistedProviderMatchWithUnreadableHome
|
||||
case .readable:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
struct PreparedIdentity: Equatable {
|
||||
let email: String?
|
||||
let identity: CodexIdentity
|
||||
let providerAccountID: String?
|
||||
let workspaceLabel: String?
|
||||
let workspaceAccountID: String?
|
||||
}
|
||||
|
||||
struct PreparedAuthMaterial {
|
||||
let homeURL: URL
|
||||
let rawData: Data
|
||||
let credentials: CodexOAuthCredentials
|
||||
let runtimeAccount: CodexAuthBackedAccount
|
||||
let authIdentity: PreparedIdentity
|
||||
}
|
||||
|
||||
enum PreparedManagedHomeState {
|
||||
case readable(PreparedAuthMaterial)
|
||||
case missing(homeURL: URL)
|
||||
case unreadable(homeURL: URL)
|
||||
}
|
||||
|
||||
struct PreparedStoredManagedAccount {
|
||||
let persisted: ManagedCodexAccount
|
||||
let persistedIdentity: PreparedIdentity
|
||||
let homeState: PreparedManagedHomeState
|
||||
|
||||
var authIdentity: PreparedIdentity? {
|
||||
switch self.homeState {
|
||||
case let .readable(authMaterial):
|
||||
authMaterial.authIdentity
|
||||
case .missing, .unreadable:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PreparedLiveHomeState {
|
||||
case missing(homeURL: URL)
|
||||
case unreadable(homeURL: URL)
|
||||
case apiKeyOnly(PreparedAuthMaterial)
|
||||
case readable(PreparedAuthMaterial)
|
||||
}
|
||||
|
||||
struct PreparedLiveAccount {
|
||||
let homeState: PreparedLiveHomeState
|
||||
|
||||
var homeURL: URL {
|
||||
switch self.homeState {
|
||||
case let .missing(homeURL), let .unreadable(homeURL):
|
||||
homeURL
|
||||
case let .apiKeyOnly(authMaterial), let .readable(authMaterial):
|
||||
authMaterial.homeURL
|
||||
}
|
||||
}
|
||||
|
||||
var authIdentity: PreparedIdentity? {
|
||||
switch self.homeState {
|
||||
case let .apiKeyOnly(authMaterial), let .readable(authMaterial):
|
||||
authMaterial.authIdentity
|
||||
case .missing, .unreadable:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedPromotionContext {
|
||||
let snapshot: CodexAccountReconciliationSnapshot
|
||||
let managedAccounts: ManagedCodexAccountSet
|
||||
let storedManagedAccounts: [PreparedStoredManagedAccount]
|
||||
let target: PreparedStoredManagedAccount
|
||||
let live: PreparedLiveAccount
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PreparedPromotionContextBuilder {
|
||||
private let store: any ManagedCodexAccountStoring
|
||||
private let workspaceResolver: any ManagedCodexWorkspaceResolving
|
||||
private let snapshotLoader: any CodexAccountReconciliationSnapshotLoading
|
||||
private let authMaterialReader: any CodexAuthMaterialReading
|
||||
private let baseEnvironment: [String: String]
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(
|
||||
store: any ManagedCodexAccountStoring,
|
||||
workspaceResolver: any ManagedCodexWorkspaceResolving,
|
||||
snapshotLoader: any CodexAccountReconciliationSnapshotLoading,
|
||||
authMaterialReader: any CodexAuthMaterialReading,
|
||||
baseEnvironment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileManager: FileManager = .default)
|
||||
{
|
||||
self.store = store
|
||||
self.workspaceResolver = workspaceResolver
|
||||
self.snapshotLoader = snapshotLoader
|
||||
self.authMaterialReader = authMaterialReader
|
||||
self.baseEnvironment = baseEnvironment
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func build(targetID: UUID) async throws -> PreparedPromotionContext {
|
||||
let snapshot = self.snapshotLoader.loadSnapshot()
|
||||
let managedAccounts = try self.store.loadAccounts()
|
||||
var preparedAccounts: [PreparedStoredManagedAccount] = []
|
||||
preparedAccounts.reserveCapacity(managedAccounts.accounts.count)
|
||||
for account in managedAccounts.accounts {
|
||||
let preparedAccount = try await self.prepareStoredManagedAccount(account)
|
||||
preparedAccounts.append(preparedAccount)
|
||||
}
|
||||
|
||||
guard let target = preparedAccounts.first(where: { $0.persisted.id == targetID }) else {
|
||||
throw CodexAccountPromotionError.targetManagedAccountNotFound
|
||||
}
|
||||
|
||||
let live = await self.prepareLiveAccount()
|
||||
return PreparedPromotionContext(
|
||||
snapshot: snapshot,
|
||||
managedAccounts: managedAccounts,
|
||||
storedManagedAccounts: preparedAccounts,
|
||||
target: target,
|
||||
live: live)
|
||||
}
|
||||
|
||||
private func prepareStoredManagedAccount(
|
||||
_ account: ManagedCodexAccount) async throws
|
||||
-> PreparedStoredManagedAccount
|
||||
{
|
||||
let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true)
|
||||
let persistedIdentity = Self.persistedIdentity(from: account)
|
||||
let homeState = await self.prepareManagedHomeState(homeURL: homeURL)
|
||||
|
||||
return PreparedStoredManagedAccount(
|
||||
persisted: account,
|
||||
persistedIdentity: persistedIdentity,
|
||||
homeState: homeState)
|
||||
}
|
||||
|
||||
private func prepareManagedHomeState(homeURL: URL) async -> PreparedManagedHomeState {
|
||||
let readResult = self.readAuthData(homeURL: homeURL)
|
||||
switch readResult {
|
||||
case .missing:
|
||||
return .missing(homeURL: homeURL)
|
||||
case .unreadable:
|
||||
return .unreadable(homeURL: homeURL)
|
||||
case let .readable(rawData):
|
||||
guard let authMaterial = await self.inspectAuthMaterial(homeURL: homeURL, rawData: rawData) else {
|
||||
return .unreadable(homeURL: homeURL)
|
||||
}
|
||||
return .readable(authMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareLiveAccount() async -> PreparedLiveAccount {
|
||||
let liveHomeURL = self.liveHomeURL()
|
||||
let readResult = self.readAuthData(homeURL: liveHomeURL)
|
||||
switch readResult {
|
||||
case .missing:
|
||||
return PreparedLiveAccount(homeState: .missing(homeURL: liveHomeURL))
|
||||
case .unreadable:
|
||||
return PreparedLiveAccount(homeState: .unreadable(homeURL: liveHomeURL))
|
||||
case let .readable(rawData):
|
||||
guard let authMaterial = await self.inspectAuthMaterial(homeURL: liveHomeURL, rawData: rawData) else {
|
||||
return PreparedLiveAccount(homeState: .unreadable(homeURL: liveHomeURL))
|
||||
}
|
||||
if Self.isAPIKeyOnly(credentials: authMaterial.credentials, rawData: authMaterial.rawData) {
|
||||
return PreparedLiveAccount(homeState: .apiKeyOnly(authMaterial))
|
||||
}
|
||||
return PreparedLiveAccount(homeState: .readable(authMaterial))
|
||||
}
|
||||
}
|
||||
|
||||
private func inspectAuthMaterial(homeURL: URL, rawData: Data) async -> PreparedAuthMaterial? {
|
||||
guard let credentials = try? CodexOAuthCredentialsStore.parse(data: rawData),
|
||||
let runtimeAccount = try? Self.runtimeAccount(from: rawData)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let authIdentity = await self.derivedIdentity(
|
||||
homePath: homeURL.path,
|
||||
runtimeAccount: runtimeAccount)
|
||||
|
||||
return PreparedAuthMaterial(
|
||||
homeURL: homeURL,
|
||||
rawData: rawData,
|
||||
credentials: credentials,
|
||||
runtimeAccount: runtimeAccount,
|
||||
authIdentity: authIdentity)
|
||||
}
|
||||
|
||||
private func derivedIdentity(homePath: String, runtimeAccount: CodexAuthBackedAccount) async -> PreparedIdentity {
|
||||
let normalizedEmail = Self.normalizeEmail(runtimeAccount.email)
|
||||
let normalizedIdentity = Self.normalizedIdentity(runtimeAccount.identity, email: normalizedEmail)
|
||||
let providerAccountID: String? = switch normalizedIdentity {
|
||||
case let .providerAccount(id):
|
||||
ManagedCodexAccount.normalizeProviderAccountID(id)
|
||||
case .emailOnly, .unresolved:
|
||||
nil
|
||||
}
|
||||
let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let providerAccountID {
|
||||
await self.workspaceResolver.resolveWorkspaceIdentity(
|
||||
homePath: homePath,
|
||||
providerAccountID: providerAccountID)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return PreparedIdentity(
|
||||
email: normalizedEmail,
|
||||
identity: normalizedIdentity,
|
||||
providerAccountID: providerAccountID,
|
||||
workspaceLabel: workspaceIdentity?.workspaceLabel,
|
||||
workspaceAccountID: workspaceIdentity?.workspaceAccountID ?? providerAccountID)
|
||||
}
|
||||
|
||||
private static func persistedIdentity(from account: ManagedCodexAccount) -> PreparedIdentity {
|
||||
let normalizedEmail = Self.normalizeEmail(account.email)
|
||||
let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(account.providerAccountID)
|
||||
let identity = Self.normalizedIdentity(
|
||||
CodexIdentityResolver.resolve(accountId: providerAccountID, email: normalizedEmail),
|
||||
email: normalizedEmail)
|
||||
|
||||
return PreparedIdentity(
|
||||
email: normalizedEmail,
|
||||
identity: identity,
|
||||
providerAccountID: providerAccountID,
|
||||
workspaceLabel: account.workspaceLabel,
|
||||
workspaceAccountID: account.workspaceAccountID)
|
||||
}
|
||||
|
||||
private func liveHomeURL() -> URL {
|
||||
CodexHomeScope.ambientHomeURL(env: self.baseEnvironment, fileManager: self.fileManager)
|
||||
}
|
||||
|
||||
private func readAuthData(homeURL: URL) -> PreparedAuthReadState {
|
||||
do {
|
||||
let rawData = try self.authMaterialReader.readAuthData(homeURL: homeURL)
|
||||
guard let rawData else {
|
||||
return .missing
|
||||
}
|
||||
return .readable(rawData)
|
||||
} catch {
|
||||
return .unreadable
|
||||
}
|
||||
}
|
||||
|
||||
private static func runtimeAccount(from rawData: Data) throws -> CodexAuthBackedAccount {
|
||||
guard let json = try JSONSerialization.jsonObject(with: rawData) as? [String: Any] else {
|
||||
throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON")
|
||||
}
|
||||
|
||||
let tokens = json["tokens"] as? [String: Any]
|
||||
let idToken = tokens.flatMap {
|
||||
Self.nonEmptyString(in: $0, snakeCaseKey: "id_token", camelCaseKey: "idToken")
|
||||
}
|
||||
let payload = idToken.flatMap(UsageFetcher.parseJWT)
|
||||
let authDict = payload?["https://api.openai.com/auth"] as? [String: Any]
|
||||
let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any]
|
||||
|
||||
let email = Self.normalizeEmail(
|
||||
(payload?["email"] as? String) ?? (profileDict?["email"] as? String))
|
||||
let plan = Self.normalizedField(
|
||||
(authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String))
|
||||
let accountID = ManagedCodexAccount.normalizeProviderAccountID(
|
||||
tokens.flatMap {
|
||||
Self.nonEmptyString(in: $0, snakeCaseKey: "account_id", camelCaseKey: "accountId")
|
||||
}
|
||||
?? (authDict?["chatgpt_account_id"] as? String)
|
||||
?? (payload?["chatgpt_account_id"] as? String))
|
||||
let identity = Self.normalizedIdentity(
|
||||
CodexIdentityResolver.resolve(accountId: accountID, email: email),
|
||||
email: email)
|
||||
|
||||
return CodexAuthBackedAccount(identity: identity, email: email, plan: plan)
|
||||
}
|
||||
|
||||
private static func normalizedField(_ value: String?) -> String? {
|
||||
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func normalizeEmail(_ email: String?) -> String? {
|
||||
CodexIdentityResolver.normalizeEmail(email)
|
||||
}
|
||||
|
||||
private static func normalizedIdentity(_ identity: CodexIdentity, email: String?) -> CodexIdentity {
|
||||
guard let email else { return identity }
|
||||
return CodexIdentityMatcher.normalized(identity, fallbackEmail: email)
|
||||
}
|
||||
|
||||
private static func isAPIKeyOnly(credentials: CodexOAuthCredentials, rawData: Data) -> Bool {
|
||||
guard self.hasUsableOAuthTokens(in: rawData) == false else {
|
||||
return false
|
||||
}
|
||||
return credentials.refreshToken.isEmpty
|
||||
&& credentials.idToken == nil
|
||||
&& credentials.accountId == nil
|
||||
&& credentials.lastRefresh == nil
|
||||
}
|
||||
|
||||
private static func hasUsableOAuthTokens(in rawData: Data) -> Bool {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: rawData) as? [String: Any],
|
||||
let tokens = json["tokens"] as? [String: Any]
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let accessToken = self.nonEmptyString(
|
||||
in: tokens,
|
||||
snakeCaseKey: "access_token",
|
||||
camelCaseKey: "accessToken")
|
||||
let refreshToken = self.nonEmptyString(
|
||||
in: tokens,
|
||||
snakeCaseKey: "refresh_token",
|
||||
camelCaseKey: "refreshToken")
|
||||
return accessToken != nil && refreshToken != nil
|
||||
}
|
||||
|
||||
private static func nonEmptyString(
|
||||
in dictionary: [String: Any],
|
||||
snakeCaseKey: String,
|
||||
camelCaseKey: String)
|
||||
-> String?
|
||||
{
|
||||
if let value = dictionary[snakeCaseKey] as? String,
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
{
|
||||
return value
|
||||
}
|
||||
if let value = dictionary[camelCaseKey] as? String,
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
{
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum PreparedAuthReadState {
|
||||
case missing
|
||||
case unreadable
|
||||
case readable(Data)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import CodexBarCore
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol CodexAccountReconciliationSnapshotLoading {
|
||||
func loadSnapshot() -> CodexAccountReconciliationSnapshot
|
||||
}
|
||||
|
||||
protocol CodexAuthMaterialReading: Sendable {
|
||||
func readAuthData(homeURL: URL) throws -> Data?
|
||||
}
|
||||
|
||||
protocol CodexLiveAuthSwapping: Sendable {
|
||||
func swapLiveAuthData(_ data: Data, liveHomeURL: URL) throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol CodexActiveSourceWriting {
|
||||
func writeCodexActiveSource(_ source: CodexActiveSource)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol CodexAccountScopedRefreshing {
|
||||
func refreshCodexAccountScopedState(allowDisabled: Bool) async
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct SettingsStoreCodexAccountReconciliationSnapshotLoader: CodexAccountReconciliationSnapshotLoading {
|
||||
private let settingsStore: SettingsStore
|
||||
|
||||
init(settingsStore: SettingsStore) {
|
||||
self.settingsStore = settingsStore
|
||||
}
|
||||
|
||||
func loadSnapshot() -> CodexAccountReconciliationSnapshot {
|
||||
self.settingsStore.codexAccountReconciliationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultCodexAuthMaterialReader: CodexAuthMaterialReading {
|
||||
func readAuthData(homeURL: URL) throws -> Data? {
|
||||
let authFileURL = CodexAccountPromotionService.authFileURL(for: homeURL)
|
||||
guard FileManager.default.fileExists(atPath: authFileURL.path) else {
|
||||
return nil
|
||||
}
|
||||
return try Data(contentsOf: authFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultCodexLiveAuthSwapper: CodexLiveAuthSwapping {
|
||||
func swapLiveAuthData(_ data: Data, liveHomeURL: URL) throws {
|
||||
try FileManager.default.createDirectory(at: liveHomeURL, withIntermediateDirectories: true)
|
||||
|
||||
let liveAuthURL = CodexAccountPromotionService.authFileURL(for: liveHomeURL)
|
||||
let stagedAuthURL = liveHomeURL.appendingPathComponent(
|
||||
"auth.json.codexbar-staged-\(UUID().uuidString)",
|
||||
isDirectory: false)
|
||||
|
||||
do {
|
||||
try data.write(to: stagedAuthURL)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: NSNumber(value: Int16(0o600))],
|
||||
ofItemAtPath: stagedAuthURL.path)
|
||||
try self.renameItem(at: stagedAuthURL, to: liveAuthURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: stagedAuthURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func renameItem(at sourceURL: URL, to destinationURL: URL) throws {
|
||||
let sourcePath = sourceURL.path
|
||||
let destinationPath = destinationURL.path
|
||||
|
||||
let result = sourcePath.withCString { sourceFS in
|
||||
destinationPath.withCString { destinationFS in
|
||||
rename(sourceFS, destinationFS)
|
||||
}
|
||||
}
|
||||
|
||||
guard result == 0 else {
|
||||
throw NSError(
|
||||
domain: NSPOSIXErrorDomain,
|
||||
code: Int(errno),
|
||||
userInfo: [NSFilePathErrorKey: destinationPath])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct SettingsStoreCodexActiveSourceWriter: CodexActiveSourceWriting {
|
||||
private let settingsStore: SettingsStore
|
||||
|
||||
init(settingsStore: SettingsStore) {
|
||||
self.settingsStore = settingsStore
|
||||
}
|
||||
|
||||
func writeCodexActiveSource(_ source: CodexActiveSource) {
|
||||
self.settingsStore.codexActiveSource = source
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct UsageStoreCodexAccountScopedRefresher: CodexAccountScopedRefreshing {
|
||||
private let usageStore: UsageStore
|
||||
|
||||
init(usageStore: UsageStore) {
|
||||
self.usageStore = usageStore
|
||||
}
|
||||
|
||||
func refreshCodexAccountScopedState(allowDisabled: Bool) async {
|
||||
await self.usageStore.refreshCodexAccountScopedState(allowDisabled: allowDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexAccountPromotionResult: Equatable {
|
||||
enum Outcome: Equatable {
|
||||
case promoted
|
||||
case convergedNoOp
|
||||
}
|
||||
|
||||
enum DisplacedLiveDisposition: Equatable {
|
||||
case none
|
||||
case alreadyManaged(managedAccountID: UUID)
|
||||
case imported(managedAccountID: UUID)
|
||||
}
|
||||
|
||||
let targetManagedAccountID: UUID
|
||||
let outcome: Outcome
|
||||
let displacedLiveDisposition: DisplacedLiveDisposition
|
||||
let didMutateLiveAuth: Bool
|
||||
let resultingActiveSource: CodexActiveSource
|
||||
}
|
||||
|
||||
enum CodexAccountPromotionError: Error, Equatable {
|
||||
case targetManagedAccountNotFound
|
||||
case targetManagedAccountAuthMissing
|
||||
case targetManagedAccountAuthUnreadable
|
||||
case liveAccountUnreadable
|
||||
case liveAccountMissingIdentityForPreservation
|
||||
case liveAccountAPIKeyOnlyUnsupported
|
||||
case displacedLiveManagedAccountConflict
|
||||
case displacedLiveImportFailed
|
||||
case managedStoreCommitFailed
|
||||
case liveAuthSwapFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CodexAccountPromotionService {
|
||||
private let store: any ManagedCodexAccountStoring
|
||||
private let homeFactory: any ManagedCodexHomeProducing
|
||||
private let identityReader: any ManagedCodexIdentityReading
|
||||
private let workspaceResolver: any ManagedCodexWorkspaceResolving
|
||||
private let snapshotLoader: any CodexAccountReconciliationSnapshotLoading
|
||||
private let authMaterialReader: any CodexAuthMaterialReading
|
||||
private let liveAuthSwapper: any CodexLiveAuthSwapping
|
||||
private let activeSourceWriter: any CodexActiveSourceWriting
|
||||
private let accountScopedRefresher: any CodexAccountScopedRefreshing
|
||||
private let baseEnvironment: [String: String]
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(
|
||||
store: any ManagedCodexAccountStoring,
|
||||
homeFactory: any ManagedCodexHomeProducing,
|
||||
identityReader: any ManagedCodexIdentityReading,
|
||||
workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(),
|
||||
snapshotLoader: any CodexAccountReconciliationSnapshotLoading,
|
||||
authMaterialReader: any CodexAuthMaterialReading,
|
||||
liveAuthSwapper: any CodexLiveAuthSwapping,
|
||||
activeSourceWriter: any CodexActiveSourceWriting,
|
||||
accountScopedRefresher: any CodexAccountScopedRefreshing,
|
||||
baseEnvironment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileManager: FileManager = .default)
|
||||
{
|
||||
self.store = store
|
||||
self.homeFactory = homeFactory
|
||||
self.identityReader = identityReader
|
||||
self.workspaceResolver = workspaceResolver
|
||||
self.snapshotLoader = snapshotLoader
|
||||
self.authMaterialReader = authMaterialReader
|
||||
self.liveAuthSwapper = liveAuthSwapper
|
||||
self.activeSourceWriter = activeSourceWriter
|
||||
self.accountScopedRefresher = accountScopedRefresher
|
||||
self.baseEnvironment = baseEnvironment
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
convenience init(
|
||||
settingsStore: SettingsStore,
|
||||
usageStore: UsageStore,
|
||||
baseEnvironment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileManager: FileManager = .default)
|
||||
{
|
||||
self.init(
|
||||
store: FileManagedCodexAccountStore(fileManager: fileManager),
|
||||
homeFactory: ManagedCodexHomeFactory(fileManager: fileManager),
|
||||
identityReader: DefaultManagedCodexIdentityReader(),
|
||||
workspaceResolver: DefaultManagedCodexWorkspaceResolver(),
|
||||
snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: settingsStore),
|
||||
authMaterialReader: DefaultCodexAuthMaterialReader(),
|
||||
liveAuthSwapper: DefaultCodexLiveAuthSwapper(),
|
||||
activeSourceWriter: SettingsStoreCodexActiveSourceWriter(settingsStore: settingsStore),
|
||||
accountScopedRefresher: UsageStoreCodexAccountScopedRefresher(usageStore: usageStore),
|
||||
baseEnvironment: baseEnvironment,
|
||||
fileManager: fileManager)
|
||||
}
|
||||
|
||||
func promoteManagedAccount(id: UUID) async throws -> CodexAccountPromotionResult {
|
||||
let contextBuilder = PreparedPromotionContextBuilder(
|
||||
store: self.store,
|
||||
workspaceResolver: self.workspaceResolver,
|
||||
snapshotLoader: self.snapshotLoader,
|
||||
authMaterialReader: self.authMaterialReader,
|
||||
baseEnvironment: self.baseEnvironment,
|
||||
fileManager: self.fileManager)
|
||||
let context = try await contextBuilder.build(targetID: id)
|
||||
|
||||
if let resultingActiveSource = self.convergedActiveSource(for: context) {
|
||||
self.activeSourceWriter.writeCodexActiveSource(resultingActiveSource)
|
||||
await self.accountScopedRefresher.refreshCodexAccountScopedState(allowDisabled: true)
|
||||
return CodexAccountPromotionResult(
|
||||
targetManagedAccountID: id,
|
||||
outcome: .convergedNoOp,
|
||||
displacedLiveDisposition: .none,
|
||||
didMutateLiveAuth: false,
|
||||
resultingActiveSource: resultingActiveSource)
|
||||
}
|
||||
|
||||
let targetAuthMaterial = try self.requiredTargetAuthMaterial(from: context.target)
|
||||
let preservationPlan = CodexDisplacedLivePreservationPlanner().makePlan(context: context)
|
||||
let executionResult = try CodexDisplacedLivePreservationExecutor(
|
||||
store: self.store,
|
||||
homeFactory: self.homeFactory,
|
||||
fileManager: self.fileManager)
|
||||
.execute(plan: preservationPlan, context: context)
|
||||
|
||||
do {
|
||||
try self.liveAuthSwapper.swapLiveAuthData(targetAuthMaterial.rawData, liveHomeURL: context.live.homeURL)
|
||||
} catch {
|
||||
throw CodexAccountPromotionError.liveAuthSwapFailed
|
||||
}
|
||||
|
||||
self.activeSourceWriter.writeCodexActiveSource(.liveSystem)
|
||||
await self.accountScopedRefresher.refreshCodexAccountScopedState(allowDisabled: true)
|
||||
|
||||
return CodexAccountPromotionResult(
|
||||
targetManagedAccountID: id,
|
||||
outcome: .promoted,
|
||||
displacedLiveDisposition: executionResult.displacedLiveDisposition,
|
||||
didMutateLiveAuth: true,
|
||||
resultingActiveSource: .liveSystem)
|
||||
}
|
||||
|
||||
nonisolated static func authFileURL(for homeURL: URL) -> URL {
|
||||
homeURL.appendingPathComponent("auth.json", isDirectory: false)
|
||||
}
|
||||
|
||||
private func convergedActiveSource(for context: PreparedPromotionContext) -> CodexActiveSource? {
|
||||
if let liveAuthIdentity = context.live.authIdentity {
|
||||
let targetIdentity = context.target.authIdentity ?? context.target.persistedIdentity
|
||||
guard CodexIdentityMatcher.matches(
|
||||
targetIdentity.identity,
|
||||
lhsEmail: targetIdentity.email,
|
||||
liveAuthIdentity.identity,
|
||||
rhsEmail: liveAuthIdentity.email)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if liveAuthIdentity.email != nil {
|
||||
return .liveSystem
|
||||
}
|
||||
|
||||
if liveAuthIdentity.providerAccountID != nil {
|
||||
return .managedAccount(id: context.target.persisted.id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let liveSystemAccount = context.snapshot.liveSystemAccount else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard CodexIdentityMatcher.matches(
|
||||
context.snapshot.runtimeIdentity(for: context.target.persisted),
|
||||
lhsEmail: context.snapshot.runtimeEmail(for: context.target.persisted),
|
||||
context.snapshot.runtimeIdentity(for: liveSystemAccount),
|
||||
rhsEmail: liveSystemAccount.email)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .liveSystem
|
||||
}
|
||||
|
||||
private func requiredTargetAuthMaterial(from target: PreparedStoredManagedAccount) throws -> PreparedAuthMaterial {
|
||||
switch target.homeState {
|
||||
case let .readable(authMaterial):
|
||||
guard authMaterial.authIdentity.email != nil else {
|
||||
throw CodexAccountPromotionError.targetManagedAccountAuthUnreadable
|
||||
}
|
||||
return authMaterial
|
||||
case .missing:
|
||||
throw CodexAccountPromotionError.targetManagedAccountAuthMissing
|
||||
case .unreadable:
|
||||
throw CodexAccountPromotionError.targetManagedAccountAuthUnreadable
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import CodexBarCore
|
||||
|
||||
typealias CodexVisibleAccount = CodexBarCore.CodexVisibleAccount
|
||||
typealias CodexVisibleAccountProjection = CodexBarCore.CodexVisibleAccountProjection
|
||||
@@ -0,0 +1,133 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
protocol CodexAccountUsageSnapshotStoring: Sendable {
|
||||
func load(for accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot]
|
||||
func store(_ snapshots: [CodexAccountUsageSnapshot])
|
||||
}
|
||||
|
||||
struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @unchecked Sendable {
|
||||
private struct Payload: Codable {
|
||||
let version: Int
|
||||
let records: [Record]
|
||||
}
|
||||
|
||||
private struct Record: Codable {
|
||||
let id: String
|
||||
let accountIdentity: AccountIdentity?
|
||||
let snapshot: UsageSnapshot?
|
||||
let error: String?
|
||||
let sourceLabel: String?
|
||||
}
|
||||
|
||||
private struct AccountIdentity: Codable, Equatable {
|
||||
let normalizedEmail: String?
|
||||
let workspaceAccountID: String?
|
||||
let authFingerprint: String?
|
||||
let storedAccountID: UUID?
|
||||
let selectionSource: CodexActiveSource?
|
||||
|
||||
init(account: CodexVisibleAccount) {
|
||||
self.normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email)
|
||||
self.workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(
|
||||
account.workspaceAccountID)
|
||||
self.authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint)
|
||||
self.storedAccountID = account.storedAccountID
|
||||
self.selectionSource = account.selectionSource
|
||||
}
|
||||
|
||||
func matches(_ account: CodexVisibleAccount) -> Bool {
|
||||
guard let normalizedEmail = self.normalizedEmail,
|
||||
normalizedEmail == CodexIdentityResolver.normalizeEmail(account.email),
|
||||
let workspaceAccountID = self.workspaceAccountID,
|
||||
workspaceAccountID == CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(
|
||||
account.workspaceAccountID)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static let currentVersion = 1
|
||||
|
||||
private let fileURL: URL
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(fileURL: URL = Self.defaultURL(), fileManager: FileManager = .default) {
|
||||
self.fileURL = fileURL
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func load(for accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] {
|
||||
guard self.fileManager.fileExists(atPath: self.fileURL.path),
|
||||
let data = try? Data(contentsOf: self.fileURL),
|
||||
let payload = try? JSONDecoder().decode(Payload.self, from: data),
|
||||
payload.version == Self.currentVersion
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) })
|
||||
return payload.records.compactMap { record in
|
||||
guard let account = accountsByID[record.id] else { return nil }
|
||||
guard record.accountIdentity?.matches(account) == true else { return nil }
|
||||
return CodexAccountUsageSnapshot(
|
||||
account: account,
|
||||
snapshot: Self.relabelSnapshot(record.snapshot, for: account),
|
||||
error: record.error,
|
||||
sourceLabel: record.sourceLabel)
|
||||
}
|
||||
}
|
||||
|
||||
func store(_ snapshots: [CodexAccountUsageSnapshot]) {
|
||||
let payload = Payload(
|
||||
version: Self.currentVersion,
|
||||
records: snapshots.compactMap { snapshot in
|
||||
let identity = AccountIdentity(account: snapshot.account)
|
||||
guard identity.normalizedEmail != nil, identity.workspaceAccountID != nil else { return nil }
|
||||
return Record(
|
||||
id: snapshot.id,
|
||||
accountIdentity: identity,
|
||||
snapshot: snapshot.snapshot,
|
||||
error: snapshot.error,
|
||||
sourceLabel: snapshot.sourceLabel)
|
||||
})
|
||||
let directory = self.fileURL.deletingLastPathComponent()
|
||||
do {
|
||||
if !self.fileManager.fileExists(atPath: directory.path) {
|
||||
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(payload).write(to: self.fileURL, options: [.atomic])
|
||||
#if os(macOS)
|
||||
try self.fileManager.setAttributes([
|
||||
.posixPermissions: NSNumber(value: Int16(0o600)),
|
||||
], ofItemAtPath: self.fileURL.path)
|
||||
#endif
|
||||
} catch {
|
||||
// Snapshot hydration is best-effort; never make menu refresh fail because disk cache failed.
|
||||
}
|
||||
}
|
||||
|
||||
private static func relabelSnapshot(_ snapshot: UsageSnapshot?, for account: CodexVisibleAccount)
|
||||
-> UsageSnapshot?
|
||||
{
|
||||
guard let snapshot else { return nil }
|
||||
let identity = snapshot.identity(for: .codex)
|
||||
return snapshot.withIdentity(ProviderIdentitySnapshot(
|
||||
providerID: .codex,
|
||||
accountEmail: account.email,
|
||||
accountOrganization: identity?.accountOrganization,
|
||||
loginMethod: identity?.loginMethod ?? account.workspaceLabel))
|
||||
}
|
||||
|
||||
static func defaultURL() -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser
|
||||
return base
|
||||
.appendingPathComponent("CodexBar", isDirectory: true)
|
||||
.appendingPathComponent("codex-account-snapshots.json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import CodexBarCore
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum CodexHistoryPersistedOwner: Equatable {
|
||||
case canonical(String)
|
||||
case legacyEmailHash(String)
|
||||
case legacyOpaqueScoped(String)
|
||||
case legacyUnscoped
|
||||
}
|
||||
|
||||
enum CodexHistoryOwnership {
|
||||
private static let providerAccountPrefix = "codex:v1:provider-account:"
|
||||
private static let emailHashPrefix = "codex:v1:email-hash:"
|
||||
|
||||
static func canonicalKey(for identity: CodexIdentity) -> String? {
|
||||
switch identity {
|
||||
case let .providerAccount(id):
|
||||
guard let normalized = Self.normalizeScopedValue(id) else { return nil }
|
||||
return "\(Self.providerAccountPrefix)\(normalized)"
|
||||
case let .emailOnly(normalizedEmail):
|
||||
guard let normalized = CodexIdentityResolver.normalizeEmail(normalizedEmail) else { return nil }
|
||||
return self.canonicalEmailHashKey(for: normalized)
|
||||
case .unresolved:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func canonicalEmailHashKey(for normalizedEmail: String) -> String {
|
||||
"\(self.emailHashPrefix)\(self.legacyEmailHash(normalizedEmail: normalizedEmail))"
|
||||
}
|
||||
|
||||
static func legacyEmailHash(normalizedEmail: String) -> String {
|
||||
guard let normalized = CodexIdentityResolver.normalizeEmail(normalizedEmail) else { return "" }
|
||||
return self.sha256Hex(normalized)
|
||||
}
|
||||
|
||||
static func classifyPersistedKey(
|
||||
_ rawKey: String?,
|
||||
legacyEmailHash: String? = nil) -> CodexHistoryPersistedOwner
|
||||
{
|
||||
guard let normalizedKey = normalizeScopedValue(rawKey) else {
|
||||
return .legacyUnscoped
|
||||
}
|
||||
if self.isCanonicalKey(normalizedKey) {
|
||||
return .canonical(normalizedKey)
|
||||
}
|
||||
if let legacyEmailHash, normalizedKey == legacyEmailHash {
|
||||
return .legacyEmailHash(normalizedKey)
|
||||
}
|
||||
return .legacyOpaqueScoped(normalizedKey)
|
||||
}
|
||||
|
||||
static func belongsToTargetContinuity(
|
||||
_ owner: CodexHistoryPersistedOwner,
|
||||
targetCanonicalKey: String,
|
||||
canonicalEmailHashKey: String?) -> Bool
|
||||
{
|
||||
switch owner {
|
||||
case let .canonical(key):
|
||||
if key == targetCanonicalKey {
|
||||
return true
|
||||
}
|
||||
guard let canonicalEmailHashKey, self.isCanonicalEmailHashKey(canonicalEmailHashKey) else {
|
||||
return false
|
||||
}
|
||||
return key == canonicalEmailHashKey
|
||||
case .legacyEmailHash:
|
||||
guard let canonicalEmailHashKey, self.isCanonicalEmailHashKey(canonicalEmailHashKey) else {
|
||||
return false
|
||||
}
|
||||
return canonicalEmailHashKey == targetCanonicalKey ||
|
||||
targetCanonicalKey.hasPrefix(self.providerAccountPrefix)
|
||||
case .legacyOpaqueScoped, .legacyUnscoped:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func hasStrictSingleAccountContinuity(
|
||||
scopedRawKeys: [String],
|
||||
targetCanonicalKey: String,
|
||||
canonicalEmailHashKey: String?,
|
||||
legacyEmailHash: String?,
|
||||
hasAdjacentMultiAccountVeto: Bool) -> Bool
|
||||
{
|
||||
guard !hasAdjacentMultiAccountVeto else { return false }
|
||||
|
||||
let normalizedCandidates = Set(scopedRawKeys.compactMap { rawKey in
|
||||
let owner = self.classifyPersistedKey(rawKey, legacyEmailHash: legacyEmailHash)
|
||||
if self.belongsToTargetContinuity(
|
||||
owner,
|
||||
targetCanonicalKey: targetCanonicalKey,
|
||||
canonicalEmailHashKey: canonicalEmailHashKey)
|
||||
{
|
||||
return targetCanonicalKey
|
||||
}
|
||||
|
||||
switch owner {
|
||||
case .legacyUnscoped:
|
||||
return nil
|
||||
case let .legacyOpaqueScoped(key):
|
||||
return "legacy-opaque:\(key)"
|
||||
case let .legacyEmailHash(hash):
|
||||
return "legacy-email-hash:\(hash)"
|
||||
case let .canonical(key):
|
||||
return key
|
||||
}
|
||||
})
|
||||
|
||||
guard normalizedCandidates.count == 1, normalizedCandidates.first == targetCanonicalKey else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func isCanonicalKey(_ rawKey: String) -> Bool {
|
||||
self.isCanonicalProviderAccountKey(rawKey) || self.isCanonicalEmailHashKey(rawKey)
|
||||
}
|
||||
|
||||
static func isCanonicalProviderAccountKey(_ rawKey: String) -> Bool {
|
||||
rawKey.hasPrefix(self.providerAccountPrefix) && rawKey.count > self.providerAccountPrefix.count
|
||||
}
|
||||
|
||||
private static func isCanonicalEmailHashKey(_ rawKey: String) -> Bool {
|
||||
rawKey.hasPrefix(self.emailHashPrefix) && rawKey.count > self.emailHashPrefix.count
|
||||
}
|
||||
|
||||
private static func normalizeScopedValue(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func sha256Hex(_ input: String) -> String {
|
||||
let digest = SHA256.hash(data: Data(input.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
struct CodexLoginAlertInfo: Equatable {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum CodexLoginAlertPresentation {
|
||||
static func alertInfo(for result: CodexLoginRunner.Result) -> CodexLoginAlertInfo? {
|
||||
switch result.outcome {
|
||||
case .success:
|
||||
return nil
|
||||
case .missingBinary:
|
||||
return CodexLoginAlertInfo(
|
||||
title: L("Codex CLI not found"),
|
||||
message: L("Install the Codex CLI (npm i -g @openai/codex) and try again."))
|
||||
case let .launchFailed(message):
|
||||
return CodexLoginAlertInfo(title: L("Could not start codex login"), message: message)
|
||||
case .timedOut:
|
||||
return CodexLoginAlertInfo(
|
||||
title: L("Codex login timed out"),
|
||||
message: self.trimmedOutput(result.output))
|
||||
case let .failed(status):
|
||||
let statusLine = String(format: L("codex login exited with status %d."), status)
|
||||
let message = self.trimmedOutput(result.output.isEmpty ? statusLine : result.output)
|
||||
return CodexLoginAlertInfo(title: L("Codex login failed"), message: message)
|
||||
}
|
||||
}
|
||||
|
||||
static func managedLoginFailureMessage(for result: CodexLoginRunner.Result) -> String {
|
||||
let baseMessage = L("managed_login_failed")
|
||||
guard let info = self.alertInfo(for: result) else { return baseMessage }
|
||||
return "\(baseMessage)\n\n\(L("codex_login_output"))\n\(info.message)"
|
||||
}
|
||||
|
||||
private static func trimmedOutput(_ text: String) -> String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let limit = 600
|
||||
if trimmed.isEmpty { return L("No output captured.") }
|
||||
if trimmed.count <= limit { return trimmed }
|
||||
let idx = trimmed.index(trimmed.startIndex, offsetBy: limit)
|
||||
return "\(trimmed[..<idx])…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import CodexBarCore
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct CodexLoginRunner {
|
||||
struct Result: Equatable {
|
||||
enum Outcome: Equatable {
|
||||
case success
|
||||
case timedOut
|
||||
case failed(status: Int32)
|
||||
case missingBinary
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
let outcome: Outcome
|
||||
let output: String
|
||||
}
|
||||
|
||||
static func run(
|
||||
homePath: String? = nil,
|
||||
timeout: TimeInterval = 120,
|
||||
outputDrainTimeout: TimeInterval = 3,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
loginPATH: [String]? = LoginShellPathCache.shared.current) async -> Result
|
||||
{
|
||||
await Task(priority: .userInitiated) {
|
||||
var env = environment
|
||||
env["PATH"] = PathBuilder.effectivePATH(
|
||||
purposes: [.rpc, .tty, .nodeTooling],
|
||||
env: env,
|
||||
loginPATH: loginPATH)
|
||||
env = CodexHomeScope.scopedEnvironment(base: env, codexHome: homePath)
|
||||
|
||||
guard let executable = BinaryLocator.resolveCodexBinary(
|
||||
env: env,
|
||||
loginPATH: loginPATH)
|
||||
else {
|
||||
return Result(outcome: .missingBinary, output: "")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = [executable, "login"]
|
||||
process.environment = env
|
||||
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
process.standardOutput = stdout
|
||||
process.standardError = stderr
|
||||
let stdoutCapture = ProcessPipeCapture(pipe: stdout)
|
||||
let stderrCapture = ProcessPipeCapture(pipe: stderr)
|
||||
|
||||
let termination = ProcessTermination()
|
||||
process.terminationHandler = { _ in
|
||||
termination.resolve(timedOut: false)
|
||||
}
|
||||
|
||||
var processGroup: pid_t?
|
||||
do {
|
||||
try process.run()
|
||||
processGroup = self.attachProcessGroup(process)
|
||||
} catch {
|
||||
return Result(outcome: .launchFailed(error.localizedDescription), output: "")
|
||||
}
|
||||
stdoutCapture.start()
|
||||
stderrCapture.start()
|
||||
|
||||
let timedOut = await self.wait(timeout: timeout, termination: termination)
|
||||
if timedOut {
|
||||
self.terminate(process, processGroup: processGroup)
|
||||
}
|
||||
|
||||
let output = await self.combinedOutput(
|
||||
stdout: stdoutCapture,
|
||||
stderr: stderrCapture,
|
||||
timeout: outputDrainTimeout)
|
||||
if timedOut {
|
||||
return Result(outcome: .timedOut, output: output)
|
||||
}
|
||||
|
||||
let status = process.terminationStatus
|
||||
if status == 0 {
|
||||
return Result(outcome: .success, output: output)
|
||||
}
|
||||
return Result(outcome: .failed(status: status), output: output)
|
||||
}.value
|
||||
}
|
||||
|
||||
private final class ProcessTermination: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var timedOut: Bool?
|
||||
private var continuation: CheckedContinuation<Bool, Never>?
|
||||
|
||||
func resolve(timedOut: Bool) {
|
||||
let continuation: CheckedContinuation<Bool, Never>?
|
||||
self.lock.lock()
|
||||
guard self.timedOut == nil else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.timedOut = timedOut
|
||||
continuation = self.continuation
|
||||
self.continuation = nil
|
||||
self.lock.unlock()
|
||||
continuation?.resume(returning: timedOut)
|
||||
}
|
||||
|
||||
func wait() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
let timedOut: Bool?
|
||||
self.lock.lock()
|
||||
timedOut = self.timedOut
|
||||
if timedOut == nil {
|
||||
self.continuation = continuation
|
||||
}
|
||||
self.lock.unlock()
|
||||
|
||||
if let timedOut {
|
||||
continuation.resume(returning: timedOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool {
|
||||
let timeoutTask = Task.detached(priority: .userInitiated) {
|
||||
try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout))
|
||||
if Task.isCancelled == false {
|
||||
termination.resolve(timedOut: true)
|
||||
}
|
||||
}
|
||||
let timedOut = await termination.wait()
|
||||
timeoutTask.cancel()
|
||||
return timedOut
|
||||
}
|
||||
|
||||
private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 {
|
||||
guard timeout.isFinite else { return UInt64.max }
|
||||
let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000))
|
||||
return UInt64(seconds * 1_000_000_000)
|
||||
}
|
||||
|
||||
private static func terminate(_ process: Process, processGroup: pid_t?) {
|
||||
if let pgid = processGroup {
|
||||
kill(-pgid, SIGTERM)
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(2.0)
|
||||
while process.isRunning, Date() < deadline {
|
||||
usleep(100_000)
|
||||
}
|
||||
|
||||
if process.isRunning {
|
||||
if let pgid = processGroup {
|
||||
kill(-pgid, SIGKILL)
|
||||
}
|
||||
kill(process.processIdentifier, SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func attachProcessGroup(_ process: Process) -> pid_t? {
|
||||
let pid = process.processIdentifier
|
||||
return setpgid(pid, pid) == 0 ? pid : nil
|
||||
}
|
||||
|
||||
private static func combinedOutput(
|
||||
stdout: ProcessPipeCapture,
|
||||
stderr: ProcessPipeCapture,
|
||||
timeout: TimeInterval) async -> String
|
||||
{
|
||||
let drainTimeout = Duration.seconds(max(0, timeout))
|
||||
async let outData = stdout.finish(timeout: drainTimeout)
|
||||
async let errData = stderr.finish(timeout: drainTimeout)
|
||||
let out = await self.decode(outData)
|
||||
let err = await self.decode(errData)
|
||||
|
||||
let merged: String = if !out.isEmpty, !err.isEmpty {
|
||||
[out, err].joined(separator: "\n")
|
||||
} else {
|
||||
out + err
|
||||
}
|
||||
let trimmed = merged.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let limited = trimmed.prefix(4000)
|
||||
return limited.isEmpty ? L("No output captured.") : String(limited)
|
||||
}
|
||||
|
||||
private static func decode(_ data: Data) -> String {
|
||||
ProcessPipeCapture.decodeUTF8(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import CodexBarCore
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct CodexOwnershipContext {
|
||||
let canonicalKey: String?
|
||||
let canonicalEmailHashKey: String?
|
||||
let historicalLegacyEmailHash: String?
|
||||
let planUtilizationLegacyEmailHash: String?
|
||||
let currentWeeklyResetAt: Date?
|
||||
let hasAdjacentMultiAccountVeto: Bool
|
||||
let hasAdjacentEmailScopeAmbiguity: Bool
|
||||
}
|
||||
|
||||
extension UsageStore {
|
||||
func codexOwnershipContext(
|
||||
preferredEmail: String? = nil,
|
||||
snapshot: UsageSnapshot? = nil,
|
||||
includeDashboardFallback: Bool = false) -> CodexOwnershipContext
|
||||
{
|
||||
let resolvedIdentity = self.currentCodexRuntimeIdentity(
|
||||
source: self.settings.codexResolvedActiveSource,
|
||||
preferCurrentSnapshot: true,
|
||||
allowLastKnownLiveFallback: true)
|
||||
let activeSourceEmail = self.codexAccountScopedRefreshEmail(
|
||||
preferCurrentSnapshot: true,
|
||||
allowLastKnownLiveFallback: true)
|
||||
let normalizedEmail = CodexIdentityResolver.normalizeEmail(
|
||||
preferredEmail ??
|
||||
activeSourceEmail ??
|
||||
snapshot?.accountEmail(for: .codex) ??
|
||||
self.snapshots[.codex]?.accountEmail(for: .codex) ??
|
||||
(includeDashboardFallback ? self.codexAccountEmailForOpenAIDashboard() : nil))
|
||||
let canonicalIdentity: CodexIdentity = switch resolvedIdentity {
|
||||
case .unresolved:
|
||||
if let normalizedEmail {
|
||||
.emailOnly(normalizedEmail: normalizedEmail)
|
||||
} else {
|
||||
.unresolved
|
||||
}
|
||||
default:
|
||||
resolvedIdentity
|
||||
}
|
||||
let legacyEmailSource: String? = switch canonicalIdentity {
|
||||
case let .emailOnly(normalizedEmail):
|
||||
normalizedEmail
|
||||
case .providerAccount, .unresolved:
|
||||
normalizedEmail
|
||||
}
|
||||
let attachedDashboardSnapshot = includeDashboardFallback
|
||||
? self.attachedOpenAIDashboardSnapshot
|
||||
: nil
|
||||
let normalizedDashboardSnapshot = attachedDashboardSnapshot?
|
||||
.toUsageSnapshot(provider: .codex, accountEmail: normalizedEmail)
|
||||
let currentWeeklyResetAt = snapshot?.secondary?.resetsAt
|
||||
?? self.snapshots[.codex]?.secondary?.resetsAt
|
||||
?? normalizedDashboardSnapshot?.secondary?.resetsAt
|
||||
|
||||
return CodexOwnershipContext(
|
||||
canonicalKey: CodexHistoryOwnership.canonicalKey(for: canonicalIdentity),
|
||||
canonicalEmailHashKey: normalizedEmail.map { CodexHistoryOwnership.canonicalEmailHashKey(for: $0) },
|
||||
historicalLegacyEmailHash: legacyEmailSource.map {
|
||||
CodexHistoryOwnership.legacyEmailHash(normalizedEmail: $0)
|
||||
},
|
||||
planUtilizationLegacyEmailHash: legacyEmailSource.map {
|
||||
Self.codexLegacyPlanUtilizationEmailHashKey(for: $0)
|
||||
},
|
||||
currentWeeklyResetAt: currentWeeklyResetAt,
|
||||
hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto(),
|
||||
hasAdjacentEmailScopeAmbiguity: normalizedEmail.map {
|
||||
self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) ||
|
||||
self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0)
|
||||
} ?? false)
|
||||
}
|
||||
|
||||
func codexOwnershipContext(
|
||||
forVisibleAccount account: CodexVisibleAccount,
|
||||
currentWeeklyResetAt: Date? = nil) -> CodexOwnershipContext
|
||||
{
|
||||
let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email)
|
||||
let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID)
|
||||
let canonicalIdentity: CodexIdentity = if let workspaceAccountID {
|
||||
.providerAccount(id: workspaceAccountID)
|
||||
} else if let normalizedEmail {
|
||||
.emailOnly(normalizedEmail: normalizedEmail)
|
||||
} else {
|
||||
.unresolved
|
||||
}
|
||||
|
||||
return CodexOwnershipContext(
|
||||
canonicalKey: CodexHistoryOwnership.canonicalKey(for: canonicalIdentity),
|
||||
canonicalEmailHashKey: normalizedEmail.map { CodexHistoryOwnership.canonicalEmailHashKey(for: $0) },
|
||||
historicalLegacyEmailHash: normalizedEmail.map {
|
||||
CodexHistoryOwnership.legacyEmailHash(normalizedEmail: $0)
|
||||
},
|
||||
planUtilizationLegacyEmailHash: normalizedEmail.map {
|
||||
Self.codexLegacyPlanUtilizationEmailHashKey(for: $0)
|
||||
},
|
||||
currentWeeklyResetAt: currentWeeklyResetAt,
|
||||
hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto() ||
|
||||
self.codexVisibleAccountsHaveAdjacentMultiAccountVeto(),
|
||||
hasAdjacentEmailScopeAmbiguity: normalizedEmail.map {
|
||||
self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) ||
|
||||
self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0)
|
||||
} ?? false)
|
||||
}
|
||||
|
||||
func codexHasAdjacentMultiAccountVeto() -> Bool {
|
||||
let snapshot = self.settings.codexAccountReconciliationSnapshot
|
||||
var distinctAccounts: Set<String> = []
|
||||
|
||||
if let activeManagedAccount = self.settings.activeManagedCodexAccount {
|
||||
distinctAccounts.insert(CodexIdentityMatcher.selectionKey(
|
||||
for: snapshot.runtimeIdentity(for: activeManagedAccount),
|
||||
fallbackEmail: snapshot.runtimeEmail(for: activeManagedAccount)))
|
||||
}
|
||||
|
||||
if let liveSystemAccount = snapshot.liveSystemAccount {
|
||||
distinctAccounts.insert(CodexIdentityMatcher.selectionKey(
|
||||
for: snapshot.runtimeIdentity(for: liveSystemAccount),
|
||||
fallbackEmail: liveSystemAccount.email))
|
||||
}
|
||||
|
||||
return distinctAccounts.count > 1
|
||||
}
|
||||
|
||||
private func codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool {
|
||||
let snapshot = self.settings.codexAccountReconciliationSnapshot
|
||||
var distinctAccounts: Set<String> = []
|
||||
|
||||
if let activeManagedAccount = self.settings.activeManagedCodexAccount,
|
||||
CodexIdentityResolver.normalizeEmail(snapshot.runtimeEmail(for: activeManagedAccount)) == normalizedEmail
|
||||
{
|
||||
distinctAccounts.insert(CodexIdentityMatcher.selectionKey(
|
||||
for: snapshot.runtimeIdentity(for: activeManagedAccount),
|
||||
fallbackEmail: snapshot.runtimeEmail(for: activeManagedAccount)))
|
||||
}
|
||||
|
||||
if let liveSystemAccount = snapshot.liveSystemAccount,
|
||||
CodexIdentityResolver.normalizeEmail(liveSystemAccount.email) == normalizedEmail
|
||||
{
|
||||
distinctAccounts.insert(CodexIdentityMatcher.selectionKey(
|
||||
for: snapshot.runtimeIdentity(for: liveSystemAccount),
|
||||
fallbackEmail: liveSystemAccount.email))
|
||||
}
|
||||
|
||||
return distinctAccounts.count > 1
|
||||
}
|
||||
|
||||
private func codexVisibleAccountsHaveAdjacentMultiAccountVeto() -> Bool {
|
||||
let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts
|
||||
var distinctAccounts: Set<String> = []
|
||||
for account in accounts {
|
||||
if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(
|
||||
account.workspaceAccountID)
|
||||
{
|
||||
distinctAccounts.insert("provider:\(workspaceAccountID)")
|
||||
} else if let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) {
|
||||
distinctAccounts.insert("email:\(normalizedEmail)")
|
||||
}
|
||||
}
|
||||
return distinctAccounts.count > 1
|
||||
}
|
||||
|
||||
private func codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool {
|
||||
let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts
|
||||
var distinctAccounts: Set<String> = []
|
||||
for account in accounts where CodexIdentityResolver.normalizeEmail(account.email) == normalizedEmail {
|
||||
if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(
|
||||
account.workspaceAccountID)
|
||||
{
|
||||
distinctAccounts.insert("provider:\(workspaceAccountID)")
|
||||
} else {
|
||||
distinctAccounts.insert("email:\(normalizedEmail)")
|
||||
}
|
||||
}
|
||||
return distinctAccounts.count > 1
|
||||
}
|
||||
|
||||
nonisolated static func codexLegacyPlanUtilizationEmailHashKey(for normalizedEmail: String) -> String {
|
||||
self.sha256Hex("\(UsageProvider.codex.rawValue):email:\(normalizedEmail)")
|
||||
}
|
||||
|
||||
nonisolated static func sha256Hex(_ input: String) -> String {
|
||||
let digest = SHA256.hash(data: Data(input.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import CodexBarCore
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct CodexResetCreditExpiryNotifier {
|
||||
static let expiryWindow: TimeInterval = 3 * 24 * 60 * 60
|
||||
static let notificationPrefix = "codex-reset-credit-expiry"
|
||||
static let summaryFingerprintsKey = "codexResetCreditExpirySummaryFingerprints"
|
||||
static let maximumRememberedSummaries = 64
|
||||
|
||||
var userDefaults: UserDefaults = .standard
|
||||
var notificationPoster: (String, String, String) -> Void = { prefix, title, body in
|
||||
AppNotifications.shared.post(idPrefix: prefix, title: title, body: body)
|
||||
}
|
||||
|
||||
func postExpiringCreditsIfNeeded(
|
||||
snapshot: CodexRateLimitResetCreditsSnapshot,
|
||||
resetStyle: ResetTimeDisplayStyle,
|
||||
now: Date = Date())
|
||||
{
|
||||
let expiringCredits = snapshot.availableInventory(at: now).credits.filter { credit in
|
||||
guard let expiresAt = credit.expiresAt else { return false }
|
||||
return expiresAt.timeIntervalSince(now) <= Self.expiryWindow
|
||||
}
|
||||
guard !expiringCredits.isEmpty else { return }
|
||||
|
||||
let fingerprint = Self.summaryFingerprint(expiringCredits)
|
||||
// Account-scoped refreshes can alternate inventories, so remember more than the latest summary.
|
||||
var notifiedFingerprints = self.userDefaults.stringArray(forKey: Self.summaryFingerprintsKey) ?? []
|
||||
guard !notifiedFingerprints.contains(fingerprint) else { return }
|
||||
notifiedFingerprints.append(fingerprint)
|
||||
if notifiedFingerprints.count > Self.maximumRememberedSummaries {
|
||||
notifiedFingerprints.removeFirst(notifiedFingerprints.count - Self.maximumRememberedSummaries)
|
||||
}
|
||||
self.userDefaults.set(notifiedFingerprints, forKey: Self.summaryFingerprintsKey)
|
||||
|
||||
let expiringSnapshot = CodexRateLimitResetCreditsSnapshot(
|
||||
credits: expiringCredits,
|
||||
availableCount: expiringCredits.count,
|
||||
updatedAt: now)
|
||||
guard let presentation = CodexResetCreditsPresentation.make(
|
||||
snapshot: expiringSnapshot,
|
||||
resetStyle: resetStyle,
|
||||
now: now)
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.notificationPoster(
|
||||
Self.notificationPrefix,
|
||||
L("Limit Reset Credits"),
|
||||
presentation.helpText)
|
||||
}
|
||||
|
||||
private static func summaryFingerprint(_ credits: [CodexRateLimitResetCredit]) -> String {
|
||||
let material = credits.map { credit in
|
||||
"\(credit.id)\u{1f}\(credit.expiresAt?.timeIntervalSince1970 ?? 0)"
|
||||
}.joined(separator: "\u{1e}")
|
||||
return SHA256.hash(data: Data(material.utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import KeyboardShortcuts
|
||||
import Observation
|
||||
import QuartzCore
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CodexBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var settings: SettingsStore
|
||||
@State private var store: UsageStore
|
||||
@State private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator
|
||||
@State private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator
|
||||
private let preferencesSelection: PreferencesSelection
|
||||
private let account: AccountInfo
|
||||
|
||||
init() {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let storedLevel = CodexBarLog.parseLevel(UserDefaults.standard.string(forKey: "debugLogLevel")) ?? .verbose
|
||||
let level = CodexBarLog.parseLevel(env["CODEXBAR_LOG_LEVEL"]) ?? storedLevel
|
||||
CodexBarLog.bootstrapIfNeeded(.init(
|
||||
destination: .oslog(subsystem: "com.steipete.codexbar"),
|
||||
level: level,
|
||||
json: false))
|
||||
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
|
||||
let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String ?? "unknown"
|
||||
let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String ?? "unknown"
|
||||
CodexBarLog.logger(LogCategories.app).info(
|
||||
"CodexBar starting",
|
||||
metadata: [
|
||||
"version": version,
|
||||
"build": build,
|
||||
"git": gitCommit,
|
||||
"built": buildTimestamp,
|
||||
])
|
||||
|
||||
KeychainAccessGate.isDisabled = UserDefaults.standard.bool(forKey: "debugDisableKeychainAccess")
|
||||
KeychainPromptCoordinator.install()
|
||||
if MainThreadHangWatchdog.isEnabledForCurrentProcess {
|
||||
MainThreadHangWatchdog.shared.start()
|
||||
}
|
||||
|
||||
let preferencesSelection = PreferencesSelection()
|
||||
let settings = SettingsStore()
|
||||
Self.applyLanguagePreference(from: settings)
|
||||
configureUsageFormatterLocalizationProvider()
|
||||
let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator()
|
||||
managedCodexAccountCoordinator.onManagedAccountsDidChange = {
|
||||
_ = settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange()
|
||||
}
|
||||
_ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()
|
||||
let fetcher = UsageFetcher()
|
||||
let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL)
|
||||
let account = fetcher.loadAccountInfo()
|
||||
let store = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: settings)
|
||||
let codexAccountPromotionCoordinator = CodexAccountPromotionCoordinator(
|
||||
settingsStore: settings,
|
||||
usageStore: store,
|
||||
managedAccountCoordinator: managedCodexAccountCoordinator)
|
||||
self.preferencesSelection = preferencesSelection
|
||||
_settings = State(wrappedValue: settings)
|
||||
_store = State(wrappedValue: store)
|
||||
_managedCodexAccountCoordinator = State(wrappedValue: managedCodexAccountCoordinator)
|
||||
_codexAccountPromotionCoordinator = State(wrappedValue: codexAccountPromotionCoordinator)
|
||||
self.account = account
|
||||
CodexBarLog.setLogLevel(settings.debugLogLevel)
|
||||
self.appDelegate.configure(.init(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: account,
|
||||
selection: preferencesSelection,
|
||||
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
|
||||
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator))
|
||||
}
|
||||
|
||||
@SceneBuilder
|
||||
var body: some Scene {
|
||||
// Hidden 1×1 window to keep SwiftUI's lifecycle alive so `Settings` scene
|
||||
// shows the native toolbar tabs even though the UI is AppKit-based.
|
||||
WindowGroup("CodexBarLifecycleKeepalive") {
|
||||
HiddenWindowView()
|
||||
}
|
||||
.defaultSize(width: 20, height: 20)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
|
||||
Settings {
|
||||
PreferencesView(
|
||||
settings: self.settings,
|
||||
store: self.store,
|
||||
updater: self.appDelegate.updaterController,
|
||||
selection: self.preferencesSelection,
|
||||
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
|
||||
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
|
||||
runProviderLoginFlow: { provider in
|
||||
await self.appDelegate.runProviderLoginFlow(provider)
|
||||
})
|
||||
}
|
||||
.defaultSize(width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)
|
||||
.windowResizability(.contentMinSize)
|
||||
}
|
||||
|
||||
private func openSettings(pane: SettingsPane) {
|
||||
self.preferencesSelection.pane = pane
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
_ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
||||
}
|
||||
|
||||
private static func applyLanguagePreference(from settings: SettingsStore) {
|
||||
AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(storedAppLanguage: settings.appLanguage)
|
||||
resetCodexBarLocalizationCache()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Updater abstraction
|
||||
|
||||
@MainActor
|
||||
protocol UpdaterProviding: AnyObject {
|
||||
var automaticallyChecksForUpdates: Bool { get set }
|
||||
var automaticallyDownloadsUpdates: Bool { get set }
|
||||
var isAvailable: Bool { get }
|
||||
var unavailableReason: String? { get }
|
||||
var updateStatus: UpdateStatus { get }
|
||||
func checkForUpdates(_ sender: Any?)
|
||||
func installUpdate()
|
||||
}
|
||||
|
||||
/// No-op updater used for debug builds and non-bundled runs to suppress Sparkle dialogs.
|
||||
final class DisabledUpdaterController: UpdaterProviding {
|
||||
var automaticallyChecksForUpdates: Bool = false
|
||||
var automaticallyDownloadsUpdates: Bool = false
|
||||
let isAvailable: Bool = false
|
||||
let unavailableReason: String?
|
||||
let updateStatus = UpdateStatus()
|
||||
|
||||
init(unavailableReason: String? = nil) {
|
||||
self.unavailableReason = unavailableReason
|
||||
}
|
||||
|
||||
func checkForUpdates(_ sender: Any?) {}
|
||||
func installUpdate() {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class UpdateStatus {
|
||||
static let disabled = UpdateStatus()
|
||||
var isUpdateReady: Bool
|
||||
|
||||
init(isUpdateReady: Bool = false) {
|
||||
self.isUpdateReady = isUpdateReady
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Sparkle) && ENABLE_SPARKLE
|
||||
import Sparkle
|
||||
|
||||
@MainActor
|
||||
final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {
|
||||
private final class ImmediateInstallHandler: @unchecked Sendable {
|
||||
private let handler: () -> Void
|
||||
|
||||
init(_ handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
func install() {
|
||||
self.handler()
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var controller = SPUStandardUpdaterController(
|
||||
startingUpdater: false,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: nil)
|
||||
let updateStatus = UpdateStatus()
|
||||
let unavailableReason: String? = nil
|
||||
private var immediateInstallHandler: ImmediateInstallHandler?
|
||||
|
||||
init(savedAutoUpdate: Bool) {
|
||||
super.init()
|
||||
let updater = self.controller.updater
|
||||
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
||||
updater.automaticallyDownloadsUpdates = savedAutoUpdate
|
||||
self.controller.startUpdater()
|
||||
}
|
||||
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
get { self.controller.updater.automaticallyChecksForUpdates }
|
||||
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
var automaticallyDownloadsUpdates: Bool {
|
||||
get { self.controller.updater.automaticallyDownloadsUpdates }
|
||||
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
||||
}
|
||||
|
||||
var isAvailable: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func checkForUpdates(_ sender: Any?) {
|
||||
self.controller.checkForUpdates(sender)
|
||||
}
|
||||
|
||||
func installUpdate() {
|
||||
guard let immediateInstallHandler else {
|
||||
self.controller.checkForUpdates(nil)
|
||||
return
|
||||
}
|
||||
|
||||
immediateInstallHandler.install()
|
||||
}
|
||||
|
||||
nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
||||
_ = updater
|
||||
_ = item
|
||||
}
|
||||
|
||||
nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
|
||||
_ = updater
|
||||
_ = item
|
||||
_ = error
|
||||
Task { @MainActor in
|
||||
self.immediateInstallHandler = nil
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {
|
||||
_ = updater
|
||||
Task { @MainActor in
|
||||
self.immediateInstallHandler = nil
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func updater(
|
||||
_ updater: SPUUpdater,
|
||||
willInstallUpdateOnQuit item: SUAppcastItem,
|
||||
immediateInstallationBlock immediateInstallHandler: @escaping () -> Void)
|
||||
-> Bool
|
||||
{
|
||||
_ = updater
|
||||
_ = item
|
||||
let installHandler = ImmediateInstallHandler(immediateInstallHandler)
|
||||
Task { @MainActor in
|
||||
self.immediateInstallHandler = installHandler
|
||||
self.updateStatus.isUpdateReady = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
|
||||
_ = updater
|
||||
_ = error
|
||||
Task { @MainActor in
|
||||
self.immediateInstallHandler = nil
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func updater(
|
||||
_ updater: SPUUpdater,
|
||||
userDidMake choice: SPUUserUpdateChoice,
|
||||
forUpdate updateItem: SUAppcastItem,
|
||||
state: SPUUserUpdateState)
|
||||
{
|
||||
let downloaded = state.stage == .downloaded
|
||||
Task { @MainActor in
|
||||
switch choice {
|
||||
case .install, .skip:
|
||||
self.immediateInstallHandler = nil
|
||||
self.updateStatus.isUpdateReady = false
|
||||
case .dismiss:
|
||||
self.updateStatus.isUpdateReady = downloaded
|
||||
@unknown default:
|
||||
self.immediateInstallHandler = nil
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func allowedChannels(for updater: SPUUpdater) -> Set<String> {
|
||||
UpdateChannel.current.allowedSparkleChannels
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
let code = staticCode else { return false }
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any],
|
||||
let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
|
||||
let leaf = certs.first else { return false }
|
||||
|
||||
if let summary = SecCertificateCopySubjectSummary(leaf) as String? {
|
||||
return summary.hasPrefix("Developer ID Application:")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
let isBundledApp = bundleURL.pathExtension == "app"
|
||||
guard isBundledApp else {
|
||||
return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
|
||||
}
|
||||
|
||||
if InstallOrigin.isHomebrewCask(appBundleURL: bundleURL) {
|
||||
return DisabledUpdaterController(
|
||||
unavailableReason: "Updates managed by Homebrew. Run: brew upgrade --cask steipete/tap/codexbar")
|
||||
}
|
||||
|
||||
guard isDeveloperIDSigned(bundleURL: bundleURL) else {
|
||||
return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let autoUpdateKey = "autoUpdateEnabled"
|
||||
// Default to true for first launch; fall back to saved preference thereafter.
|
||||
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
||||
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
|
||||
}
|
||||
#else
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
DisabledUpdaterController()
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
struct Dependencies {
|
||||
let store: UsageStore
|
||||
let settings: SettingsStore
|
||||
let account: AccountInfo
|
||||
let selection: PreferencesSelection
|
||||
let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator
|
||||
let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator
|
||||
}
|
||||
|
||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||
private let confettiOverlayController = ScreenConfettiOverlayController()
|
||||
private let confettiLogger = CodexBarLog.logger(LogCategories.confetti)
|
||||
private lazy var memoryPressureMonitor = MemoryPressureMonitor(trimAppCaches: { [weak self] in
|
||||
self?.trimRebuildableCachesForMemoryPressure() ?? MemoryPressureCacheTrimSummary()
|
||||
})
|
||||
|
||||
private var statusController: StatusItemControlling?
|
||||
private var store: UsageStore?
|
||||
private var settings: SettingsStore?
|
||||
private var account: AccountInfo?
|
||||
private var preferencesSelection: PreferencesSelection?
|
||||
private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?
|
||||
private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?
|
||||
private var hasInstalledLimitResetObservers = false
|
||||
#if DEBUG
|
||||
private var debugMemoryPressureObserver: NSObjectProtocol?
|
||||
#endif
|
||||
var terminateActiveProcessesForAppShutdown: () -> Void = {
|
||||
TTYCommandRunner.terminateActiveProcessesForAppShutdown()
|
||||
}
|
||||
|
||||
func configure(_ dependencies: Dependencies) {
|
||||
self.store = dependencies.store
|
||||
self.settings = dependencies.settings
|
||||
self.account = dependencies.account
|
||||
self.preferencesSelection = dependencies.selection
|
||||
self.managedCodexAccountCoordinator = dependencies.managedCodexAccountCoordinator
|
||||
self.codexAccountPromotionCoordinator = dependencies.codexAccountPromotionCoordinator
|
||||
}
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
self.configureAppIconForMacOSVersion()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
AppNotifications.shared.requestAuthorizationOnStartup()
|
||||
self.memoryPressureMonitor.start()
|
||||
#if DEBUG
|
||||
self.installDebugMemoryPressureObserverIfNeeded()
|
||||
#endif
|
||||
self.ensureStatusController()
|
||||
KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in
|
||||
// KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop.
|
||||
MainActor.assumeIsolated {
|
||||
self?.statusController?.openMenuFromShortcut()
|
||||
}
|
||||
}
|
||||
if !self.hasInstalledLimitResetObservers {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.handleSessionLimitResetNotification(_:)),
|
||||
name: .codexbarSessionLimitReset,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.handleWeeklyLimitResetNotification(_:)),
|
||||
name: .codexbarWeeklyLimitReset,
|
||||
object: nil)
|
||||
self.hasInstalledLimitResetObservers = true
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
self.memoryPressureMonitor.stop()
|
||||
#if DEBUG
|
||||
self.removeDebugMemoryPressureObserver()
|
||||
#endif
|
||||
self.statusController?.prepareForAppShutdown()
|
||||
self.confettiOverlayController.dismiss()
|
||||
self.dismissAppKitWindowsForShutdown()
|
||||
self.terminateActiveProcessesForAppShutdown()
|
||||
}
|
||||
|
||||
func runProviderLoginFlow(_ provider: UsageProvider) async {
|
||||
self.ensureStatusController()
|
||||
guard let statusController else { return }
|
||||
await statusController.runLoginFlowFromSettings(provider: provider)
|
||||
}
|
||||
|
||||
@objc private func handleSessionLimitResetNotification(_ notification: Notification) {
|
||||
guard let event = notification.object as? SessionLimitResetEvent else { return }
|
||||
guard self.settings?.confettiOnSessionLimitResetsEnabled == true else { return }
|
||||
self.playLimitResetConfetti(
|
||||
provider: event.provider,
|
||||
accountIdentifier: event.accountIdentifier,
|
||||
resetKind: "session")
|
||||
}
|
||||
|
||||
@objc private func handleWeeklyLimitResetNotification(_ notification: Notification) {
|
||||
guard let event = notification.object as? WeeklyLimitResetEvent else { return }
|
||||
guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return }
|
||||
self.playLimitResetConfetti(
|
||||
provider: event.provider,
|
||||
accountIdentifier: event.accountIdentifier,
|
||||
resetKind: "weekly")
|
||||
}
|
||||
|
||||
private func playLimitResetConfetti(
|
||||
provider: UsageProvider,
|
||||
accountIdentifier: String,
|
||||
resetKind: String)
|
||||
{
|
||||
let origin = self.statusController?.celebrationOriginPoint(for: provider)
|
||||
self.confettiLogger.info(
|
||||
"Triggering confetti",
|
||||
metadata: [
|
||||
"provider": provider.rawValue,
|
||||
"accountIdentifier": accountIdentifier,
|
||||
"resetKind": resetKind,
|
||||
"originKnown": origin == nil ? "0" : "1",
|
||||
])
|
||||
self.confettiOverlayController.play(originInScreen: origin)
|
||||
}
|
||||
|
||||
/// Use the classic (non-Liquid Glass) app icon on macOS versions before 26.
|
||||
private func configureAppIconForMacOSVersion() {
|
||||
if #unavailable(macOS 26) {
|
||||
self.applyClassicAppIcon()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyClassicAppIcon() {
|
||||
guard let classicIcon = Self.loadClassicIcon() else { return }
|
||||
NSApp.applicationIconImage = classicIcon
|
||||
}
|
||||
|
||||
private static func loadClassicIcon() -> NSImage? {
|
||||
guard let url = self.classicIconURL(),
|
||||
let image = NSImage(contentsOf: url)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private static func classicIconURL() -> URL? {
|
||||
Bundle.main.url(forResource: "Icon-classic", withExtension: "icns")
|
||||
}
|
||||
|
||||
private func dismissAppKitWindowsForShutdown() {
|
||||
guard let app = NSApp else { return }
|
||||
for window in app.windows {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureStatusController() {
|
||||
if self.statusController != nil { return }
|
||||
|
||||
if let store,
|
||||
let settings,
|
||||
let account,
|
||||
let selection = self.preferencesSelection,
|
||||
let managedCodexAccountCoordinator,
|
||||
let codexAccountPromotionCoordinator
|
||||
{
|
||||
self.statusController = StatusItemController.factory(
|
||||
store,
|
||||
settings,
|
||||
account,
|
||||
self.updaterController,
|
||||
selection,
|
||||
managedCodexAccountCoordinator,
|
||||
codexAccountPromotionCoordinator)
|
||||
return
|
||||
}
|
||||
|
||||
// Defensive fallback: this should not be hit in normal app lifecycle.
|
||||
CodexBarLog.logger(LogCategories.app)
|
||||
.error("StatusItemController fallback path used; settings/store mismatch likely.")
|
||||
assertionFailure("StatusItemController fallback path used; check app lifecycle wiring.")
|
||||
let fallbackSettings = SettingsStore()
|
||||
let fetcher = UsageFetcher()
|
||||
let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL)
|
||||
let fallbackAccount = fetcher.loadAccountInfo()
|
||||
let fallbackStore = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: fallbackSettings)
|
||||
let fallbackManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator()
|
||||
let fallbackCodexAccountPromotionCoordinator = CodexAccountPromotionCoordinator(
|
||||
settingsStore: fallbackSettings,
|
||||
usageStore: fallbackStore,
|
||||
managedAccountCoordinator: fallbackManagedCodexAccountCoordinator)
|
||||
self.statusController = StatusItemController.factory(
|
||||
fallbackStore,
|
||||
fallbackSettings,
|
||||
fallbackAccount,
|
||||
self.updaterController,
|
||||
PreferencesSelection(),
|
||||
fallbackManagedCodexAccountCoordinator,
|
||||
fallbackCodexAccountPromotionCoordinator)
|
||||
}
|
||||
|
||||
private func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary {
|
||||
var summary = MemoryPressureCacheTrimSummary()
|
||||
let statusSummary = self.statusController?.trimRebuildableCachesForMemoryPressure()
|
||||
?? MemoryPressureCacheTrimSummary()
|
||||
let storeSummary = self.store?.trimRebuildableCachesForMemoryPressure()
|
||||
?? MemoryPressureCacheTrimSummary()
|
||||
summary.merge(statusSummary)
|
||||
summary.merge(storeSummary)
|
||||
return summary
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func installDebugMemoryPressureObserverIfNeeded() {
|
||||
guard self.debugMemoryPressureObserver == nil else { return }
|
||||
self.debugMemoryPressureObserver = DistributedNotificationCenter.default().addObserver(
|
||||
forName: .codexbarDebugSimulateMemoryPressure,
|
||||
object: nil,
|
||||
queue: .main)
|
||||
{ [weak self] notification in
|
||||
let rawLevel = notification.userInfo?["level"] as? String
|
||||
let shouldSeedCaches = notification.userInfo?["seedCaches"] as? String == "1"
|
||||
MainActor.assumeIsolated {
|
||||
self?.handleDebugMemoryPressureNotification(
|
||||
rawLevel: rawLevel,
|
||||
shouldSeedCaches: shouldSeedCaches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDebugMemoryPressureObserver() {
|
||||
guard let observer = self.debugMemoryPressureObserver else { return }
|
||||
DistributedNotificationCenter.default().removeObserver(observer)
|
||||
self.debugMemoryPressureObserver = nil
|
||||
}
|
||||
|
||||
private func handleDebugMemoryPressureNotification(rawLevel: String?, shouldSeedCaches: Bool) {
|
||||
let isCritical = rawLevel?.caseInsensitiveCompare("critical") == .orderedSame
|
||||
if shouldSeedCaches {
|
||||
OpenAIDashboardFetcher.seedCachedWebViewsForMemoryPressureProof()
|
||||
self.statusController?.seedRebuildableCachesForMemoryPressureProof()
|
||||
self.store?.seedRebuildableCachesForMemoryPressureProof()
|
||||
}
|
||||
CodexBarLog.logger(LogCategories.memoryPressure).info(
|
||||
"Debug memory pressure notification received",
|
||||
metadata: [
|
||||
"level": isCritical ? "critical" : "warning",
|
||||
"seedCaches": shouldSeedCaches ? "1" : "0",
|
||||
])
|
||||
self.memoryPressureMonitor.handleMemoryPressureForTesting(isWarning: !isCritical, isCritical: isCritical)
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
struct CodexBarConfigMigrator {
|
||||
struct LegacyStores {
|
||||
let zaiTokenStore: any ZaiTokenStoring
|
||||
let syntheticTokenStore: any SyntheticTokenStoring
|
||||
let codexCookieStore: any CookieHeaderStoring
|
||||
let claudeCookieStore: any CookieHeaderStoring
|
||||
let cursorCookieStore: any CookieHeaderStoring
|
||||
let opencodeCookieStore: any CookieHeaderStoring
|
||||
let factoryCookieStore: any CookieHeaderStoring
|
||||
let minimaxCookieStore: any MiniMaxCookieStoring
|
||||
let minimaxAPITokenStore: any MiniMaxAPITokenStoring
|
||||
let kimiTokenStore: any KimiTokenStoring
|
||||
let kimiK2TokenStore: any KimiK2TokenStoring
|
||||
let augmentCookieStore: any CookieHeaderStoring
|
||||
let ampCookieStore: any CookieHeaderStoring
|
||||
let copilotTokenStore: any CopilotTokenStoring
|
||||
let tokenAccountStore: any ProviderTokenAccountStoring
|
||||
}
|
||||
|
||||
private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted"
|
||||
|
||||
private struct MigrationState {
|
||||
var didUpdate = false
|
||||
var sawLegacySecrets = false
|
||||
var sawLegacyAccounts = false
|
||||
}
|
||||
|
||||
static func loadOrMigrate(
|
||||
configStore: CodexBarConfigStore,
|
||||
userDefaults: UserDefaults,
|
||||
stores: LegacyStores) -> CodexBarConfig
|
||||
{
|
||||
let log = CodexBarLog.logger(LogCategories.configMigration)
|
||||
let existing = try? configStore.load()
|
||||
var config = (existing ?? CodexBarConfig.makeDefault()).normalized()
|
||||
var state = MigrationState()
|
||||
|
||||
// applyLegacyCookieSources reads only UserDefaults — cheap, runs unconditionally so
|
||||
// newly-added cookie-source keys are picked up on every launch.
|
||||
self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state)
|
||||
|
||||
let migrationCompleted = userDefaults.bool(forKey: Self.legacyMigrationCompletedKey)
|
||||
if !migrationCompleted {
|
||||
// Run once: migrate Keychain/file secrets then clear them. Using a completion flag rather
|
||||
// than `existing == nil` ensures a crash between config-save and clearLegacyStores can
|
||||
// finish cleanup on the next launch without re-doing the (already-saved) data migration.
|
||||
if existing == nil {
|
||||
self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state)
|
||||
}
|
||||
self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
|
||||
self.migrateLegacyAccounts(stores: stores, config: &config, state: &state)
|
||||
}
|
||||
|
||||
var didPersistUpdates = true
|
||||
if state.didUpdate {
|
||||
do {
|
||||
try configStore.save(config)
|
||||
} catch {
|
||||
didPersistUpdates = false
|
||||
log.error("Failed to persist config: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
guard didPersistUpdates else {
|
||||
return config.normalized()
|
||||
}
|
||||
|
||||
if state.sawLegacySecrets || state.sawLegacyAccounts {
|
||||
let cleared = self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log)
|
||||
if cleared {
|
||||
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
|
||||
}
|
||||
} else if !migrationCompleted {
|
||||
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
|
||||
}
|
||||
|
||||
return config.normalized()
|
||||
}
|
||||
|
||||
private static func applyLegacyOrderAndToggles(
|
||||
userDefaults: UserDefaults,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
if let order = userDefaults.stringArray(forKey: "providerOrder"), !order.isEmpty {
|
||||
config = self.applyProviderOrder(order, config: config)
|
||||
state.didUpdate = true
|
||||
}
|
||||
let toggles = userDefaults.dictionary(forKey: "providerToggles") as? [String: Bool] ?? [:]
|
||||
if !toggles.isEmpty {
|
||||
config = self.applyProviderToggles(toggles, config: config)
|
||||
state.didUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacySecrets(
|
||||
userDefaults: UserDefaults,
|
||||
stores: LegacyStores,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
self.migrateTokenProviders(
|
||||
[
|
||||
(.zai, stores.zaiTokenStore.loadToken),
|
||||
(.synthetic, stores.syntheticTokenStore.loadToken),
|
||||
(.copilot, stores.copilotTokenStore.loadToken),
|
||||
(.kimik2, stores.kimiK2TokenStore.loadToken),
|
||||
],
|
||||
config: &config,
|
||||
state: &state)
|
||||
|
||||
self.migrateCookieProviders(
|
||||
[
|
||||
(.codex, stores.codexCookieStore.loadCookieHeader),
|
||||
(.claude, stores.claudeCookieStore.loadCookieHeader),
|
||||
(.cursor, stores.cursorCookieStore.loadCookieHeader),
|
||||
(.factory, stores.factoryCookieStore.loadCookieHeader),
|
||||
(.augment, stores.augmentCookieStore.loadCookieHeader),
|
||||
(.amp, stores.ampCookieStore.loadCookieHeader),
|
||||
],
|
||||
config: &config,
|
||||
state: &state)
|
||||
|
||||
self.migrateMiniMax(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
|
||||
self.migrateKimi(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
|
||||
self.migrateOpenCode(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
|
||||
}
|
||||
|
||||
private static func applyLegacyCookieSources(
|
||||
userDefaults: UserDefaults,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
let sources: [(UsageProvider, String)] = [
|
||||
(.codex, "codexCookieSource"),
|
||||
(.claude, "claudeCookieSource"),
|
||||
(.cursor, "cursorCookieSource"),
|
||||
(.opencode, "opencodeCookieSource"),
|
||||
(.factory, "factoryCookieSource"),
|
||||
(.minimax, "minimaxCookieSource"),
|
||||
(.kimi, "kimiCookieSource"),
|
||||
(.augment, "augmentCookieSource"),
|
||||
(.amp, "ampCookieSource"),
|
||||
]
|
||||
|
||||
for (provider, key) in sources {
|
||||
guard let raw = userDefaults.string(forKey: key),
|
||||
let source = ProviderCookieSource(rawValue: raw)
|
||||
else { continue }
|
||||
self.updateProvider(provider, config: &config, state: &state) { entry in
|
||||
guard entry.cookieSource == nil else { return false }
|
||||
entry.cookieSource = source
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool == false {
|
||||
self.updateProvider(.codex, config: &config, state: &state) { entry in
|
||||
guard entry.cookieSource == nil else { return false }
|
||||
entry.cookieSource = .off
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateTokenProviders(
|
||||
_ providers: [(UsageProvider, () throws -> String?)],
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
for (provider, loader) in providers {
|
||||
let token = try? loader()
|
||||
if token != nil { state.sawLegacySecrets = true }
|
||||
self.updateProvider(provider, config: &config, state: &state) { entry in
|
||||
self.setIfEmpty(&entry.apiKey, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateCookieProviders(
|
||||
_ providers: [(UsageProvider, () throws -> String?)],
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
for (provider, loader) in providers {
|
||||
let header = try? loader()
|
||||
if header != nil { state.sawLegacySecrets = true }
|
||||
self.updateProvider(provider, config: &config, state: &state) { entry in
|
||||
self.setIfEmpty(&entry.cookieHeader, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateMiniMax(
|
||||
userDefaults: UserDefaults,
|
||||
stores: LegacyStores,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
let token = try? stores.minimaxAPITokenStore.loadToken()
|
||||
let header = try? stores.minimaxCookieStore.loadCookieHeader()
|
||||
if token != nil || header != nil {
|
||||
state.sawLegacySecrets = true
|
||||
}
|
||||
let regionRaw = userDefaults.string(forKey: "minimaxAPIRegion")
|
||||
self.updateProvider(.minimax, config: &config, state: &state) { entry in
|
||||
var changed = false
|
||||
changed = self.setIfEmpty(&entry.apiKey, token) || changed
|
||||
if let regionRaw, !regionRaw.isEmpty, entry.region == nil {
|
||||
entry.region = regionRaw
|
||||
changed = true
|
||||
}
|
||||
changed = self.setIfEmpty(&entry.cookieHeader, header) || changed
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateKimi(
|
||||
userDefaults: UserDefaults,
|
||||
stores: LegacyStores,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
var token = try? stores.kimiTokenStore.loadToken()
|
||||
if token?.isEmpty ?? true {
|
||||
token = userDefaults.string(forKey: "kimiManualCookieHeader")
|
||||
}
|
||||
if token != nil { state.sawLegacySecrets = true }
|
||||
self.updateProvider(.kimi, config: &config, state: &state) { entry in
|
||||
self.setIfEmpty(&entry.cookieHeader, token)
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateOpenCode(
|
||||
userDefaults: UserDefaults,
|
||||
stores: LegacyStores,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
let header = try? stores.opencodeCookieStore.loadCookieHeader()
|
||||
if header != nil { state.sawLegacySecrets = true }
|
||||
let workspaceID = userDefaults.string(forKey: "opencodeWorkspaceID")
|
||||
self.updateProvider(.opencode, config: &config, state: &state) { entry in
|
||||
var changed = false
|
||||
changed = self.setIfEmpty(&entry.cookieHeader, header) || changed
|
||||
if let workspaceID, !workspaceID.isEmpty, entry.workspaceID == nil {
|
||||
entry.workspaceID = workspaceID
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyAccounts(
|
||||
stores: LegacyStores,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState)
|
||||
{
|
||||
guard let accounts = try? stores.tokenAccountStore.loadAccounts(), !accounts.isEmpty else { return }
|
||||
state.sawLegacyAccounts = true
|
||||
for (provider, data) in accounts where !data.accounts.isEmpty {
|
||||
self.updateProvider(provider, config: &config, state: &state) { entry in
|
||||
guard entry.tokenAccounts == nil else { return false }
|
||||
entry.tokenAccounts = data
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateProvider(
|
||||
_ provider: UsageProvider,
|
||||
config: inout CodexBarConfig,
|
||||
state: inout MigrationState,
|
||||
mutate: (inout ProviderConfig) -> Bool)
|
||||
{
|
||||
guard let index = config.providers.firstIndex(where: { $0.id == provider }) else { return }
|
||||
var entry = config.providers[index]
|
||||
let changed = mutate(&entry)
|
||||
if changed {
|
||||
config.providers[index] = entry
|
||||
state.didUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
private static func setIfEmpty(_ value: inout String?, _ replacement: String?) -> Bool {
|
||||
let cleaned = replacement?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let cleaned, !cleaned.isEmpty else { return false }
|
||||
if value == nil || value?.isEmpty == true {
|
||||
value = cleaned
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func clearLegacyStores(
|
||||
stores: LegacyStores,
|
||||
sawAccounts: Bool,
|
||||
log: CodexBarLogger) -> Bool
|
||||
{
|
||||
var success = true
|
||||
do {
|
||||
try stores.zaiTokenStore.storeToken(nil)
|
||||
try stores.syntheticTokenStore.storeToken(nil)
|
||||
try stores.copilotTokenStore.storeToken(nil)
|
||||
try stores.minimaxAPITokenStore.storeToken(nil)
|
||||
try stores.kimiTokenStore.storeToken(nil)
|
||||
try stores.kimiK2TokenStore.storeToken(nil)
|
||||
try stores.codexCookieStore.storeCookieHeader(nil)
|
||||
try stores.claudeCookieStore.storeCookieHeader(nil)
|
||||
try stores.cursorCookieStore.storeCookieHeader(nil)
|
||||
try stores.opencodeCookieStore.storeCookieHeader(nil)
|
||||
try stores.factoryCookieStore.storeCookieHeader(nil)
|
||||
try stores.minimaxCookieStore.storeCookieHeader(nil)
|
||||
try stores.augmentCookieStore.storeCookieHeader(nil)
|
||||
try stores.ampCookieStore.storeCookieHeader(nil)
|
||||
} catch {
|
||||
log.error("Failed to clear legacy secrets: \(error)")
|
||||
success = false
|
||||
}
|
||||
|
||||
if sawAccounts {
|
||||
let legacyURL = FileTokenAccountStore.defaultURL()
|
||||
if FileManager.default.fileExists(atPath: legacyURL.path) {
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig {
|
||||
let configsByID = Dictionary(uniqueKeysWithValues: config.providers.map { ($0.id, $0) })
|
||||
var seen: Set<UsageProvider> = []
|
||||
var ordered: [ProviderConfig] = []
|
||||
ordered.reserveCapacity(config.providers.count)
|
||||
|
||||
for rawValue in raw {
|
||||
guard let provider = UsageProvider(rawValue: rawValue),
|
||||
let entry = configsByID[provider],
|
||||
!seen.contains(provider)
|
||||
else { continue }
|
||||
seen.insert(provider)
|
||||
ordered.append(entry)
|
||||
}
|
||||
|
||||
for provider in UsageProvider.allCases where !seen.contains(provider) {
|
||||
ordered.append(configsByID[provider] ?? ProviderConfig(id: provider))
|
||||
}
|
||||
|
||||
var updated = config
|
||||
updated.providers = ordered
|
||||
return updated
|
||||
}
|
||||
|
||||
private static func applyProviderToggles(
|
||||
_ toggles: [String: Bool],
|
||||
config: CodexBarConfig) -> CodexBarConfig
|
||||
{
|
||||
var updated = config
|
||||
for index in updated.providers.indices {
|
||||
let provider = updated.providers[index].id
|
||||
let meta = ProviderDescriptorRegistry.descriptor(for: provider).metadata
|
||||
if let value = toggles[meta.cliName] {
|
||||
updated.providers[index].enabled = value
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol CookieHeaderStoring: Sendable {
|
||||
func loadCookieHeader() throws -> String?
|
||||
func storeCookieHeader(_ header: String?) throws
|
||||
}
|
||||
|
||||
enum CookieHeaderStoreError: LocalizedError {
|
||||
case keychainStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .keychainStatus(status):
|
||||
"Keychain error: \(status)"
|
||||
case .invalidData:
|
||||
"Keychain returned invalid data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainCookieHeaderStore: CookieHeaderStoring {
|
||||
private static let log = CodexBarLog.logger(LogCategories.cookieHeaderStore)
|
||||
|
||||
private let service = "com.steipete.CodexBar"
|
||||
private let account: String
|
||||
private let promptKind: KeychainPromptContext.Kind
|
||||
|
||||
// Cache to reduce keychain access frequency
|
||||
private nonisolated(unsafe) static var cache: [String: CachedValue] = [:]
|
||||
private static let cacheLock = NSLock()
|
||||
private static let cacheTTL: TimeInterval = 1800 // 30 minutes
|
||||
|
||||
private struct CachedValue {
|
||||
let value: String?
|
||||
let timestamp: Date
|
||||
|
||||
var isExpired: Bool {
|
||||
Date().timeIntervalSince(self.timestamp) > KeychainCookieHeaderStore.cacheTTL
|
||||
}
|
||||
}
|
||||
|
||||
init(account: String, promptKind: KeychainPromptContext.Kind) {
|
||||
self.account = account
|
||||
self.promptKind = promptKind
|
||||
}
|
||||
|
||||
func loadCookieHeader() throws -> String? {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping cookie load")
|
||||
return nil
|
||||
}
|
||||
// Check cache first
|
||||
Self.cacheLock.lock()
|
||||
if let cached = Self.cache[self.account], !cached.isExpired {
|
||||
Self.cacheLock.unlock()
|
||||
Self.log.debug("Using cached cookie header for \(self.account)")
|
||||
return cached.value
|
||||
}
|
||||
Self.cacheLock.unlock()
|
||||
var result: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
if case .interactionRequired = KeychainAccessPreflight
|
||||
.checkGenericPassword(service: self.service, account: self.account)
|
||||
{
|
||||
KeychainPromptHandler.handler?(KeychainPromptContext(
|
||||
kind: self.promptKind,
|
||||
service: self.service,
|
||||
account: self.account))
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
// Cache the nil result
|
||||
Self.cacheLock.lock()
|
||||
Self.cache[self.account] = CachedValue(value: nil, timestamp: Date())
|
||||
Self.cacheLock.unlock()
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
Self.log.error("Keychain read failed: \(status)")
|
||||
throw CookieHeaderStoreError.keychainStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw CookieHeaderStoreError.invalidData
|
||||
}
|
||||
let header = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let finalValue = (header?.isEmpty == false) ? header : nil
|
||||
|
||||
// Cache the result
|
||||
Self.cacheLock.lock()
|
||||
Self.cache[self.account] = CachedValue(value: finalValue, timestamp: Date())
|
||||
Self.cacheLock.unlock()
|
||||
|
||||
return finalValue
|
||||
}
|
||||
|
||||
func storeCookieHeader(_ header: String?) throws {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping cookie store")
|
||||
return
|
||||
}
|
||||
guard let raw = header?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
try self.deleteIfPresent()
|
||||
// Invalidate cache
|
||||
Self.cacheLock.lock()
|
||||
Self.cache.removeValue(forKey: self.account)
|
||||
Self.cacheLock.unlock()
|
||||
return
|
||||
}
|
||||
guard CookieHeaderNormalizer.normalize(raw) != nil else {
|
||||
try self.deleteIfPresent()
|
||||
// Invalidate cache
|
||||
Self.cacheLock.lock()
|
||||
Self.cache.removeValue(forKey: self.account)
|
||||
Self.cacheLock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
let data = raw.data(using: .utf8)!
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
// Update cache
|
||||
Self.cacheLock.lock()
|
||||
Self.cache[self.account] = CachedValue(value: raw, timestamp: Date())
|
||||
Self.cacheLock.unlock()
|
||||
return
|
||||
}
|
||||
if updateStatus != errSecItemNotFound {
|
||||
Self.log.error("Keychain update failed: \(updateStatus)")
|
||||
throw CookieHeaderStoreError.keychainStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
for (key, value) in attributes {
|
||||
addQuery[key] = value
|
||||
}
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
Self.log.error("Keychain add failed: \(addStatus)")
|
||||
throw CookieHeaderStoreError.keychainStatus(addStatus)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
Self.cacheLock.lock()
|
||||
Self.cache[self.account] = CachedValue(value: raw, timestamp: Date())
|
||||
Self.cacheLock.unlock()
|
||||
}
|
||||
|
||||
private func deleteIfPresent() throws {
|
||||
guard !KeychainAccessGate.isDisabled else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let status = KeychainSecurity.delete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
// Invalidate cache
|
||||
Self.cacheLock.lock()
|
||||
Self.cache.removeValue(forKey: self.account)
|
||||
Self.cacheLock.unlock()
|
||||
return
|
||||
}
|
||||
Self.log.error("Keychain delete failed: \(status)")
|
||||
throw CookieHeaderStoreError.keychainStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol CopilotTokenStoring: Sendable {
|
||||
func loadToken() throws -> String?
|
||||
func storeToken(_ token: String?) throws
|
||||
}
|
||||
|
||||
enum CopilotTokenStoreError: LocalizedError {
|
||||
case keychainStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .keychainStatus(status):
|
||||
"Keychain error: \(status)"
|
||||
case .invalidData:
|
||||
"Keychain returned invalid data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainCopilotTokenStore: CopilotTokenStoring {
|
||||
private static let log = CodexBarLog.logger(LogCategories.copilotTokenStore)
|
||||
|
||||
private let service = "com.steipete.CodexBar"
|
||||
private let account = "copilot-api-token"
|
||||
|
||||
func loadToken() throws -> String? {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token load")
|
||||
return nil
|
||||
}
|
||||
var result: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
if case .interactionRequired = KeychainAccessPreflight
|
||||
.checkGenericPassword(service: self.service, account: self.account)
|
||||
{
|
||||
KeychainPromptHandler.handler?(KeychainPromptContext(
|
||||
kind: .copilotToken,
|
||||
service: self.service,
|
||||
account: self.account))
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
Self.log.error("Keychain read failed: \(status)")
|
||||
throw CopilotTokenStoreError.keychainStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw CopilotTokenStoreError.invalidData
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let token, !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeToken(_ token: String?) throws {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token store")
|
||||
return
|
||||
}
|
||||
let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned == nil || cleaned?.isEmpty == true {
|
||||
try self.deleteTokenIfPresent()
|
||||
return
|
||||
}
|
||||
|
||||
let data = cleaned!.data(using: .utf8)!
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
if updateStatus != errSecItemNotFound {
|
||||
Self.log.error("Keychain update failed: \(updateStatus)")
|
||||
throw CopilotTokenStoreError.keychainStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
for (key, value) in attributes {
|
||||
addQuery[key] = value
|
||||
}
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
Self.log.error("Keychain add failed: \(addStatus)")
|
||||
throw CopilotTokenStoreError.keychainStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteTokenIfPresent() throws {
|
||||
guard !KeychainAccessGate.isDisabled else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let status = KeychainSecurity.delete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
Self.log.error("Keychain delete failed: \(status)")
|
||||
throw CopilotTokenStoreError.keychainStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import Charts
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct CostHistoryChartMenuView: View {
|
||||
typealias DailyEntry = CostUsageDailyReport.Entry
|
||||
|
||||
enum AxisLabelPlacement: Equatable {
|
||||
case hidden
|
||||
case centered
|
||||
case edges
|
||||
}
|
||||
|
||||
private struct Point: Identifiable {
|
||||
let id: String
|
||||
let date: Date
|
||||
let costUSD: Double
|
||||
let totalTokens: Int?
|
||||
let requestCount: Int?
|
||||
|
||||
init(date: Date, costUSD: Double, totalTokens: Int?, requestCount: Int?) {
|
||||
self.date = date
|
||||
self.costUSD = costUSD
|
||||
self.totalTokens = totalTokens
|
||||
self.requestCount = requestCount
|
||||
self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)"
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailRow: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let modeSubtitle: String?
|
||||
let accentColor: Color
|
||||
}
|
||||
|
||||
private struct DetailContent {
|
||||
let primary: String
|
||||
let rows: [DetailRow]
|
||||
}
|
||||
|
||||
private let provider: UsageProvider
|
||||
private let daily: [DailyEntry]
|
||||
private let totalCostUSD: Double?
|
||||
private let currencyCode: String
|
||||
private let historyDays: Int
|
||||
private let windowLabel: String?
|
||||
private let projects: [CostUsageProjectBreakdown]
|
||||
private let width: CGFloat
|
||||
@State private var selectedDateKey: String?
|
||||
|
||||
init(
|
||||
provider: UsageProvider,
|
||||
daily: [DailyEntry],
|
||||
totalCostUSD: Double?,
|
||||
currencyCode: String = "USD",
|
||||
historyDays: Int = 30,
|
||||
windowLabel: String? = nil,
|
||||
projects: [CostUsageProjectBreakdown] = [],
|
||||
width: CGFloat)
|
||||
{
|
||||
self.provider = provider
|
||||
self.daily = daily
|
||||
self.totalCostUSD = totalCostUSD
|
||||
self.currencyCode = currencyCode
|
||||
self.historyDays = max(1, min(365, historyDays))
|
||||
self.windowLabel = windowLabel
|
||||
self.projects = projects
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let model = Self.makeModel(provider: self.provider, daily: self.daily)
|
||||
let selectedDateKey = self.selectedDateKey ?? Self.defaultSelectedDateKey(model: model)
|
||||
VStack(alignment: .leading, spacing: Self.outerSpacing) {
|
||||
if model.points.isEmpty {
|
||||
Text(L("No cost history data."))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(L("No cost history data."))
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(model.points) { point in
|
||||
BarMark(
|
||||
x: .value(L("Day"), point.date, unit: .day),
|
||||
y: .value(L("Cost"), point.costUSD))
|
||||
.foregroundStyle(model.barColor)
|
||||
}
|
||||
if let peak = Self.peakPoint(model: model) {
|
||||
let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0)
|
||||
BarMark(
|
||||
x: .value(L("Day"), peak.date, unit: .day),
|
||||
yStart: .value(L("Cap start"), capStart),
|
||||
yEnd: .value(L("Cap end"), peak.costUSD))
|
||||
.foregroundStyle(Color(nsColor: .systemYellow))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading, values: Self.yAxisTickValues(maxCostUSD: model.maxCostUSD)) { value in
|
||||
AxisGridLine().foregroundStyle(Color.clear)
|
||||
AxisTick().foregroundStyle(Color.clear)
|
||||
AxisValueLabel(centered: false) {
|
||||
if let raw = value.as(Double.self) {
|
||||
Text(Self.yAxisCostString(raw, currencyCode: self.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: model.axisDates) { value in
|
||||
AxisGridLine().foregroundStyle(Color.clear)
|
||||
AxisTick().foregroundStyle(Color.clear)
|
||||
if let date = value.as(Date.self) {
|
||||
AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) {
|
||||
Text(date, format: .dateTime.month(.abbreviated).day())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(height: Self.chartHeight)
|
||||
.accessibilityLabel(L("Cost history chart"))
|
||||
.accessibilityValue(
|
||||
model.points.isEmpty
|
||||
? L("No data")
|
||||
: String(format: L("%d days of cost data"), model.points.count))
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) {
|
||||
Rectangle()
|
||||
.fill(Self.selectionBandColor)
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.position(x: rect.midX, y: rect.midY)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
MouseLocationReader { location in
|
||||
self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let detail = self.detailContent(selectedDateKey: selectedDateKey, model: model)
|
||||
VStack(alignment: .leading, spacing: Self.detailSpacing) {
|
||||
Text(detail.primary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)
|
||||
if model.detailViewportRowCount > 0 {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: Self.detailSpacing) {
|
||||
ForEach(detail.rows) { row in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Rectangle()
|
||||
.fill(row.accentColor)
|
||||
.frame(
|
||||
width: 2,
|
||||
height: Self.accentHeight(
|
||||
for: row,
|
||||
rowHeight: model.detailRowHeight))
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(row.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(height: Self.detailTitleLineHeight, alignment: .leading)
|
||||
if let subtitle = row.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(
|
||||
height: Self.detailSubtitleLineHeight,
|
||||
alignment: .leading)
|
||||
}
|
||||
if let modeSubtitle = row.modeSubtitle {
|
||||
Text(modeSubtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(
|
||||
height: Self.detailSubtitleLineHeight,
|
||||
alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: model.detailRowHeight, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollIndicators(
|
||||
Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden)
|
||||
.frame(
|
||||
height: Self.detailRowsViewportHeight(
|
||||
rowCount: model.detailViewportRowCount,
|
||||
rowHeight: model.detailRowHeight),
|
||||
alignment: .topLeading)
|
||||
.id(selectedDateKey)
|
||||
|
||||
if model.hasDetailOverflow {
|
||||
Text(Self.detailOverflowHint(itemCount: detail.rows.count) ?? " ")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.frame(height: Self.detailHintHeight, alignment: .leading)
|
||||
.accessibilityHidden(!Self.detailRowsNeedScrolling(itemCount: detail.rows.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
height: Self.detailBlockHeight(
|
||||
rowCount: model.detailViewportRowCount,
|
||||
hasOverflow: model.hasDetailOverflow,
|
||||
rowHeight: model.detailRowHeight),
|
||||
alignment: .topLeading)
|
||||
}
|
||||
|
||||
if let total = self.totalCostUSD {
|
||||
Text(String(
|
||||
format: L("Est. total (%@): %@"),
|
||||
self.windowLabel ?? Self.windowLabel(days: self.historyDays),
|
||||
self.costString(total)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)
|
||||
}
|
||||
|
||||
if !self.projects.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Self.projectRowSpacing) {
|
||||
Text("Projects")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)
|
||||
ForEach(Array(self.projects.prefix(Self.maxVisibleProjectRows)), id: \.projectRowID) { project in
|
||||
let visibleSources = Self.visibleProjectSources(project)
|
||||
VStack(alignment: .leading, spacing: Self.projectSourceSpacing) {
|
||||
self.projectParentRow(project)
|
||||
if !visibleSources.isEmpty {
|
||||
ForEach(
|
||||
Array(visibleSources.prefix(Self.maxVisibleProjectSourceRows)),
|
||||
id: \.sourceRowID)
|
||||
{ source in
|
||||
self.projectSourceRow(source)
|
||||
}
|
||||
let hiddenSourceCount = visibleSources.count - Self.maxVisibleProjectSourceRows
|
||||
if hiddenSourceCount > 0 {
|
||||
Text("+ \(hiddenSourceCount) more")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.padding(.leading, Self.projectSourceIndent)
|
||||
.frame(height: Self.projectMoreRowHeight, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: Self.projectEntryHeight(project), alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
.frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, Self.verticalPadding)
|
||||
.frame(minWidth: self.width, maxWidth: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
private struct Model {
|
||||
let points: [Point]
|
||||
let pointsByDateKey: [String: Point]
|
||||
let entriesByDateKey: [String: DailyEntry]
|
||||
let dateKeys: [(key: String, date: Date)]
|
||||
let axisDates: [Date]
|
||||
let barColor: Color
|
||||
let peakKey: String?
|
||||
let maxCostUSD: Double
|
||||
let detailViewportRowCount: Int
|
||||
let hasDetailOverflow: Bool
|
||||
let detailRowHeight: CGFloat
|
||||
}
|
||||
|
||||
private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1)
|
||||
static let maxVisibleDetailLines = 4
|
||||
private static let detailPrimaryLineHeight: CGFloat = 16
|
||||
private static let detailTitleLineHeight: CGFloat = 16
|
||||
private static let detailSubtitleLineHeight: CGFloat = 13
|
||||
private static let compactDetailRowHeight: CGFloat = 36
|
||||
private static let expandedDetailRowHeight: CGFloat = 44
|
||||
private static let detailSpacing: CGFloat = 6
|
||||
private static let detailHintHeight: CGFloat = 13
|
||||
private static let chartHeight: CGFloat = 130
|
||||
private static let outerSpacing: CGFloat = 10
|
||||
private static let projectRowHeight: CGFloat = 31
|
||||
private static let projectRowSpacing: CGFloat = 5
|
||||
private static let maxVisibleProjectRows = 5
|
||||
private static let projectSourceRowHeight: CGFloat = 29
|
||||
private static let projectSourceSpacing: CGFloat = 3
|
||||
private static let projectSourceIndent: CGFloat = 10
|
||||
private static let projectMoreRowHeight: CGFloat = 16
|
||||
private static let maxVisibleProjectSourceRows = 2
|
||||
static let verticalPadding: CGFloat = 10
|
||||
|
||||
static func windowLabel(days: Int) -> String {
|
||||
if days == 1 {
|
||||
return L("Today")
|
||||
}
|
||||
return String(format: L("Last %d days"), days)
|
||||
}
|
||||
|
||||
private static func accentHeight(for row: DetailRow, rowHeight: CGFloat) -> CGFloat {
|
||||
row.subtitle == nil && row.modeSubtitle == nil ? 14 : rowHeight
|
||||
}
|
||||
|
||||
private static func capHeight(maxValue: Double) -> Double {
|
||||
maxValue * 0.05
|
||||
}
|
||||
|
||||
/// Y-axis tick values for the cost chart: 0, mid, max when the range is at
|
||||
/// $1 or more; 0 and max for smaller ranges; empty for flat/no data so the
|
||||
/// axis renders no labels.
|
||||
private static func yAxisTickValues(maxCostUSD: Double) -> [Double] {
|
||||
guard maxCostUSD > 0 else { return [] }
|
||||
if maxCostUSD < 1.0 {
|
||||
return [0, maxCostUSD]
|
||||
}
|
||||
return [0, maxCostUSD / 2, maxCostUSD]
|
||||
}
|
||||
|
||||
private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model {
|
||||
let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date }
|
||||
var points: [Point] = []
|
||||
points.reserveCapacity(sorted.count)
|
||||
|
||||
var pointsByKey: [String: Point] = [:]
|
||||
pointsByKey.reserveCapacity(sorted.count)
|
||||
|
||||
var entriesByKey: [String: DailyEntry] = [:]
|
||||
entriesByKey.reserveCapacity(sorted.count)
|
||||
|
||||
var dateKeys: [(key: String, date: Date)] = []
|
||||
dateKeys.reserveCapacity(sorted.count)
|
||||
|
||||
var peak: (key: String, costUSD: Double)?
|
||||
var maxCostUSD: Double = 0
|
||||
var maxDetailRows = 0
|
||||
var hasModeDetails = false
|
||||
for entry in sorted {
|
||||
guard let (costUSD, date) = self.chartPointInput(for: entry) else { continue }
|
||||
let point = Point(
|
||||
date: date,
|
||||
costUSD: costUSD,
|
||||
totalTokens: entry.totalTokens,
|
||||
requestCount: entry.requestCount)
|
||||
points.append(point)
|
||||
pointsByKey[entry.date] = point
|
||||
entriesByKey[entry.date] = entry
|
||||
dateKeys.append((entry.date, date))
|
||||
let modelBreakdowns = entry.modelBreakdowns ?? []
|
||||
maxDetailRows = max(maxDetailRows, modelBreakdowns.count)
|
||||
hasModeDetails = hasModeDetails || modelBreakdowns.contains { Self.hasModeSubtitle($0) }
|
||||
if let cur = peak {
|
||||
if costUSD > cur.costUSD {
|
||||
peak = (entry.date, costUSD)
|
||||
}
|
||||
} else {
|
||||
peak = (entry.date, costUSD)
|
||||
}
|
||||
maxCostUSD = max(maxCostUSD, costUSD)
|
||||
}
|
||||
|
||||
let axisDates: [Date] = {
|
||||
guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] }
|
||||
if Calendar.current.isDate(first, inSameDayAs: last) {
|
||||
return [first]
|
||||
}
|
||||
return [first, last]
|
||||
}()
|
||||
|
||||
let barColor = Self.barColor(for: provider)
|
||||
return Model(
|
||||
points: points,
|
||||
pointsByDateKey: pointsByKey,
|
||||
entriesByDateKey: entriesByKey,
|
||||
dateKeys: dateKeys,
|
||||
axisDates: axisDates,
|
||||
barColor: barColor,
|
||||
peakKey: maxCostUSD > 0 ? peak?.key : nil,
|
||||
maxCostUSD: maxCostUSD,
|
||||
detailViewportRowCount: min(maxDetailRows, self.maxVisibleDetailLines),
|
||||
hasDetailOverflow: maxDetailRows > self.maxVisibleDetailLines,
|
||||
detailRowHeight: hasModeDetails ? self.expandedDetailRowHeight : self.compactDetailRowHeight)
|
||||
}
|
||||
|
||||
private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement {
|
||||
switch dates.count {
|
||||
case 0: .hidden
|
||||
case 1: .centered
|
||||
default: .edges
|
||||
}
|
||||
}
|
||||
|
||||
private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint {
|
||||
switch self.axisLabelPlacement(for: axisDates) {
|
||||
case .hidden, .centered:
|
||||
.top
|
||||
case .edges:
|
||||
if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) {
|
||||
.topLeading
|
||||
} else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) {
|
||||
.topTrailing
|
||||
} else {
|
||||
.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func barColor(for provider: UsageProvider) -> Color {
|
||||
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
|
||||
return Color(red: color.red, green: color.green, blue: color.blue)
|
||||
}
|
||||
|
||||
private static func dateFromDayKey(_ key: String) -> Date? {
|
||||
let parts = key.split(separator: "-")
|
||||
guard parts.count == 3,
|
||||
let year = Int(parts[0]),
|
||||
let month = Int(parts[1]),
|
||||
let day = Int(parts[2]) else { return nil }
|
||||
|
||||
var comps = DateComponents()
|
||||
comps.calendar = Calendar.current
|
||||
comps.timeZone = TimeZone.current
|
||||
comps.year = year
|
||||
comps.month = month
|
||||
comps.day = day
|
||||
comps.hour = 12
|
||||
return comps.date
|
||||
}
|
||||
|
||||
private static func chartPointInput(for entry: DailyEntry) -> (costUSD: Double, date: Date)? {
|
||||
guard let costUSD = entry.costUSD, costUSD >= 0 else { return nil }
|
||||
guard let date = self.dateFromDayKey(entry.date) else { return nil }
|
||||
return (costUSD, date)
|
||||
}
|
||||
|
||||
private static func peakPoint(model: Model) -> Point? {
|
||||
guard let key = model.peakKey else { return nil }
|
||||
return model.pointsByDateKey[key]
|
||||
}
|
||||
|
||||
private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool {
|
||||
item.standardCostUSD != nil || item.priorityCostUSD != nil
|
||||
}
|
||||
|
||||
private static func detailRowsViewportHeight(rowCount: Int, rowHeight: CGFloat) -> CGFloat {
|
||||
guard rowCount > 0 else { return 0 }
|
||||
return CGFloat(rowCount) * rowHeight + CGFloat(rowCount - 1) * self.detailSpacing
|
||||
}
|
||||
|
||||
private static func detailBlockHeight(rowCount: Int, hasOverflow: Bool, rowHeight: CGFloat) -> CGFloat {
|
||||
guard rowCount > 0 else { return self.detailPrimaryLineHeight }
|
||||
var height = self.detailPrimaryLineHeight + self.detailSpacing
|
||||
height += self.detailRowsViewportHeight(rowCount: rowCount, rowHeight: rowHeight)
|
||||
if hasOverflow {
|
||||
height += self.detailSpacing + self.detailHintHeight
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat {
|
||||
let visibleProjects = Array(projects.prefix(self.maxVisibleProjectRows))
|
||||
guard !visibleProjects.isEmpty else { return 0 }
|
||||
return self.detailPrimaryLineHeight
|
||||
+ self.projectRowSpacing
|
||||
+ visibleProjects.reduce(CGFloat(0)) { $0 + self.projectEntryHeight($1) }
|
||||
+ CGFloat(max(visibleProjects.count - 1, 0)) * self.projectRowSpacing
|
||||
}
|
||||
|
||||
private static func projectEntryHeight(_ project: CostUsageProjectBreakdown) -> CGFloat {
|
||||
let sources = self.visibleProjectSources(project)
|
||||
guard !sources.isEmpty else { return self.projectRowHeight }
|
||||
let visibleSources = min(sources.count, self.maxVisibleProjectSourceRows)
|
||||
let moreRows = sources.count > self.maxVisibleProjectSourceRows ? 1 : 0
|
||||
return self.projectRowHeight
|
||||
+ CGFloat(visibleSources) * (self.projectSourceRowHeight + self.projectSourceSpacing)
|
||||
+ CGFloat(moreRows) * (self.projectMoreRowHeight + self.projectSourceSpacing)
|
||||
}
|
||||
|
||||
static func visibleProjectSources(
|
||||
_ project: CostUsageProjectBreakdown) -> [CostUsageProjectSourceBreakdown]
|
||||
{
|
||||
guard project.sources.count == 1 else { return project.sources }
|
||||
guard let source = project.sources.first, source.path != project.path else { return [] }
|
||||
return [source]
|
||||
}
|
||||
|
||||
private static func defaultSelectedDateKey(model: Model) -> String? {
|
||||
model.dateKeys.last?.key
|
||||
}
|
||||
|
||||
private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? {
|
||||
guard let key = self.selectedDateKey else { return nil }
|
||||
guard let plotAnchor = proxy.plotFrame else { return nil }
|
||||
let plotFrame = geo[plotAnchor]
|
||||
guard let index = model.dateKeys.firstIndex(where: { $0.key == key }) else { return nil }
|
||||
let date = model.dateKeys[index].date
|
||||
guard let x = proxy.position(forX: date) else { return nil }
|
||||
|
||||
// Use the calendar day slot width so the band stays the same size regardless of data gaps.
|
||||
let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20)
|
||||
let slotWidth = abs(nextDayX - x)
|
||||
let barHalfWidth = slotWidth * 0.25 + 2
|
||||
|
||||
let left = plotFrame.origin.x + x - barHalfWidth
|
||||
let right = plotFrame.origin.x + x + barHalfWidth
|
||||
return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height)
|
||||
}
|
||||
|
||||
private func updateSelection(
|
||||
location: CGPoint?,
|
||||
model: Model,
|
||||
proxy: ChartProxy,
|
||||
geo: GeometryProxy)
|
||||
{
|
||||
// Keep the last hovered day selected when the pointer leaves the chart so the adjacent
|
||||
// model-breakdown scroller remains interactive. The selection resets with the menu view.
|
||||
guard let location else { return }
|
||||
|
||||
guard let plotAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geo[plotAnchor]
|
||||
guard plotFrame.contains(location) else { return }
|
||||
|
||||
let xInPlot = location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: xInPlot) else { return }
|
||||
guard let nearest = self.nearestDateKey(to: date, model: model) else { return }
|
||||
|
||||
// Stay on the last selected bar when cursor is in the gap between bars.
|
||||
if let nearestEntry = model.dateKeys.first(where: { $0.key == nearest }),
|
||||
let barX = proxy.position(forX: nearestEntry.date)
|
||||
{
|
||||
let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: nearestEntry.date)) ??
|
||||
(barX + 20)
|
||||
let slotWidth = abs(nextDayX - barX)
|
||||
guard ChartBarHoverSelection.accepts(
|
||||
distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)),
|
||||
barHalfWidth: slotWidth * 0.25 + 2,
|
||||
selectableCount: model.dateKeys.count)
|
||||
else { return }
|
||||
}
|
||||
|
||||
if self.selectedDateKey != nearest {
|
||||
self.selectedDateKey = nearest
|
||||
}
|
||||
}
|
||||
|
||||
private func projectSummary(_ project: CostUsageProjectBreakdown) -> String {
|
||||
let cost = project.totalCostUSD
|
||||
.map { self.costString($0) } ?? "—"
|
||||
guard let totalTokens = project.totalTokens else { return cost }
|
||||
return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))"
|
||||
}
|
||||
|
||||
private func projectParentRow(_ project: CostUsageProjectBreakdown) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 8) {
|
||||
Text(project.name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 8)
|
||||
Text(self.projectSummary(project))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
}
|
||||
if let path = project.path {
|
||||
Text(path)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
.frame(height: Self.projectRowHeight, alignment: .leading)
|
||||
}
|
||||
|
||||
private func projectSourceRow(_ source: CostUsageProjectSourceBreakdown) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 6) {
|
||||
Text(source.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 6)
|
||||
Text(self.projectSourceSummary(source))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
}
|
||||
if let path = source.path {
|
||||
Text(path)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .quaternaryLabelColor))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
.padding(.leading, Self.projectSourceIndent)
|
||||
.frame(height: Self.projectSourceRowHeight, alignment: .leading)
|
||||
}
|
||||
|
||||
private func projectSourceSummary(_ source: CostUsageProjectSourceBreakdown) -> String {
|
||||
let cost = source.totalCostUSD
|
||||
.map { self.costString($0) } ?? "—"
|
||||
guard let totalTokens = source.totalTokens else { return cost }
|
||||
return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))"
|
||||
}
|
||||
|
||||
private func nearestDateKey(to date: Date, model: Model) -> String? {
|
||||
guard !model.dateKeys.isEmpty else { return nil }
|
||||
var best: (key: String, distance: TimeInterval)?
|
||||
for entry in model.dateKeys {
|
||||
let dist = abs(entry.date.timeIntervalSince(date))
|
||||
if let cur = best {
|
||||
if dist < cur.distance {
|
||||
best = (entry.key, dist)
|
||||
}
|
||||
} else {
|
||||
best = (entry.key, dist)
|
||||
}
|
||||
}
|
||||
return best?.key
|
||||
}
|
||||
|
||||
private func detailContent(selectedDateKey: String?, model: Model) -> DetailContent {
|
||||
guard let key = selectedDateKey,
|
||||
let point = model.pointsByDateKey[key],
|
||||
let date = Self.dateFromDayKey(key)
|
||||
else {
|
||||
return DetailContent(primary: L("Hover a bar for details"), rows: [])
|
||||
}
|
||||
|
||||
let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())
|
||||
let cost = self.costString(point.costUSD)
|
||||
var parts = [cost]
|
||||
if let tokens = point.totalTokens {
|
||||
parts.append("\(UsageFormatter.tokenCountString(tokens)) tokens")
|
||||
}
|
||||
if let requests = point.requestCount {
|
||||
parts.append("\(UsageFormatter.tokenCountString(requests)) requests")
|
||||
}
|
||||
let primary = "\(dayLabel): \(parts.joined(separator: " · "))"
|
||||
return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model))
|
||||
}
|
||||
|
||||
private func breakdownRows(key: String, model: Model) -> [DetailRow] {
|
||||
guard let entry = model.entriesByDateKey[key] else { return [] }
|
||||
guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] }
|
||||
|
||||
return Self.orderedBreakdownItems(breakdown)
|
||||
.enumerated()
|
||||
.map { index, item in
|
||||
DetailRow(
|
||||
id: "\(item.modelName)-\(index)",
|
||||
title: UsageFormatter.modelDisplayName(item.modelName),
|
||||
subtitle: self.modelBreakdownTotalSubtitle(item),
|
||||
modeSubtitle: self.modelBreakdownModeSubtitle(item),
|
||||
accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index)))
|
||||
}
|
||||
}
|
||||
|
||||
static func orderedBreakdownItems(
|
||||
_ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown]
|
||||
{
|
||||
breakdown.sorted { lhs, rhs in
|
||||
let lCost = lhs.costUSD ?? -1
|
||||
let rCost = rhs.costUSD ?? -1
|
||||
if lCost != rCost {
|
||||
return lCost > rCost
|
||||
}
|
||||
|
||||
let lTokens = lhs.totalTokens ?? -1
|
||||
let rTokens = rhs.totalTokens ?? -1
|
||||
if lTokens != rTokens {
|
||||
return lTokens > rTokens
|
||||
}
|
||||
|
||||
return lhs.modelName > rhs.modelName
|
||||
}
|
||||
}
|
||||
|
||||
static func detailViewportRowCount(itemCount: Int) -> Int {
|
||||
min(max(itemCount, 0), self.maxVisibleDetailLines)
|
||||
}
|
||||
|
||||
static func detailRowsNeedScrolling(itemCount: Int) -> Bool {
|
||||
itemCount > self.maxVisibleDetailLines
|
||||
}
|
||||
|
||||
static func detailOverflowHint(itemCount: Int) -> String? {
|
||||
self.detailRowsNeedScrolling(itemCount: itemCount) ? L("Scroll to see more models") : nil
|
||||
}
|
||||
|
||||
private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? {
|
||||
UsageFormatter.modelCostDetail(
|
||||
item.modelName,
|
||||
costUSD: item.costUSD,
|
||||
totalTokens: item.totalTokens,
|
||||
currencyCode: self.currencyCode)
|
||||
}
|
||||
|
||||
private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? {
|
||||
var parts: [String] = []
|
||||
if let standardCost = item.standardCostUSD {
|
||||
var standardPart = "Std \(self.costString(standardCost))"
|
||||
if let standardTokens = item.standardTokens {
|
||||
standardPart += " · \(UsageFormatter.tokenCountString(standardTokens))"
|
||||
}
|
||||
parts.append(standardPart)
|
||||
}
|
||||
if let priorityCost = item.priorityCostUSD {
|
||||
var priorityPart = "Fast \(self.costString(priorityCost))"
|
||||
if let priorityTokens = item.priorityTokens {
|
||||
priorityPart += " · \(UsageFormatter.tokenCountString(priorityTokens))"
|
||||
}
|
||||
parts.append(priorityPart)
|
||||
}
|
||||
guard !parts.isEmpty else { return nil }
|
||||
return parts.joined(separator: " / ")
|
||||
}
|
||||
|
||||
private func costString(_ value: Double) -> String {
|
||||
Self.costString(value, currencyCode: self.currencyCode)
|
||||
}
|
||||
|
||||
private static func costString(_ value: Double, currencyCode: String) -> String {
|
||||
UsageFormatter.currencyString(value, currencyCode: currencyCode)
|
||||
}
|
||||
|
||||
private static func yAxisCostString(_ value: Double, currencyCode: String) -> String {
|
||||
UsageFormatter.compactCurrencyString(value, currencyCode: currencyCode)
|
||||
}
|
||||
|
||||
private static func breakdownAccentOpacity(for index: Int) -> Double {
|
||||
let opacity = 0.75 - (Double(index) * 0.12)
|
||||
return max(0.3, opacity)
|
||||
}
|
||||
}
|
||||
|
||||
extension CostHistoryChartMenuView {
|
||||
struct RenderFingerprint: Equatable {
|
||||
let currencyCode: String
|
||||
let historyDays: Int
|
||||
let windowLabel: String?
|
||||
let totalCostBitPattern: UInt64?
|
||||
let hasDailyEntries: Bool
|
||||
let daily: [VisibleDailyFingerprint]
|
||||
let projects: [VisibleProjectFingerprint]
|
||||
}
|
||||
|
||||
struct VisibleDailyFingerprint: Equatable {
|
||||
let date: String
|
||||
let totalTokens: Int?
|
||||
let requestCount: Int?
|
||||
let costBitPattern: UInt64?
|
||||
let modelBreakdowns: [VisibleModelBreakdownFingerprint]
|
||||
}
|
||||
|
||||
struct VisibleModelBreakdownFingerprint: Equatable {
|
||||
let modelName: String
|
||||
let costBitPattern: UInt64?
|
||||
let totalTokens: Int?
|
||||
let standardCostBitPattern: UInt64?
|
||||
let priorityCostBitPattern: UInt64?
|
||||
let standardTokens: Int?
|
||||
let priorityTokens: Int?
|
||||
}
|
||||
|
||||
struct VisibleProjectFingerprint: Equatable {
|
||||
let name: String
|
||||
let path: String?
|
||||
let totalTokens: Int?
|
||||
let totalCostBitPattern: UInt64?
|
||||
let visibleSourceCount: Int
|
||||
let sources: [VisibleSourceFingerprint]
|
||||
}
|
||||
|
||||
struct VisibleSourceFingerprint: Equatable {
|
||||
let name: String
|
||||
let path: String?
|
||||
let totalTokens: Int?
|
||||
let totalCostBitPattern: UInt64?
|
||||
}
|
||||
|
||||
static func renderFingerprint(
|
||||
from snapshot: CostUsageTokenSnapshot,
|
||||
provider: UsageProvider) -> RenderFingerprint
|
||||
{
|
||||
let projects = provider == .codex ? snapshot.projects : []
|
||||
return RenderFingerprint(
|
||||
currencyCode: snapshot.currencyCode,
|
||||
historyDays: snapshot.historyDays,
|
||||
windowLabel: snapshot.historyLabel,
|
||||
totalCostBitPattern: snapshot.last30DaysCostUSD.map(\.bitPattern),
|
||||
hasDailyEntries: !snapshot.daily.isEmpty,
|
||||
daily: snapshot.daily
|
||||
.filter { self.chartPointInput(for: $0) != nil }
|
||||
.sorted { $0.date < $1.date }
|
||||
.map(self.visibleDailyFingerprint),
|
||||
projects: Array(projects.prefix(self.maxVisibleProjectRows)).map { project in
|
||||
let visibleSources = self.visibleProjectSources(project)
|
||||
return VisibleProjectFingerprint(
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
totalTokens: project.totalTokens,
|
||||
totalCostBitPattern: project.totalCostUSD.map(\.bitPattern),
|
||||
visibleSourceCount: visibleSources.count,
|
||||
sources: Array(visibleSources.prefix(self.maxVisibleProjectSourceRows)).map { source in
|
||||
VisibleSourceFingerprint(
|
||||
name: source.name,
|
||||
path: source.path,
|
||||
totalTokens: source.totalTokens,
|
||||
totalCostBitPattern: source.totalCostUSD.map(\.bitPattern))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private static func visibleDailyFingerprint(_ entry: DailyEntry) -> VisibleDailyFingerprint {
|
||||
VisibleDailyFingerprint(
|
||||
date: entry.date,
|
||||
totalTokens: entry.totalTokens,
|
||||
requestCount: entry.requestCount,
|
||||
costBitPattern: entry.costUSD.map(\.bitPattern),
|
||||
modelBreakdowns: self.orderedBreakdownItems(entry.modelBreakdowns ?? []).map { item in
|
||||
VisibleModelBreakdownFingerprint(
|
||||
modelName: item.modelName,
|
||||
costBitPattern: item.costUSD.map(\.bitPattern),
|
||||
totalTokens: item.totalTokens,
|
||||
standardCostBitPattern: item.standardCostUSD.map(\.bitPattern),
|
||||
priorityCostBitPattern: item.priorityCostUSD.map(\.bitPattern),
|
||||
standardTokens: item.standardCostUSD == nil ? nil : item.standardTokens,
|
||||
priorityTokens: item.priorityCostUSD == nil ? nil : item.priorityTokens)
|
||||
})
|
||||
}
|
||||
|
||||
static func _defaultSelectedDateKeyForTesting(provider: UsageProvider, daily: [DailyEntry]) -> String? {
|
||||
self.defaultSelectedDateKey(model: self.makeModel(provider: provider, daily: daily))
|
||||
}
|
||||
|
||||
static func _axisDatesForTesting(provider: UsageProvider, daily: [DailyEntry]) -> [Date] {
|
||||
self.makeModel(provider: provider, daily: daily).axisDates
|
||||
}
|
||||
|
||||
static func _axisLabelPlacementForTesting(
|
||||
provider: UsageProvider,
|
||||
daily: [DailyEntry]) -> AxisLabelPlacement
|
||||
{
|
||||
self.axisLabelPlacement(for: self.makeModel(provider: provider, daily: daily).axisDates)
|
||||
}
|
||||
|
||||
static func _yAxisTickValuesForTesting(maxCostUSD: Double) -> [Double] {
|
||||
self.yAxisTickValues(maxCostUSD: maxCostUSD)
|
||||
}
|
||||
|
||||
static func _yAxisCostStringForTesting(_ value: Double, currencyCode: String = "USD") -> String {
|
||||
self.yAxisCostString(value, currencyCode: currencyCode)
|
||||
}
|
||||
|
||||
static func _detailViewportConfigurationForTesting(
|
||||
provider: UsageProvider,
|
||||
daily: [DailyEntry]) -> (rowCount: Int, hasOverflow: Bool, rowHeight: CGFloat)
|
||||
{
|
||||
let model = self.makeModel(provider: provider, daily: daily)
|
||||
return (model.detailViewportRowCount, model.hasDetailOverflow, model.detailRowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
extension CostUsageProjectBreakdown {
|
||||
fileprivate var projectRowID: String {
|
||||
self.path ?? "unknown:\(self.name)"
|
||||
}
|
||||
}
|
||||
|
||||
extension CostUsageProjectSourceBreakdown {
|
||||
fileprivate var sourceRowID: String {
|
||||
self.path ?? "unknown:\(self.name)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import Charts
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct CreditsHistoryChartMenuView: View {
|
||||
private struct Point: Identifiable {
|
||||
let id: String
|
||||
let date: Date
|
||||
let creditsUsed: Double
|
||||
|
||||
init(date: Date, creditsUsed: Double) {
|
||||
self.date = date
|
||||
self.creditsUsed = creditsUsed
|
||||
self.id = "\(Int(date.timeIntervalSince1970))-\(creditsUsed)"
|
||||
}
|
||||
}
|
||||
|
||||
private let breakdown: [OpenAIDashboardDailyBreakdown]
|
||||
private let width: CGFloat
|
||||
@State private var selectedDayKey: String?
|
||||
|
||||
init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) {
|
||||
self.breakdown = breakdown
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let model = Self.makeModel(from: self.breakdown)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if model.points.isEmpty {
|
||||
Text(L("No credits history data."))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(L("No credits history data available."))
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(model.points) { point in
|
||||
BarMark(
|
||||
x: .value(L("Day"), point.date, unit: .day),
|
||||
y: .value(L("Credits used"), point.creditsUsed))
|
||||
.foregroundStyle(Self.barColor)
|
||||
}
|
||||
if let peak = Self.peakPoint(model: model) {
|
||||
let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0)
|
||||
BarMark(
|
||||
x: .value(L("Day"), peak.date, unit: .day),
|
||||
yStart: .value(L("Cap start"), capStart),
|
||||
yEnd: .value(L("Cap end"), peak.creditsUsed))
|
||||
.foregroundStyle(Color(nsColor: .systemYellow))
|
||||
}
|
||||
}
|
||||
.chartYAxis(.hidden)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: model.axisDates) { _ in
|
||||
AxisGridLine().foregroundStyle(Color.clear)
|
||||
AxisTick().foregroundStyle(Color.clear)
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).day())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(height: 130)
|
||||
.accessibilityLabel(L("Credits history chart"))
|
||||
.accessibilityValue(
|
||||
model.points.isEmpty
|
||||
? L("No data")
|
||||
: String(format: L("%d days of credits data"), model.points.count))
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) {
|
||||
Rectangle()
|
||||
.fill(Self.selectionBandColor)
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.position(x: rect.midX, y: rect.midY)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
MouseLocationReader { location in
|
||||
self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let detail = self.detailLines(model: model)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(detail.primary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(height: 16, alignment: .leading)
|
||||
Text(detail.secondary ?? " ")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(height: 16, alignment: .leading)
|
||||
.opacity(detail.secondary == nil ? 0 : 1)
|
||||
}
|
||||
|
||||
if let total = model.totalCreditsUsed {
|
||||
Text(String(
|
||||
format: L("Total (30d): %@ credits"),
|
||||
total.formatted(.number.precision(.fractionLength(0...2)))))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private struct Model {
|
||||
let points: [Point]
|
||||
let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown]
|
||||
let pointsByDayKey: [String: Point]
|
||||
let dayDates: [(dayKey: String, date: Date)]
|
||||
let selectableDayDates: [(dayKey: String, date: Date)]
|
||||
let axisDates: [Date]
|
||||
let peakKey: String?
|
||||
let totalCreditsUsed: Double?
|
||||
let maxCreditsUsed: Double
|
||||
}
|
||||
|
||||
private static let barColor = Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255)
|
||||
private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1)
|
||||
private static func capHeight(maxValue: Double) -> Double {
|
||||
maxValue * 0.05
|
||||
}
|
||||
|
||||
private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model {
|
||||
let sorted = breakdown.sorted { lhs, rhs in lhs.day < rhs.day }
|
||||
|
||||
var points: [Point] = []
|
||||
points.reserveCapacity(sorted.count)
|
||||
|
||||
var breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] = [:]
|
||||
breakdownByDayKey.reserveCapacity(sorted.count)
|
||||
|
||||
var pointsByDayKey: [String: Point] = [:]
|
||||
pointsByDayKey.reserveCapacity(sorted.count)
|
||||
|
||||
var dayDates: [(dayKey: String, date: Date)] = []
|
||||
dayDates.reserveCapacity(sorted.count)
|
||||
|
||||
var selectableDayDates: [(dayKey: String, date: Date)] = []
|
||||
selectableDayDates.reserveCapacity(sorted.count)
|
||||
|
||||
var totalCreditsUsed: Double = 0
|
||||
var peak: (key: String, creditsUsed: Double)?
|
||||
var maxCreditsUsed: Double = 0
|
||||
|
||||
for day in sorted {
|
||||
guard let date = self.dateFromDayKey(day.day) else { continue }
|
||||
breakdownByDayKey[day.day] = day
|
||||
dayDates.append((dayKey: day.day, date: date))
|
||||
totalCreditsUsed += day.totalCreditsUsed
|
||||
if day.totalCreditsUsed > 0 {
|
||||
let point = Point(date: date, creditsUsed: day.totalCreditsUsed)
|
||||
points.append(point)
|
||||
pointsByDayKey[day.day] = point
|
||||
selectableDayDates.append((dayKey: day.day, date: date))
|
||||
if let cur = peak {
|
||||
if day.totalCreditsUsed > cur.creditsUsed { peak = (day.day, day.totalCreditsUsed) }
|
||||
} else {
|
||||
peak = (day.day, day.totalCreditsUsed)
|
||||
}
|
||||
maxCreditsUsed = max(maxCreditsUsed, day.totalCreditsUsed)
|
||||
}
|
||||
}
|
||||
|
||||
let axisDates: [Date] = {
|
||||
guard let first = dayDates.first?.date, let last = dayDates.last?.date else { return [] }
|
||||
if Calendar.current.isDate(first, inSameDayAs: last) { return [first] }
|
||||
return [first, last]
|
||||
}()
|
||||
|
||||
return Model(
|
||||
points: points,
|
||||
breakdownByDayKey: breakdownByDayKey,
|
||||
pointsByDayKey: pointsByDayKey,
|
||||
dayDates: dayDates,
|
||||
selectableDayDates: selectableDayDates,
|
||||
axisDates: axisDates,
|
||||
peakKey: peak?.key,
|
||||
totalCreditsUsed: totalCreditsUsed > 0 ? totalCreditsUsed : nil,
|
||||
maxCreditsUsed: maxCreditsUsed)
|
||||
}
|
||||
|
||||
private static func dateFromDayKey(_ key: String) -> Date? {
|
||||
let parts = key.split(separator: "-")
|
||||
guard parts.count == 3,
|
||||
let year = Int(parts[0]),
|
||||
let month = Int(parts[1]),
|
||||
let day = Int(parts[2])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var comps = DateComponents()
|
||||
comps.calendar = Calendar.current
|
||||
comps.timeZone = TimeZone.current
|
||||
comps.year = year
|
||||
comps.month = month
|
||||
comps.day = day
|
||||
comps.hour = 12
|
||||
return comps.date
|
||||
}
|
||||
|
||||
private static func peakPoint(model: Model) -> Point? {
|
||||
guard let key = model.peakKey else { return nil }
|
||||
return model.pointsByDayKey[key]
|
||||
}
|
||||
|
||||
private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? {
|
||||
guard let key = self.selectedDayKey else { return nil }
|
||||
guard let plotAnchor = proxy.plotFrame else { return nil }
|
||||
let plotFrame = geo[plotAnchor]
|
||||
guard let index = model.dayDates.firstIndex(where: { $0.dayKey == key }) else { return nil }
|
||||
let date = model.dayDates[index].date
|
||||
guard let x = proxy.position(forX: date) else { return nil }
|
||||
|
||||
if model.dayDates.count <= 1 {
|
||||
return CGRect(
|
||||
x: plotFrame.origin.x,
|
||||
y: plotFrame.origin.y,
|
||||
width: plotFrame.width,
|
||||
height: plotFrame.height)
|
||||
}
|
||||
|
||||
// Use the calendar day slot width (always 1 day on the time axis) so the band is the
|
||||
// same size for every bar regardless of gaps in the data.
|
||||
let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20)
|
||||
let slotWidth = abs(nextDayX - x)
|
||||
let barHalfWidth = slotWidth * 0.25 + 2
|
||||
|
||||
let left = plotFrame.origin.x + x - barHalfWidth
|
||||
let right = plotFrame.origin.x + x + barHalfWidth
|
||||
return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height)
|
||||
}
|
||||
|
||||
private func updateSelection(
|
||||
location: CGPoint?,
|
||||
model: Model,
|
||||
proxy: ChartProxy,
|
||||
geo: GeometryProxy)
|
||||
{
|
||||
guard let location else {
|
||||
if self.selectedDayKey != nil { self.selectedDayKey = nil }
|
||||
return
|
||||
}
|
||||
|
||||
guard let plotAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geo[plotAnchor]
|
||||
guard plotFrame.contains(location) else { return }
|
||||
|
||||
let xInPlot = location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: xInPlot) else { return }
|
||||
guard let nearest = self.nearestDayKey(to: date, model: model) else { return }
|
||||
|
||||
// Stay on the last selected bar when cursor is in the gap between bars; only switch
|
||||
// selection when the cursor is over the bar's own visual body.
|
||||
// Skip this gate for single-day charts: no gap exists, and selectionBandRect
|
||||
// already covers the full plot width in that case.
|
||||
if model.selectableDayDates.count > 1,
|
||||
let nearestEntry = model.selectableDayDates.first(where: { $0.dayKey == nearest }),
|
||||
let barX = proxy.position(forX: nearestEntry.date)
|
||||
{
|
||||
let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: nearestEntry.date)) ??
|
||||
(barX + 20)
|
||||
let slotWidth = abs(nextDayX - barX)
|
||||
guard ChartBarHoverSelection.accepts(
|
||||
distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)),
|
||||
barHalfWidth: slotWidth * 0.25 + 2,
|
||||
selectableCount: model.selectableDayDates.count)
|
||||
else { return }
|
||||
}
|
||||
|
||||
if self.selectedDayKey != nearest {
|
||||
self.selectedDayKey = nearest
|
||||
}
|
||||
}
|
||||
|
||||
private func nearestDayKey(to date: Date, model: Model) -> String? {
|
||||
guard !model.selectableDayDates.isEmpty else { return nil }
|
||||
var best: (key: String, distance: TimeInterval)?
|
||||
for entry in model.selectableDayDates {
|
||||
let dist = abs(entry.date.timeIntervalSince(date))
|
||||
if let cur = best {
|
||||
if dist < cur.distance { best = (entry.dayKey, dist) }
|
||||
} else {
|
||||
best = (entry.dayKey, dist)
|
||||
}
|
||||
}
|
||||
return best?.key
|
||||
}
|
||||
|
||||
private func detailLines(model: Model) -> (primary: String, secondary: String?) {
|
||||
guard let key = self.selectedDayKey,
|
||||
let day = model.breakdownByDayKey[key],
|
||||
let date = Self.dateFromDayKey(key)
|
||||
else {
|
||||
return (L("Hover a bar for details"), nil)
|
||||
}
|
||||
|
||||
let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())
|
||||
let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2)))
|
||||
if day.services.isEmpty {
|
||||
return (String(format: L("%@: %@ credits"), dayLabel, total), nil)
|
||||
}
|
||||
if day.services.count <= 1, let first = day.services.first {
|
||||
let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2)))
|
||||
return (String(format: L("%@: %@ credits"), dayLabel, used), first.service)
|
||||
}
|
||||
|
||||
let services = day.services
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service }
|
||||
return lhs.creditsUsed > rhs.creditsUsed
|
||||
}
|
||||
.prefix(3)
|
||||
.map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" }
|
||||
.joined(separator: " · ")
|
||||
|
||||
return (String(format: L("%@: %@ credits"), dayLabel, total), services)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
/// Opens Cursor in the user's browser and waits until the normal browser-cookie importer can read a session.
|
||||
@MainActor
|
||||
final class CursorLoginRunner {
|
||||
enum Phase {
|
||||
case loading
|
||||
case waitingLogin
|
||||
case success
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
struct Result {
|
||||
enum Outcome {
|
||||
case success
|
||||
case cancelled
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let outcome: Outcome
|
||||
let email: String?
|
||||
}
|
||||
|
||||
typealias SnapshotLoader = @Sendable () async throws -> CursorStatusSnapshot
|
||||
typealias Sleeper = @Sendable (UInt64) async throws -> Void
|
||||
typealias SessionCacheResetter = @Sendable () async -> Void
|
||||
|
||||
private let loadSnapshot: SnapshotLoader
|
||||
private let openURL: @MainActor (URL) -> Bool
|
||||
private let sleeper: Sleeper
|
||||
private let resetSessionCache: SessionCacheResetter
|
||||
private let timeout: TimeInterval
|
||||
private let pollInterval: TimeInterval
|
||||
private let logger = CodexBarLog.logger(LogCategories.cursorLogin)
|
||||
|
||||
static let authURL = URL(string: "https://authenticator.cursor.sh/")!
|
||||
|
||||
init(
|
||||
browserDetection: BrowserDetection,
|
||||
timeout: TimeInterval = 120,
|
||||
pollInterval: TimeInterval = 2,
|
||||
openURL: @escaping @MainActor (URL) -> Bool = { NSWorkspace.shared.open($0) },
|
||||
loadSnapshot: SnapshotLoader? = nil,
|
||||
sleeper: @escaping Sleeper = { try await Task.sleep(nanoseconds: $0) },
|
||||
resetSessionCache: @escaping SessionCacheResetter = {
|
||||
CookieHeaderCache.clear(provider: .cursor)
|
||||
CursorSessionStore.shared.clearCookies()
|
||||
})
|
||||
{
|
||||
self.timeout = timeout
|
||||
self.pollInterval = pollInterval
|
||||
self.openURL = openURL
|
||||
self.sleeper = sleeper
|
||||
self.resetSessionCache = resetSessionCache
|
||||
self.loadSnapshot = loadSnapshot ?? {
|
||||
let probe = CursorStatusProbe(browserDetection: browserDetection)
|
||||
return try await probe.fetch(
|
||||
allowCachedSessions: false,
|
||||
allowAppAuthFallback: false)
|
||||
}
|
||||
}
|
||||
|
||||
func run(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result {
|
||||
onPhaseChange(.loading)
|
||||
self.logger.info("Cursor login started")
|
||||
await self.resetSessionCache()
|
||||
|
||||
guard self.openURL(Self.authURL) else {
|
||||
let message = L("Could not open Cursor login in your browser.")
|
||||
onPhaseChange(.failed(message))
|
||||
self.logger.error("Cursor login browser launch failed")
|
||||
return Result(outcome: .failed(message), email: nil)
|
||||
}
|
||||
|
||||
onPhaseChange(.waitingLogin)
|
||||
let deadline = Date().addingTimeInterval(self.timeout)
|
||||
var lastError: Error?
|
||||
|
||||
repeat {
|
||||
if Task.isCancelled {
|
||||
self.logger.info("Cursor login cancelled")
|
||||
return Result(outcome: .cancelled, email: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let snapshot = try await self.loadSnapshot()
|
||||
onPhaseChange(.success)
|
||||
self.logger.info("Cursor login completed", metadata: ["outcome": "success"])
|
||||
return Result(outcome: .success, email: snapshot.accountEmail)
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
|
||||
guard Date() < deadline else { break }
|
||||
let delay = UInt64(max(0.1, self.pollInterval) * 1_000_000_000)
|
||||
try? await self.sleeper(delay)
|
||||
} while true
|
||||
|
||||
let message = Self.timeoutMessage(lastError: lastError)
|
||||
onPhaseChange(.failed(message))
|
||||
self.logger.warning("Cursor login timed out", metadata: ["error": message])
|
||||
return Result(outcome: .failed(message), email: nil)
|
||||
}
|
||||
|
||||
private static func timeoutMessage(lastError: Error?) -> String {
|
||||
let hint = L("Sign in to cursor.com in your browser, then refresh Cursor in CodexBar.")
|
||||
guard let lastError else {
|
||||
return String(format: L("Timed out waiting for Cursor login. %@"), hint)
|
||||
}
|
||||
return String(
|
||||
format: L("Timed out waiting for Cursor login. %@ Last error: %@"),
|
||||
hint,
|
||||
lastError.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
enum RelativeTimeFormatters {
|
||||
@MainActor
|
||||
static func full(locale: Locale) -> RelativeDateTimeFormatter {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.unitsStyle = .full
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
@MainActor
|
||||
func relativeDescription(now: Date = .now) -> String {
|
||||
let seconds = abs(now.timeIntervalSince(self))
|
||||
if seconds < 15 {
|
||||
return L("just now")
|
||||
}
|
||||
let locale = codexBarLocalizedLocale()
|
||||
return RelativeTimeFormatters.full(locale: locale).localizedString(for: self, relativeTo: now)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import AppKit
|
||||
import CoreVideo
|
||||
import Observation
|
||||
import QuartzCore
|
||||
|
||||
/// Minimal display link driver using NSScreen.displayLink on macOS 15+,
|
||||
/// and CVDisplayLink on macOS 14.
|
||||
/// Publishes ticks on the main thread at the requested frame rate.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DisplayLinkDriver {
|
||||
// Published counter used to drive SwiftUI updates.
|
||||
var tick: Int = 0
|
||||
private var displayLink: CADisplayLink?
|
||||
private var cvDisplayLink: CVDisplayLink?
|
||||
private var targetInterval: CFTimeInterval = 1.0 / 60.0
|
||||
private var lastTickTimestamp: CFTimeInterval = 0
|
||||
private let onTick: (() -> Void)?
|
||||
|
||||
init(onTick: (() -> Void)? = nil) {
|
||||
self.onTick = onTick
|
||||
}
|
||||
|
||||
func start(fps: Double = 12) {
|
||||
guard self.displayLink == nil, self.cvDisplayLink == nil else { return }
|
||||
let clampedFps = max(fps, 1)
|
||||
self.targetInterval = 1.0 / clampedFps
|
||||
self.lastTickTimestamp = 0
|
||||
if #available(macOS 15, *), let screen = NSScreen.main {
|
||||
// NSScreen.displayLink is macOS 15+ only.
|
||||
let displayLink = screen.displayLink(target: self, selector: #selector(self.step))
|
||||
let rate = Float(clampedFps)
|
||||
displayLink.preferredFrameRateRange = CAFrameRateRange(
|
||||
minimum: rate,
|
||||
maximum: rate,
|
||||
preferred: rate)
|
||||
displayLink.add(to: .main, forMode: .common)
|
||||
self.displayLink = displayLink
|
||||
} else {
|
||||
self.startCVDisplayLink()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.displayLink?.invalidate()
|
||||
self.displayLink = nil
|
||||
if let cvDisplayLink = self.cvDisplayLink {
|
||||
CVDisplayLinkStop(cvDisplayLink)
|
||||
}
|
||||
self.cvDisplayLink = nil
|
||||
}
|
||||
|
||||
@objc private func step(_: AnyObject) {
|
||||
self.handleTick()
|
||||
}
|
||||
|
||||
private func handleTick() {
|
||||
let now = CACurrentMediaTime()
|
||||
if self.lastTickTimestamp > 0, now - self.lastTickTimestamp < self.targetInterval {
|
||||
return
|
||||
}
|
||||
self.lastTickTimestamp = now
|
||||
// Safe on main runloop; drives SwiftUI updates.
|
||||
self.tick &+= 1
|
||||
self.onTick?()
|
||||
}
|
||||
|
||||
private func startCVDisplayLink() {
|
||||
var link: CVDisplayLink?
|
||||
if CVDisplayLinkCreateWithActiveCGDisplays(&link) != kCVReturnSuccess {
|
||||
return
|
||||
}
|
||||
guard let link else { return }
|
||||
let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, userInfo in
|
||||
guard let userInfo else { return kCVReturnSuccess }
|
||||
let driver = Unmanaged<DisplayLinkDriver>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
driver.scheduleTick()
|
||||
return kCVReturnSuccess
|
||||
}
|
||||
CVDisplayLinkSetOutputCallback(link, callback, Unmanaged.passUnretained(self).toOpaque())
|
||||
CVDisplayLinkStart(link)
|
||||
self.cvDisplayLink = link
|
||||
}
|
||||
|
||||
private nonisolated func scheduleTick() {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleTick()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum GeminiLoginRunner {
|
||||
private static let geminiConfigDir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".gemini")
|
||||
private static let credentialsFile = "oauth_creds.json"
|
||||
|
||||
private static func clearCredentials() {
|
||||
let fm = FileManager.default
|
||||
let filesToDelete = [credentialsFile, "google_accounts.json"]
|
||||
for file in filesToDelete {
|
||||
let path = self.geminiConfigDir.appendingPathComponent(file)
|
||||
try? fm.removeItem(at: path)
|
||||
}
|
||||
}
|
||||
|
||||
struct Result {
|
||||
enum Outcome {
|
||||
case success
|
||||
case missingBinary
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
let outcome: Outcome
|
||||
}
|
||||
|
||||
static func run(onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result {
|
||||
await Task(priority: .userInitiated) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let binary = BinaryLocator.resolveGeminiBinary(
|
||||
env: env,
|
||||
loginPATH: LoginShellPathCache.shared.current)
|
||||
else {
|
||||
return Result(outcome: .missingBinary)
|
||||
}
|
||||
|
||||
// Clear existing credentials before auth (enables clean account switch)
|
||||
Self.clearCredentials()
|
||||
|
||||
// Start watching for credentials file to be created
|
||||
if let callback = onCredentialsCreated {
|
||||
Self.watchForCredentials(callback: callback)
|
||||
}
|
||||
|
||||
// Create a temporary shell script that runs gemini (auto-prompts for auth when no creds)
|
||||
let scriptContent = """
|
||||
#!/bin/bash
|
||||
cd ~
|
||||
"\(binary)"
|
||||
"""
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let scriptURL = tempDir.appendingPathComponent("gemini_login_\(UUID().uuidString).command")
|
||||
|
||||
do {
|
||||
try scriptContent.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
|
||||
|
||||
let config = NSWorkspace.OpenConfiguration()
|
||||
config.activates = true
|
||||
try await NSWorkspace.shared.open(scriptURL, configuration: config)
|
||||
|
||||
// Clean up script after Terminal has time to read it
|
||||
let scriptPath = scriptURL.path
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 10) {
|
||||
try? FileManager.default.removeItem(atPath: scriptPath)
|
||||
}
|
||||
|
||||
return Result(outcome: .success)
|
||||
} catch {
|
||||
return Result(outcome: .launchFailed(error.localizedDescription))
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Watch for credentials file to be created, then call callback once
|
||||
private static func watchForCredentials(callback: @escaping @Sendable () -> Void, timeout: TimeInterval = 300) {
|
||||
let credsPath = self.geminiConfigDir.appendingPathComponent(self.credentialsFile).path
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let startTime = Date()
|
||||
while Date().timeIntervalSince(startTime) < timeout {
|
||||
if FileManager.default.fileExists(atPath: credsPath) {
|
||||
// Small delay to ensure file is fully written
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
callback()
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HiddenWindowView: View {
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: 20, height: 20)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .codexbarOpenSettings)) { _ in
|
||||
Task { @MainActor in
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Migrate keychain items to reduce permission prompts during development (runs off main thread)
|
||||
await Task.detached(priority: .userInitiated) {
|
||||
KeychainMigration.migrateIfNeeded()
|
||||
}.value
|
||||
}
|
||||
.onAppear {
|
||||
if let window = NSApp.windows.first(where: { $0.title == "CodexBarLifecycleKeepalive" }) {
|
||||
// Make the keepalive window truly invisible and non-interactive.
|
||||
window.styleMask = [.borderless]
|
||||
window.collectionBehavior = [.auxiliary, .ignoresCycle, .transient, .canJoinAllSpaces]
|
||||
window.isExcludedFromWindowsMenu = true
|
||||
window.level = .floating
|
||||
window.isOpaque = false
|
||||
window.alphaValue = 0
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = false
|
||||
window.ignoresMouseEvents = true
|
||||
window.canHide = false
|
||||
window.setContentSize(NSSize(width: 1, height: 1))
|
||||
window.setFrameOrigin(NSPoint(x: -5000, y: -5000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum HistoricalUsageWindowKind: String, Codable {
|
||||
case secondary
|
||||
}
|
||||
|
||||
enum HistoricalUsageRecordSource: String, Codable {
|
||||
case live
|
||||
case backfill
|
||||
}
|
||||
|
||||
struct HistoricalUsageRecord: Codable {
|
||||
let v: Int
|
||||
let provider: UsageProvider
|
||||
let windowKind: HistoricalUsageWindowKind
|
||||
let source: HistoricalUsageRecordSource
|
||||
let accountKey: String?
|
||||
let sampledAt: Date
|
||||
let usedPercent: Double
|
||||
let resetsAt: Date
|
||||
let windowMinutes: Int
|
||||
|
||||
init(
|
||||
v: Int,
|
||||
provider: UsageProvider,
|
||||
windowKind: HistoricalUsageWindowKind,
|
||||
source: HistoricalUsageRecordSource,
|
||||
accountKey: String?,
|
||||
sampledAt: Date,
|
||||
usedPercent: Double,
|
||||
resetsAt: Date,
|
||||
windowMinutes: Int)
|
||||
{
|
||||
self.v = v
|
||||
self.provider = provider
|
||||
self.windowKind = windowKind
|
||||
self.source = source
|
||||
self.accountKey = accountKey
|
||||
self.sampledAt = sampledAt
|
||||
self.usedPercent = usedPercent
|
||||
self.resetsAt = resetsAt
|
||||
self.windowMinutes = windowMinutes
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.v = try container.decodeIfPresent(Int.self, forKey: .v) ?? 1
|
||||
self.provider = try container.decode(UsageProvider.self, forKey: .provider)
|
||||
self.windowKind = try container.decode(HistoricalUsageWindowKind.self, forKey: .windowKind)
|
||||
self.source = try container.decodeIfPresent(HistoricalUsageRecordSource.self, forKey: .source) ?? .live
|
||||
self.accountKey = try container.decodeIfPresent(String.self, forKey: .accountKey)
|
||||
self.sampledAt = try container.decode(Date.self, forKey: .sampledAt)
|
||||
self.usedPercent = try container.decode(Double.self, forKey: .usedPercent)
|
||||
self.resetsAt = try container.decode(Date.self, forKey: .resetsAt)
|
||||
self.windowMinutes = try container.decode(Int.self, forKey: .windowMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoricalWeekProfile {
|
||||
let resetsAt: Date
|
||||
let windowMinutes: Int
|
||||
let curve: [Double]
|
||||
}
|
||||
|
||||
struct CodexHistoricalDataset {
|
||||
static let gridPointCount = 169
|
||||
let weeks: [HistoricalWeekProfile]
|
||||
}
|
||||
|
||||
actor HistoricalUsageHistoryStore {
|
||||
private static let schemaVersion = 1
|
||||
private static let writeInterval: TimeInterval = 30 * 60
|
||||
private static let writeDeltaThreshold: Double = 1
|
||||
private static let retentionDays: TimeInterval = 56 * 24 * 60 * 60
|
||||
private static let minimumWeekSamples = 6
|
||||
private static let boundaryCoverageWindow: TimeInterval = 24 * 60 * 60
|
||||
private static let backfillWindowCapWeeks = 8
|
||||
private static let backfillCalibrationMinimumUsedPercent = 1.0
|
||||
private static let backfillCalibrationMinimumCredits = 0.001
|
||||
private static let backfillSampleFractions: [Double] = (0...14).map { Double($0) / 14.0 }
|
||||
private static let coverageTolerance: TimeInterval = 16 * 60 * 60
|
||||
private static let resetBucketSeconds: TimeInterval = 5 * 60
|
||||
|
||||
private let fileURL: URL
|
||||
private var records: [HistoricalUsageRecord] = []
|
||||
private var loaded = false
|
||||
|
||||
init(fileURL: URL? = nil) {
|
||||
self.fileURL = fileURL ?? HistoricalUsageHistoryStore.defaultFileURL()
|
||||
}
|
||||
|
||||
func loadCodexDataset(accountKey: String?) -> CodexHistoricalDataset? {
|
||||
self.ensureLoaded()
|
||||
return self.buildDataset(accountKey: accountKey)
|
||||
}
|
||||
|
||||
func loadCodexDataset(
|
||||
canonicalAccountKey: String?,
|
||||
canonicalEmailHashKey: String?,
|
||||
legacyEmailHash: String?,
|
||||
hasAdjacentMultiAccountVeto: Bool) -> CodexHistoricalDataset?
|
||||
{
|
||||
self.ensureLoaded()
|
||||
return self.buildDataset(
|
||||
canonicalAccountKey: canonicalAccountKey,
|
||||
canonicalEmailHashKey: canonicalEmailHashKey,
|
||||
legacyEmailHash: legacyEmailHash,
|
||||
hasAdjacentMultiAccountVeto: hasAdjacentMultiAccountVeto)
|
||||
}
|
||||
|
||||
func recordCodexWeekly(
|
||||
window: RateWindow,
|
||||
sampledAt: Date = .init(),
|
||||
accountKey: String?) -> CodexHistoricalDataset?
|
||||
{
|
||||
guard let rawResetsAt = window.resetsAt else { return self.loadCodexDataset(accountKey: accountKey) }
|
||||
guard let windowMinutes = window.windowMinutes, windowMinutes > 0 else {
|
||||
return self.loadCodexDataset(accountKey: accountKey)
|
||||
}
|
||||
self.ensureLoaded()
|
||||
let resetsAt = Self.normalizeReset(rawResetsAt)
|
||||
|
||||
let sample = HistoricalUsageRecord(
|
||||
v: Self.schemaVersion,
|
||||
provider: .codex,
|
||||
windowKind: .secondary,
|
||||
source: .live,
|
||||
accountKey: accountKey,
|
||||
sampledAt: sampledAt,
|
||||
usedPercent: Self.clamp(window.usedPercent, lower: 0, upper: 100),
|
||||
resetsAt: resetsAt,
|
||||
windowMinutes: windowMinutes)
|
||||
|
||||
if !self.shouldAccept(sample) {
|
||||
return self.buildDataset(accountKey: accountKey)
|
||||
}
|
||||
|
||||
self.records.append(sample)
|
||||
self.pruneOldRecords(now: sampledAt)
|
||||
self.records.sort { lhs, rhs in
|
||||
if lhs.sampledAt == rhs.sampledAt {
|
||||
if lhs.resetsAt == rhs.resetsAt {
|
||||
return lhs.usedPercent < rhs.usedPercent
|
||||
}
|
||||
return lhs.resetsAt < rhs.resetsAt
|
||||
}
|
||||
return lhs.sampledAt < rhs.sampledAt
|
||||
}
|
||||
self.persist()
|
||||
return self.buildDataset(accountKey: accountKey)
|
||||
}
|
||||
|
||||
func backfillCodexWeeklyFromUsageBreakdown(
|
||||
_ breakdown: [OpenAIDashboardDailyBreakdown],
|
||||
referenceWindow: RateWindow,
|
||||
now: Date = .init(),
|
||||
accountKey: String?) -> CodexHistoricalDataset?
|
||||
{
|
||||
self.ensureLoaded()
|
||||
let existingDataset = self.buildDataset(accountKey: accountKey)
|
||||
|
||||
guard let rawResetsAt = referenceWindow.resetsAt else { return existingDataset }
|
||||
guard let windowMinutes = referenceWindow.windowMinutes, windowMinutes > 0 else { return existingDataset }
|
||||
let resetsAt = Self.normalizeReset(rawResetsAt)
|
||||
|
||||
let duration = TimeInterval(windowMinutes) * 60
|
||||
guard duration > 0 else { return existingDataset }
|
||||
|
||||
let windowStart = resetsAt.addingTimeInterval(-duration)
|
||||
let calibrationEnd = Self.clampDate(now, lower: windowStart, upper: resetsAt)
|
||||
let dayUsages = Self.parseDayUsages(
|
||||
from: breakdown,
|
||||
asOf: calibrationEnd,
|
||||
fillingFrom: windowStart)
|
||||
guard !dayUsages.isEmpty else { return existingDataset }
|
||||
guard let coverageStart = dayUsages.first?.start, let coverageEnd = dayUsages.last?.end else {
|
||||
return existingDataset
|
||||
}
|
||||
guard coverageStart <= windowStart.addingTimeInterval(Self.coverageTolerance) else {
|
||||
return existingDataset
|
||||
}
|
||||
guard coverageEnd >= calibrationEnd.addingTimeInterval(-Self.coverageTolerance) else {
|
||||
return existingDataset
|
||||
}
|
||||
|
||||
let currentUsedPercent = Self.clamp(referenceWindow.usedPercent, lower: 0, upper: 100)
|
||||
guard currentUsedPercent >= Self.backfillCalibrationMinimumUsedPercent else { return existingDataset }
|
||||
|
||||
let currentCredits = Self.creditsUsed(
|
||||
from: dayUsages,
|
||||
between: windowStart,
|
||||
and: calibrationEnd)
|
||||
guard currentCredits > Self.backfillCalibrationMinimumCredits else { return existingDataset }
|
||||
|
||||
let estimatedCreditsAtLimit = currentCredits / (currentUsedPercent / 100)
|
||||
guard estimatedCreditsAtLimit.isFinite, estimatedCreditsAtLimit > Self.backfillCalibrationMinimumCredits else {
|
||||
return existingDataset
|
||||
}
|
||||
|
||||
struct RecordKey: Hashable {
|
||||
let resetsAt: Date
|
||||
let sampledAt: Date
|
||||
let windowMinutes: Int
|
||||
let accountKey: String?
|
||||
}
|
||||
|
||||
var synthesized: [HistoricalUsageRecord] = []
|
||||
synthesized.reserveCapacity(Self.backfillWindowCapWeeks * Self.backfillSampleFractions.count)
|
||||
|
||||
for weeksBack in 1...Self.backfillWindowCapWeeks {
|
||||
let reset = Self.normalizeReset(resetsAt.addingTimeInterval(-duration * Double(weeksBack)))
|
||||
let start = reset.addingTimeInterval(-duration)
|
||||
guard start >= coverageStart.addingTimeInterval(-Self.coverageTolerance),
|
||||
reset <= coverageEnd.addingTimeInterval(Self.coverageTolerance)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let existingForWeek = self.records.filter {
|
||||
$0.provider == .codex &&
|
||||
$0.windowKind == .secondary &&
|
||||
$0.windowMinutes == windowMinutes &&
|
||||
$0.accountKey == accountKey &&
|
||||
$0.resetsAt == reset
|
||||
}
|
||||
if Self.isCompleteWeek(samples: existingForWeek, windowStart: start, resetsAt: reset) {
|
||||
continue
|
||||
}
|
||||
var existingRecordKeys = Set(existingForWeek.map {
|
||||
RecordKey(
|
||||
resetsAt: $0.resetsAt,
|
||||
sampledAt: $0.sampledAt,
|
||||
windowMinutes: $0.windowMinutes,
|
||||
accountKey: $0.accountKey)
|
||||
})
|
||||
|
||||
let weekCredits = Self.creditsUsed(from: dayUsages, between: start, and: reset)
|
||||
guard weekCredits > Self.backfillCalibrationMinimumCredits else { continue }
|
||||
|
||||
for fraction in Self.backfillSampleFractions {
|
||||
let sampledAt = start.addingTimeInterval(duration * fraction)
|
||||
let recordKey = RecordKey(
|
||||
resetsAt: reset,
|
||||
sampledAt: sampledAt,
|
||||
windowMinutes: windowMinutes,
|
||||
accountKey: accountKey)
|
||||
guard !existingRecordKeys.contains(recordKey) else { continue }
|
||||
let cumulativeCredits = Self.creditsUsed(from: dayUsages, between: start, and: sampledAt)
|
||||
let usedPercent = Self.clamp((cumulativeCredits / estimatedCreditsAtLimit) * 100, lower: 0, upper: 100)
|
||||
synthesized.append(HistoricalUsageRecord(
|
||||
v: Self.schemaVersion,
|
||||
provider: .codex,
|
||||
windowKind: .secondary,
|
||||
source: .backfill,
|
||||
accountKey: accountKey,
|
||||
sampledAt: sampledAt,
|
||||
usedPercent: usedPercent,
|
||||
resetsAt: reset,
|
||||
windowMinutes: windowMinutes))
|
||||
existingRecordKeys.insert(recordKey)
|
||||
}
|
||||
}
|
||||
|
||||
guard !synthesized.isEmpty else { return existingDataset }
|
||||
self.records.append(contentsOf: synthesized)
|
||||
self.pruneOldRecords(now: now)
|
||||
self.records.sort { lhs, rhs in
|
||||
if lhs.sampledAt == rhs.sampledAt {
|
||||
if lhs.resetsAt == rhs.resetsAt {
|
||||
return lhs.usedPercent < rhs.usedPercent
|
||||
}
|
||||
return lhs.resetsAt < rhs.resetsAt
|
||||
}
|
||||
return lhs.sampledAt < rhs.sampledAt
|
||||
}
|
||||
self.persist()
|
||||
return self.buildDataset(accountKey: accountKey)
|
||||
}
|
||||
|
||||
private func shouldAccept(_ sample: HistoricalUsageRecord) -> Bool {
|
||||
guard let prior = self.records
|
||||
.last(where: {
|
||||
$0.provider == sample.provider &&
|
||||
$0.windowKind == sample.windowKind &&
|
||||
$0.accountKey == sample.accountKey &&
|
||||
$0.windowMinutes == sample.windowMinutes
|
||||
})
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
if prior.resetsAt != sample.resetsAt { return true }
|
||||
if sample.sampledAt.timeIntervalSince(prior.sampledAt) >= Self.writeInterval { return true }
|
||||
if abs(sample.usedPercent - prior.usedPercent) >= Self.writeDeltaThreshold { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func pruneOldRecords(now: Date) {
|
||||
let cutoff = now.addingTimeInterval(-Self.retentionDays)
|
||||
self.records.removeAll { $0.sampledAt < cutoff }
|
||||
}
|
||||
|
||||
private func ensureLoaded() {
|
||||
guard !self.loaded else { return }
|
||||
self.loaded = true
|
||||
self.records = self.readRecordsFromDisk()
|
||||
self.pruneOldRecords(now: .init())
|
||||
}
|
||||
|
||||
private func readRecordsFromDisk() -> [HistoricalUsageRecord] {
|
||||
guard let data = try? Data(contentsOf: self.fileURL), !data.isEmpty else { return [] }
|
||||
guard let text = String(data: data, encoding: .utf8) else { return [] }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
var decoded: [HistoricalUsageRecord] = []
|
||||
decoded.reserveCapacity(text.count / 80)
|
||||
|
||||
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty, let lineData = line.data(using: .utf8) else { continue }
|
||||
guard var record = try? decoder.decode(HistoricalUsageRecord.self, from: lineData) else { continue }
|
||||
record = HistoricalUsageRecord(
|
||||
v: record.v,
|
||||
provider: record.provider,
|
||||
windowKind: record.windowKind,
|
||||
source: record.source,
|
||||
accountKey: record.accountKey?.isEmpty == false ? record.accountKey : nil,
|
||||
sampledAt: record.sampledAt,
|
||||
usedPercent: Self.clamp(record.usedPercent, lower: 0, upper: 100),
|
||||
resetsAt: Self.normalizeReset(record.resetsAt),
|
||||
windowMinutes: record.windowMinutes)
|
||||
decoded.append(record)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
|
||||
var lines: [String] = []
|
||||
lines.reserveCapacity(self.records.count)
|
||||
for record in self.records {
|
||||
guard let data = try? encoder.encode(record),
|
||||
let line = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
lines.append(line)
|
||||
}
|
||||
|
||||
let payload = (lines.joined(separator: "\n") + "\n").data(using: .utf8) ?? Data()
|
||||
let directory = self.fileURL.deletingLastPathComponent()
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try payload.write(to: self.fileURL, options: [.atomic])
|
||||
} catch {
|
||||
// Best-effort cache file; ignore write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private func buildDataset(accountKey: String?) -> CodexHistoricalDataset? {
|
||||
let scoped = self.records.filter { record in
|
||||
guard Self.isCodexSecondaryRecord(record) else { return false }
|
||||
if let accountKey {
|
||||
return record.accountKey == accountKey
|
||||
}
|
||||
return record.accountKey == nil
|
||||
}
|
||||
return self.buildDataset(from: scoped)
|
||||
}
|
||||
|
||||
private func buildDataset(
|
||||
canonicalAccountKey: String?,
|
||||
canonicalEmailHashKey: String?,
|
||||
legacyEmailHash: String?,
|
||||
hasAdjacentMultiAccountVeto: Bool) -> CodexHistoricalDataset?
|
||||
{
|
||||
guard let canonicalAccountKey else {
|
||||
return self.buildDataset(accountKey: nil)
|
||||
}
|
||||
|
||||
let shouldIncludeUnscoped = CodexHistoryOwnership.hasStrictSingleAccountContinuity(
|
||||
scopedRawKeys: Self.scopedRawKeysRelevantToCodexUnscopedHistory(self.records),
|
||||
targetCanonicalKey: canonicalAccountKey,
|
||||
canonicalEmailHashKey: canonicalEmailHashKey,
|
||||
legacyEmailHash: legacyEmailHash,
|
||||
hasAdjacentMultiAccountVeto: hasAdjacentMultiAccountVeto)
|
||||
|
||||
let scoped = self.records.filter { record in
|
||||
guard Self.isCodexSecondaryRecord(record) else { return false }
|
||||
guard let rawKey = record.accountKey else {
|
||||
return shouldIncludeUnscoped
|
||||
}
|
||||
|
||||
let owner = CodexHistoryOwnership.classifyPersistedKey(rawKey, legacyEmailHash: legacyEmailHash)
|
||||
return CodexHistoryOwnership.belongsToTargetContinuity(
|
||||
owner,
|
||||
targetCanonicalKey: canonicalAccountKey,
|
||||
canonicalEmailHashKey: canonicalEmailHashKey)
|
||||
}
|
||||
return self.buildDataset(from: scoped)
|
||||
}
|
||||
|
||||
private func buildDataset(from scoped: [HistoricalUsageRecord]) -> CodexHistoricalDataset? {
|
||||
struct WeekKey: Hashable {
|
||||
let resetsAt: Date
|
||||
let windowMinutes: Int
|
||||
}
|
||||
|
||||
if scoped.isEmpty { return nil }
|
||||
|
||||
let grouped = Dictionary(grouping: scoped) {
|
||||
WeekKey(resetsAt: $0.resetsAt, windowMinutes: $0.windowMinutes)
|
||||
}
|
||||
|
||||
var weeks: [HistoricalWeekProfile] = []
|
||||
weeks.reserveCapacity(grouped.count)
|
||||
|
||||
for (key, samples) in grouped {
|
||||
let duration = TimeInterval(key.windowMinutes) * 60
|
||||
guard duration > 0 else { continue }
|
||||
let windowStart = key.resetsAt.addingTimeInterval(-duration)
|
||||
guard Self.isCompleteWeek(samples: samples, windowStart: windowStart, resetsAt: key.resetsAt) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let curve = Self.reconstructWeekCurve(
|
||||
samples: samples,
|
||||
windowStart: windowStart,
|
||||
windowDuration: duration,
|
||||
gridPointCount: CodexHistoricalDataset.gridPointCount)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
weeks.append(HistoricalWeekProfile(
|
||||
resetsAt: key.resetsAt,
|
||||
windowMinutes: key.windowMinutes,
|
||||
curve: curve))
|
||||
}
|
||||
|
||||
weeks.sort { $0.resetsAt < $1.resetsAt }
|
||||
if weeks.isEmpty { return nil }
|
||||
return CodexHistoricalDataset(weeks: weeks)
|
||||
}
|
||||
|
||||
private nonisolated static func isCodexSecondaryRecord(_ record: HistoricalUsageRecord) -> Bool {
|
||||
record.provider == .codex && record.windowKind == .secondary && record.windowMinutes > 0
|
||||
}
|
||||
|
||||
private static func reconstructWeekCurve(
|
||||
samples: [HistoricalUsageRecord],
|
||||
windowStart: Date,
|
||||
windowDuration: TimeInterval,
|
||||
gridPointCount: Int) -> [Double]?
|
||||
{
|
||||
guard gridPointCount >= 2 else { return nil }
|
||||
|
||||
var points = samples.map { sample -> (u: Double, value: Double) in
|
||||
let offset = sample.sampledAt.timeIntervalSince(windowStart)
|
||||
let u = Self.clamp(offset / windowDuration, lower: 0, upper: 1)
|
||||
return (u: u, value: Self.clamp(sample.usedPercent, lower: 0, upper: 100))
|
||||
}
|
||||
points.sort { lhs, rhs in
|
||||
if lhs.u == rhs.u {
|
||||
return lhs.value < rhs.value
|
||||
}
|
||||
return lhs.u < rhs.u
|
||||
}
|
||||
|
||||
guard !points.isEmpty else { return nil }
|
||||
|
||||
// Enforce monotonicity on observed samples before interpolation.
|
||||
var monotonePoints: [(u: Double, value: Double)] = []
|
||||
monotonePoints.reserveCapacity(points.count)
|
||||
var runningMax = 0.0
|
||||
for point in points {
|
||||
runningMax = max(runningMax, point.value)
|
||||
monotonePoints.append((u: point.u, value: runningMax))
|
||||
}
|
||||
|
||||
// Anchor reconstructed curves to reset start and end-of-week plateau.
|
||||
let endValue = monotonePoints.last?.value ?? 0
|
||||
monotonePoints.append((u: 0, value: 0))
|
||||
monotonePoints.append((u: 1, value: endValue))
|
||||
monotonePoints.sort { lhs, rhs in
|
||||
if lhs.u == rhs.u {
|
||||
return lhs.value < rhs.value
|
||||
}
|
||||
return lhs.u < rhs.u
|
||||
}
|
||||
runningMax = 0
|
||||
for index in monotonePoints.indices {
|
||||
runningMax = max(runningMax, monotonePoints[index].value)
|
||||
monotonePoints[index].value = runningMax
|
||||
}
|
||||
|
||||
var curve = Array(repeating: 0.0, count: gridPointCount)
|
||||
let first = monotonePoints[0]
|
||||
let last = monotonePoints[monotonePoints.count - 1]
|
||||
|
||||
var upperIndex = 1
|
||||
let denominator = Double(gridPointCount - 1)
|
||||
|
||||
for index in 0..<gridPointCount {
|
||||
let u = Double(index) / denominator
|
||||
if u <= first.u {
|
||||
curve[index] = first.value
|
||||
continue
|
||||
}
|
||||
if u >= last.u {
|
||||
curve[index] = last.value
|
||||
continue
|
||||
}
|
||||
|
||||
while upperIndex < monotonePoints.count, monotonePoints[upperIndex].u < u {
|
||||
upperIndex += 1
|
||||
}
|
||||
|
||||
let hi = monotonePoints[min(upperIndex, monotonePoints.count - 1)]
|
||||
let lo = monotonePoints[max(0, upperIndex - 1)]
|
||||
if hi.u <= lo.u {
|
||||
curve[index] = max(lo.value, hi.value)
|
||||
continue
|
||||
}
|
||||
|
||||
let ratio = Self.clamp((u - lo.u) / (hi.u - lo.u), lower: 0, upper: 1)
|
||||
curve[index] = lo.value + (hi.value - lo.value) * ratio
|
||||
}
|
||||
|
||||
// Re-enforce monotonicity on reconstructed grid.
|
||||
var curveMax = 0.0
|
||||
for index in curve.indices {
|
||||
curve[index] = Self.clamp(curve[index], lower: 0, upper: 100)
|
||||
curveMax = max(curveMax, curve[index])
|
||||
curve[index] = curveMax
|
||||
}
|
||||
return curve
|
||||
}
|
||||
|
||||
private static func isCompleteWeek(samples: [HistoricalUsageRecord], windowStart: Date, resetsAt: Date) -> Bool {
|
||||
guard samples.count >= self.minimumWeekSamples else { return false }
|
||||
let startBoundary = windowStart.addingTimeInterval(Self.boundaryCoverageWindow)
|
||||
let endBoundary = resetsAt.addingTimeInterval(-Self.boundaryCoverageWindow)
|
||||
let hasStartCoverage = samples.contains { sample in
|
||||
sample.sampledAt >= windowStart && sample.sampledAt <= startBoundary
|
||||
}
|
||||
let hasEndCoverage = samples.contains { sample in
|
||||
sample.sampledAt >= endBoundary && sample.sampledAt <= resetsAt
|
||||
}
|
||||
return hasStartCoverage && hasEndCoverage
|
||||
}
|
||||
|
||||
private nonisolated static func scopedRawKeysRelevantToCodexUnscopedHistory(
|
||||
_ records: [HistoricalUsageRecord]) -> [String]
|
||||
{
|
||||
let unscopedRecords = records.filter { record in
|
||||
Self.isCodexSecondaryRecord(record) && record.accountKey == nil
|
||||
}
|
||||
guard let continuityWindow = self.historicalContinuityWindow(for: unscopedRecords) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return records.compactMap { record in
|
||||
guard Self.isCodexSecondaryRecord(record),
|
||||
let accountKey = record.accountKey,
|
||||
continuityWindow.contains(record.sampledAt)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return accountKey
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func historicalContinuityWindow(
|
||||
for records: [HistoricalUsageRecord]) -> ClosedRange<Date>?
|
||||
{
|
||||
let sampledDates = records.map(\.sampledAt)
|
||||
guard let lowerBound = sampledDates.min(),
|
||||
let upperBound = sampledDates.max()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let expansion = TimeInterval(records.map(\.windowMinutes).max() ?? 0) * 60
|
||||
return lowerBound.addingTimeInterval(-expansion)...upperBound.addingTimeInterval(expansion)
|
||||
}
|
||||
|
||||
private struct DayUsage {
|
||||
let start: Date
|
||||
let end: Date
|
||||
let creditsUsed: Double
|
||||
}
|
||||
|
||||
private static func parseDayUsages(
|
||||
from breakdown: [OpenAIDashboardDailyBreakdown],
|
||||
asOf: Date,
|
||||
fillingFrom expectedCoverageStart: Date? = nil) -> [DayUsage]
|
||||
{
|
||||
var creditsByStart: [Date: Double] = [:]
|
||||
creditsByStart.reserveCapacity(breakdown.count)
|
||||
|
||||
for day in breakdown {
|
||||
guard let dayStart = Self.dayStart(for: day.day) else { continue }
|
||||
creditsByStart[dayStart, default: 0] += max(0, day.totalCreditsUsed)
|
||||
}
|
||||
|
||||
let calendar = Self.gregorianCalendar()
|
||||
var dayUsages: [DayUsage] = []
|
||||
dayUsages.reserveCapacity(creditsByStart.count)
|
||||
for (dayStart, credits) in creditsByStart {
|
||||
guard let nominalEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { continue }
|
||||
let effectiveEnd: Date = if dayStart <= asOf, asOf < nominalEnd {
|
||||
asOf
|
||||
} else {
|
||||
nominalEnd
|
||||
}
|
||||
guard effectiveEnd > dayStart else { continue }
|
||||
dayUsages.append(DayUsage(start: dayStart, end: effectiveEnd, creditsUsed: credits))
|
||||
}
|
||||
|
||||
dayUsages.sort { lhs, rhs in lhs.start < rhs.start }
|
||||
return Self.fillMissingZeroUsageDays(
|
||||
in: dayUsages,
|
||||
through: asOf,
|
||||
fillingFrom: expectedCoverageStart)
|
||||
}
|
||||
|
||||
private static func fillMissingZeroUsageDays(
|
||||
in dayUsages: [DayUsage],
|
||||
through asOf: Date,
|
||||
fillingFrom expectedCoverageStart: Date? = nil) -> [DayUsage]
|
||||
{
|
||||
guard let firstStart = dayUsages.first?.start else { return [] }
|
||||
|
||||
let calendar = Self.gregorianCalendar()
|
||||
let fillStart: Date = if let expectedCoverageStart {
|
||||
min(firstStart, calendar.startOfDay(for: expectedCoverageStart))
|
||||
} else {
|
||||
firstStart
|
||||
}
|
||||
let finalDayStart = calendar.startOfDay(for: asOf)
|
||||
guard fillStart <= finalDayStart else { return dayUsages }
|
||||
|
||||
let creditsByStart = Dictionary(uniqueKeysWithValues: dayUsages.map { ($0.start, $0.creditsUsed) })
|
||||
let daySpan = max(0, calendar.dateComponents([.day], from: fillStart, to: finalDayStart).day ?? 0)
|
||||
var filled: [DayUsage] = []
|
||||
filled.reserveCapacity(daySpan + 1)
|
||||
|
||||
var cursor = fillStart
|
||||
while cursor <= finalDayStart {
|
||||
guard let nominalEnd = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
let effectiveEnd: Date = if cursor <= asOf, asOf < nominalEnd {
|
||||
asOf
|
||||
} else {
|
||||
nominalEnd
|
||||
}
|
||||
guard effectiveEnd > cursor else { break }
|
||||
filled.append(DayUsage(
|
||||
start: cursor,
|
||||
end: effectiveEnd,
|
||||
creditsUsed: creditsByStart[cursor] ?? 0))
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
|
||||
return filled
|
||||
}
|
||||
|
||||
private static func dayStart(for key: String) -> Date? {
|
||||
let components = key.split(separator: "-", omittingEmptySubsequences: true)
|
||||
guard components.count == 3,
|
||||
let year = Int(components[0]),
|
||||
let month = Int(components[1]),
|
||||
let day = Int(components[2])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let calendar = Self.gregorianCalendar()
|
||||
var dateComponents = DateComponents()
|
||||
dateComponents.calendar = calendar
|
||||
dateComponents.timeZone = calendar.timeZone
|
||||
dateComponents.year = year
|
||||
dateComponents.month = month
|
||||
dateComponents.day = day
|
||||
dateComponents.hour = 0
|
||||
dateComponents.minute = 0
|
||||
dateComponents.second = 0
|
||||
return dateComponents.date
|
||||
}
|
||||
|
||||
private static func creditsUsed(from dayUsages: [DayUsage], between start: Date, and end: Date) -> Double {
|
||||
guard end > start else { return 0 }
|
||||
var total = 0.0
|
||||
for day in dayUsages {
|
||||
if day.end <= start { continue }
|
||||
if day.start >= end { break }
|
||||
let overlapStart = max(day.start, start)
|
||||
let overlapEnd = min(day.end, end)
|
||||
guard overlapEnd > overlapStart else { continue }
|
||||
|
||||
let dayDuration = day.end.timeIntervalSince(day.start)
|
||||
guard dayDuration > 0 else { continue }
|
||||
let overlap = overlapEnd.timeIntervalSince(overlapStart)
|
||||
total += day.creditsUsed * (overlap / dayDuration)
|
||||
}
|
||||
return max(0, total)
|
||||
}
|
||||
|
||||
nonisolated static func defaultFileURL() -> URL {
|
||||
let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser
|
||||
return root
|
||||
.appendingPathComponent("CodexBar", isDirectory: true)
|
||||
.appendingPathComponent("usage-history.jsonl", isDirectory: false)
|
||||
}
|
||||
|
||||
private nonisolated static func clamp(_ value: Double, lower: Double, upper: Double) -> Double {
|
||||
min(upper, max(lower, value))
|
||||
}
|
||||
|
||||
private nonisolated static func clampDate(_ value: Date, lower: Date, upper: Date) -> Date {
|
||||
min(upper, max(lower, value))
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeReset(_ value: Date) -> Date {
|
||||
let bucket = Self.resetBucketSeconds
|
||||
guard bucket > 0 else { return value }
|
||||
let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket
|
||||
return Date(timeIntervalSinceReferenceDate: rounded)
|
||||
}
|
||||
|
||||
private nonisolated static func gregorianCalendar() -> Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone.current
|
||||
return calendar
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
nonisolated static func _dayStartForTesting(_ key: String) -> Date? {
|
||||
self.dayStart(for: key)
|
||||
}
|
||||
|
||||
nonisolated static func _creditsUsedForTesting(
|
||||
breakdown: [OpenAIDashboardDailyBreakdown],
|
||||
asOf: Date,
|
||||
start: Date,
|
||||
end: Date) -> Double
|
||||
{
|
||||
let dayUsages = Self.parseDayUsages(from: breakdown, asOf: asOf)
|
||||
return Self.creditsUsed(from: dayUsages, between: start, and: end)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum CodexHistoricalPaceEvaluator {
|
||||
static let minimumCompleteWeeksForHistorical = 3
|
||||
static let minimumWeeksForRisk = 5
|
||||
private static let recencyTauWeeks: Double = 3
|
||||
private static let epsilon: Double = 1e-9
|
||||
private static let resetBucketSeconds: TimeInterval = 5 * 60
|
||||
|
||||
static func evaluate(window: RateWindow, now: Date, dataset: CodexHistoricalDataset?) -> UsagePace? {
|
||||
guard let dataset else { return nil }
|
||||
guard let resetsAt = window.resetsAt else { return nil }
|
||||
let minutes = window.windowMinutes ?? 10080
|
||||
guard minutes > 0 else { return nil }
|
||||
|
||||
let duration = TimeInterval(minutes) * 60
|
||||
guard duration > 0 else { return nil }
|
||||
|
||||
let timeUntilReset = resetsAt.timeIntervalSince(now)
|
||||
guard timeUntilReset > 0, timeUntilReset <= duration else { return nil }
|
||||
let normalizedResetsAt = Self.normalizeReset(resetsAt)
|
||||
|
||||
let elapsed = Self.clamp(duration - timeUntilReset, lower: 0, upper: duration)
|
||||
let actual = Self.clamp(window.usedPercent, lower: 0, upper: 100)
|
||||
if elapsed == 0, actual > 0 { return nil }
|
||||
|
||||
let uNow = Self.clamp(elapsed / duration, lower: 0, upper: 1)
|
||||
let scopedWeeks = dataset.weeks.filter { week in
|
||||
week.windowMinutes == minutes && week.resetsAt < normalizedResetsAt
|
||||
}
|
||||
guard scopedWeeks.count >= Self.minimumCompleteWeeksForHistorical else { return nil }
|
||||
|
||||
let weightedWeeks = scopedWeeks.map { week in
|
||||
let ageWeeks = Self.clamp(
|
||||
normalizedResetsAt.timeIntervalSince(week.resetsAt) / duration,
|
||||
lower: 0,
|
||||
upper: Double.greatestFiniteMagnitude)
|
||||
let weight = exp(-ageWeeks / Self.recencyTauWeeks)
|
||||
return (week: week, weight: weight)
|
||||
}
|
||||
let totalWeight = weightedWeeks.reduce(0.0) { $0 + $1.weight }
|
||||
guard totalWeight > Self.epsilon else { return nil }
|
||||
|
||||
let totalWeightSquared = weightedWeeks.reduce(0.0) { $0 + ($1.weight * $1.weight) }
|
||||
let nEff = totalWeightSquared > Self.epsilon ? (totalWeight * totalWeight) / totalWeightSquared : 0
|
||||
let lambda = Self.clamp((nEff - 2) / 6, lower: 0, upper: 1)
|
||||
|
||||
let gridCount = CodexHistoricalDataset.gridPointCount
|
||||
let denominator = Double(gridCount - 1)
|
||||
var expectedCurve = Array(repeating: 0.0, count: gridCount)
|
||||
for index in 0..<gridCount {
|
||||
let u = Double(index) / denominator
|
||||
let values = weightedWeeks.map { $0.week.curve[index] }
|
||||
let weights = weightedWeeks.map(\.weight)
|
||||
let historicalMedian = Self.weightedMedian(values: values, weights: weights)
|
||||
let linearBaseline = 100 * u
|
||||
// Historical demand can exceed a sustainable quota pace. Never call that excess a reserve.
|
||||
expectedCurve[index] = Self.clamp(
|
||||
(lambda * historicalMedian) + ((1 - lambda) * linearBaseline),
|
||||
lower: 0,
|
||||
upper: linearBaseline)
|
||||
}
|
||||
|
||||
// Expected cumulative usage should be monotone.
|
||||
var runningExpected = 0.0
|
||||
for index in expectedCurve.indices {
|
||||
runningExpected = max(runningExpected, expectedCurve[index])
|
||||
expectedCurve[index] = runningExpected
|
||||
}
|
||||
|
||||
let expectedNow = Self.interpolate(curve: expectedCurve, at: uNow)
|
||||
|
||||
var weightedRunOutMass = 0.0
|
||||
var crossingCandidates: [(etaSeconds: TimeInterval, weight: Double)] = []
|
||||
crossingCandidates.reserveCapacity(weightedWeeks.count)
|
||||
|
||||
for weighted in weightedWeeks {
|
||||
var extendedCurve = weighted.week.curve
|
||||
if let capIndex = extendedCurve.firstIndex(where: { $0 >= 100 - Self.epsilon }),
|
||||
capIndex > 0, capIndex < extendedCurve.count - 1
|
||||
{
|
||||
let gridCount = CodexHistoricalDataset.gridPointCount
|
||||
let uCap = Double(capIndex) / Double(gridCount - 1)
|
||||
let valCap = extendedCurve[capIndex]
|
||||
let slope: Double = valCap / uCap
|
||||
for i in capIndex..<extendedCurve.count {
|
||||
let u = Double(i) / Double(gridCount - 1)
|
||||
extendedCurve[i] = slope * u
|
||||
}
|
||||
}
|
||||
|
||||
let weight = weighted.weight
|
||||
let weekNow = Self.interpolate(curve: extendedCurve, at: uNow)
|
||||
let shift = actual - weekNow
|
||||
let shiftedEnd = (extendedCurve.last ?? 0) + shift
|
||||
let runOut = shiftedEnd >= 100 - Self.epsilon
|
||||
if runOut {
|
||||
weightedRunOutMass += weight
|
||||
if let crossingU = Self.firstCrossing(
|
||||
after: uNow,
|
||||
curve: extendedCurve,
|
||||
shift: shift,
|
||||
actualAtNow: actual)
|
||||
{
|
||||
let etaSeconds = max(0, (crossingU - uNow) * duration)
|
||||
crossingCandidates.append((etaSeconds: etaSeconds, weight: weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let smoothedProbability = Self.clamp(
|
||||
(weightedRunOutMass + 0.5) / (totalWeight + 1),
|
||||
lower: 0,
|
||||
upper: 1)
|
||||
var runOutProbability: Double? = scopedWeeks.count >= Self.minimumWeeksForRisk ? smoothedProbability : nil
|
||||
|
||||
var willLastToReset = smoothedProbability < 0.5
|
||||
var etaSeconds: TimeInterval?
|
||||
|
||||
if actual >= 100 {
|
||||
willLastToReset = false
|
||||
etaSeconds = 0
|
||||
runOutProbability = 1
|
||||
} else if !willLastToReset {
|
||||
let values = crossingCandidates.map(\.etaSeconds)
|
||||
let weights = crossingCandidates.map(\.weight)
|
||||
if values.isEmpty {
|
||||
willLastToReset = true
|
||||
} else {
|
||||
etaSeconds = max(0, Self.weightedMedian(values: values, weights: weights))
|
||||
}
|
||||
}
|
||||
|
||||
return UsagePace.historical(
|
||||
expectedUsedPercent: expectedNow,
|
||||
actualUsedPercent: actual,
|
||||
etaSeconds: etaSeconds,
|
||||
willLastToReset: willLastToReset,
|
||||
runOutProbability: runOutProbability,
|
||||
projectedRemainingUsage: max(0, (expectedCurve.last ?? expectedNow) - expectedNow))
|
||||
}
|
||||
|
||||
private static func firstCrossing(
|
||||
after uNow: Double,
|
||||
curve: [Double],
|
||||
shift: Double,
|
||||
actualAtNow: Double) -> Double?
|
||||
{
|
||||
let gridCount = curve.count
|
||||
guard gridCount >= 2 else { return nil }
|
||||
|
||||
let denominator = Double(gridCount - 1)
|
||||
var previousU = uNow
|
||||
var previousValue = actualAtNow
|
||||
|
||||
let startIndex = min(gridCount - 1, max(1, Int(floor(uNow * denominator)) + 1))
|
||||
for index in startIndex..<gridCount {
|
||||
let u = Double(index) / denominator
|
||||
if u <= uNow + Self.epsilon { continue }
|
||||
let value = Self.clamp(curve[index] + shift, lower: 0, upper: 100)
|
||||
if previousValue < 100 - Self.epsilon, value >= 100 - Self.epsilon {
|
||||
let delta = value - previousValue
|
||||
if abs(delta) <= Self.epsilon { return u }
|
||||
let ratio = Self.clamp((100 - previousValue) / delta, lower: 0, upper: 1)
|
||||
return Self.clamp(previousU + ratio * (u - previousU), lower: uNow, upper: 1)
|
||||
}
|
||||
previousU = u
|
||||
previousValue = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func interpolate(curve: [Double], at u: Double) -> Double {
|
||||
guard !curve.isEmpty else { return 0 }
|
||||
if curve.count == 1 { return curve[0] }
|
||||
|
||||
let clipped = Self.clamp(u, lower: 0, upper: 1)
|
||||
let scaled = clipped * Double(curve.count - 1)
|
||||
let lower = Int(floor(scaled))
|
||||
let upper = min(curve.count - 1, lower + 1)
|
||||
if lower == upper { return curve[lower] }
|
||||
let ratio = scaled - Double(lower)
|
||||
return curve[lower] + ((curve[upper] - curve[lower]) * ratio)
|
||||
}
|
||||
|
||||
private static func weightedMedian(values: [Double], weights: [Double]) -> Double {
|
||||
guard values.count == weights.count, !values.isEmpty else { return 0 }
|
||||
let pairs = zip(values, weights)
|
||||
.map { (value: $0, weight: max(0, $1)) }
|
||||
.sorted { lhs, rhs in lhs.value < rhs.value }
|
||||
let totalWeight = pairs.reduce(0.0) { $0 + $1.weight }
|
||||
if totalWeight <= Self.epsilon {
|
||||
let sortedValues = values.sorted()
|
||||
return sortedValues[sortedValues.count / 2]
|
||||
}
|
||||
|
||||
let threshold = totalWeight / 2
|
||||
var cumulative = 0.0
|
||||
for pair in pairs {
|
||||
cumulative += pair.weight
|
||||
if cumulative >= threshold {
|
||||
return pair.value
|
||||
}
|
||||
}
|
||||
return pairs.last?.value ?? 0
|
||||
}
|
||||
|
||||
private static func clamp(_ value: Double, lower: Double, upper: Double) -> Double {
|
||||
min(upper, max(lower, value))
|
||||
}
|
||||
|
||||
private static func normalizeReset(_ value: Date) -> Date {
|
||||
let bucket = Self.resetBucketSeconds
|
||||
guard bucket > 0 else { return value }
|
||||
let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket
|
||||
return Date(timeIntervalSinceReferenceDate: rounded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum IconRemainingResolver {
|
||||
private static let visibleZeroPercent = 0.0001
|
||||
private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-"
|
||||
// Antigravity quota summaries expose exact 5-hour session and weekly buckets for the compact icon.
|
||||
private static let sessionWindowMinutes = 5 * 60
|
||||
private static let weeklyWindowMinutes = 7 * 24 * 60
|
||||
|
||||
private static func codexProjection(snapshot: UsageSnapshot, now: Date) -> CodexConsumerProjection {
|
||||
CodexConsumerProjection.make(
|
||||
surface: .menuBar,
|
||||
context: CodexConsumerProjection.Context(
|
||||
snapshot: snapshot,
|
||||
rawUsageError: nil,
|
||||
liveCredits: nil,
|
||||
rawCreditsError: nil,
|
||||
liveDashboard: nil,
|
||||
rawDashboardError: nil,
|
||||
dashboardAttachmentAuthorized: false,
|
||||
dashboardRequiresLogin: false,
|
||||
now: now))
|
||||
}
|
||||
|
||||
private static func codexVisibleWindows(snapshot: UsageSnapshot, now: Date) -> [RateWindow] {
|
||||
let projection = self.codexProjection(snapshot: snapshot, now: now)
|
||||
return projection.visibleRateLanes.compactMap { projection.menuBarSelectableRateWindow(for: $0) }
|
||||
}
|
||||
|
||||
private static func antigravityQuotaSummaryWindows(
|
||||
snapshot: UsageSnapshot)
|
||||
-> (primary: RateWindow?, secondary: RateWindow?)?
|
||||
{
|
||||
let quotaSummaryWindows = snapshot.extraRateWindows?
|
||||
.filter {
|
||||
$0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix)
|
||||
} ?? []
|
||||
guard !quotaSummaryWindows.isEmpty else { return nil }
|
||||
|
||||
return self.antigravityQuotaSummaryPair(in: quotaSummaryWindows.filter(\.usageKnown))
|
||||
}
|
||||
|
||||
private static func antigravityQuotaSummaryPair(
|
||||
in windows: [NamedRateWindow])
|
||||
-> (primary: RateWindow?, secondary: RateWindow?)?
|
||||
{
|
||||
let session = self.mostConstrainedWindow(in: windows, windowMinutes: Self.sessionWindowMinutes)
|
||||
let weekly = self.mostConstrainedWindow(in: windows, windowMinutes: Self.weeklyWindowMinutes)
|
||||
guard session != nil || weekly != nil else { return nil }
|
||||
return (primary: session, secondary: weekly)
|
||||
}
|
||||
|
||||
/// Returns the highest-usage window for an exact Antigravity compact-icon cadence.
|
||||
private static func mostConstrainedWindow(in windows: [NamedRateWindow], windowMinutes: Int) -> RateWindow? {
|
||||
windows
|
||||
.filter { $0.window.windowMinutes == windowMinutes }
|
||||
.max { lhs, rhs in
|
||||
if lhs.window.usedPercent != rhs.window.usedPercent {
|
||||
return lhs.window.usedPercent < rhs.window.usedPercent
|
||||
}
|
||||
// max(by:) keeps the right-hand element when this returns true; use `>` so the smallest id wins ties.
|
||||
return lhs.id > rhs.id
|
||||
}?
|
||||
.window
|
||||
}
|
||||
|
||||
static func resolvedWindows(
|
||||
snapshot: UsageSnapshot,
|
||||
style: IconStyle,
|
||||
secondaryOverrideWindowID: String? = nil,
|
||||
now: Date = Date())
|
||||
-> (primary: RateWindow?, secondary: RateWindow?)
|
||||
{
|
||||
if style == .perplexity {
|
||||
let windows = snapshot.orderedPerplexityDisplayWindows()
|
||||
return (
|
||||
primary: windows.first,
|
||||
secondary: windows.dropFirst().first)
|
||||
}
|
||||
if style == .antigravity {
|
||||
// Only current quota-summary buckets define the fixed session/weekly icon lanes.
|
||||
return self.antigravityQuotaSummaryWindows(snapshot: snapshot)
|
||||
?? (primary: nil, secondary: nil)
|
||||
}
|
||||
if style == .codex {
|
||||
let windows = self.codexVisibleWindows(snapshot: snapshot, now: now)
|
||||
return (
|
||||
primary: windows.first,
|
||||
secondary: windows.dropFirst().first)
|
||||
}
|
||||
if style == .copilot,
|
||||
let secondaryOverrideWindowID,
|
||||
let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window
|
||||
{
|
||||
return (
|
||||
primary: snapshot.primary,
|
||||
secondary: extraWindow)
|
||||
}
|
||||
return (
|
||||
primary: snapshot.primary,
|
||||
secondary: snapshot.secondary)
|
||||
}
|
||||
|
||||
static func resolvedRemaining(
|
||||
snapshot: UsageSnapshot,
|
||||
style: IconStyle,
|
||||
secondaryOverrideWindowID: String? = nil,
|
||||
now: Date = Date())
|
||||
-> (primary: Double?, secondary: Double?)
|
||||
{
|
||||
let windows = self.resolvedWindows(
|
||||
snapshot: snapshot,
|
||||
style: style,
|
||||
secondaryOverrideWindowID: secondaryOverrideWindowID,
|
||||
now: now)
|
||||
return (
|
||||
primary: windows.primary?.remainingPercent,
|
||||
secondary: windows.secondary?.remainingPercent)
|
||||
}
|
||||
|
||||
static func resolvedPercents(
|
||||
snapshot: UsageSnapshot,
|
||||
style: IconStyle,
|
||||
showUsed: Bool,
|
||||
renderingStyle: IconStyle? = nil,
|
||||
secondaryOverrideWindowID: String? = nil,
|
||||
now: Date = Date())
|
||||
-> (primary: Double?, secondary: Double?)
|
||||
{
|
||||
let windows = Self.resolvedWindows(
|
||||
snapshot: snapshot,
|
||||
style: style,
|
||||
secondaryOverrideWindowID: secondaryOverrideWindowID,
|
||||
now: now)
|
||||
var percents = (
|
||||
primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent,
|
||||
secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent)
|
||||
// Provider style chooses the usage lanes; rendering style controls renderer-specific layout sentinels.
|
||||
// Merged icons still resolve Warp's lanes, but render as `.combined` and must keep the real percentage.
|
||||
if showUsed, style == .warp, (renderingStyle ?? style) == .warp, let secondary = windows.secondary {
|
||||
if secondary.remainingPercent <= 0 {
|
||||
// Preserve Warp's exhausted/no-bonus layout even though used percent is 100.
|
||||
percents.secondary = 0
|
||||
} else if percents.secondary == 0 {
|
||||
// A zero fill means "lane absent" to IconRenderer; keep an unused bonus lane visible.
|
||||
percents.secondary = self.visibleZeroPercent
|
||||
}
|
||||
}
|
||||
return percents
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,914 @@
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
struct InlineUsageDashboardModel: Equatable {
|
||||
struct KPI: Equatable {
|
||||
let title: String
|
||||
let value: String
|
||||
let emphasis: Bool
|
||||
}
|
||||
|
||||
struct Point: Equatable, Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let value: Double
|
||||
let accessibilityValue: String
|
||||
}
|
||||
|
||||
enum ValueStyle: Equatable {
|
||||
case currencyUSD
|
||||
case currency(symbol: String)
|
||||
case tokens
|
||||
case points
|
||||
}
|
||||
|
||||
let accessibilityLabel: String
|
||||
let valueStyle: ValueStyle
|
||||
let kpis: [KPI]
|
||||
let points: [Point]
|
||||
let detailLines: [String]
|
||||
/// Provider branding color used to fill the mini usage bars. When nil the bars fall back to a
|
||||
/// neutral palette derived from `valueStyle`.
|
||||
var barColor: Color?
|
||||
/// ISO 4217 currency code for cost dashboards. When non-nil, `MiniUsageBars` shows a max-cost scale label.
|
||||
/// Nil for token/points dashboards.
|
||||
var currencyCode: String?
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
static func apiProviderUsageNotes(input: Input) -> [String]? {
|
||||
if input.provider == .openai,
|
||||
let usage = input.snapshot?.openAIAPIUsage
|
||||
{
|
||||
return self.openAIAPIUsageNotes(usage)
|
||||
}
|
||||
|
||||
if input.provider == .deepgram,
|
||||
let usage = input.snapshot?.deepgramUsage
|
||||
{
|
||||
return usage.displayLines
|
||||
}
|
||||
|
||||
if input.provider == .clawrouter,
|
||||
let usage = input.snapshot?.clawRouterUsage
|
||||
{
|
||||
var notes = [
|
||||
"\(UsageFormatter.tokenCountString(usage.requestCount)) \(L("requests")) · " +
|
||||
"\(UsageFormatter.tokenCountString(usage.totalTokens)) \(L("tokens"))",
|
||||
]
|
||||
if usage.errorCount > 0 {
|
||||
notes.append("\(usage.successCount) succeeded · \(usage.errorCount) failed")
|
||||
}
|
||||
if !usage.providers.isEmpty {
|
||||
let mix = usage.providers.prefix(5)
|
||||
.map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" }
|
||||
.joined(separator: " · ")
|
||||
notes.append("Routed providers: \(mix)")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
if input.provider == .wayfinder,
|
||||
let usage = input.snapshot?.wayfinderUsage
|
||||
{
|
||||
return usage.displayLines
|
||||
}
|
||||
|
||||
if input.provider == .minimax,
|
||||
input.showOptionalCreditsAndExtraUsage,
|
||||
let billing = input.snapshot?.minimaxUsage?.billingSummary
|
||||
{
|
||||
return [
|
||||
String(format: L("Today: %@ tokens"), UsageFormatter.tokenCountString(billing.todayTokens)),
|
||||
String(
|
||||
format: L("Last 30 days: %@ tokens"),
|
||||
UsageFormatter.tokenCountString(billing.last30DaysTokens)),
|
||||
]
|
||||
}
|
||||
|
||||
if input.provider == .deepseek,
|
||||
input.showOptionalCreditsAndExtraUsage,
|
||||
let usage = input.snapshot?.deepseekUsage
|
||||
{
|
||||
let symbol = usage.currency == "CNY" ? "¥" : "$"
|
||||
let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—"
|
||||
return [
|
||||
String(
|
||||
format: L("Today: %@ · %@ tokens"),
|
||||
todayCostStr,
|
||||
UsageFormatter.tokenCountString(usage.todayTokens)),
|
||||
String(format: L("This month: %@ tokens"), UsageFormatter.tokenCountString(usage.currentMonthTokens)),
|
||||
]
|
||||
}
|
||||
|
||||
if input.provider == .poe,
|
||||
let usage = input.snapshot?.poeUsage
|
||||
{
|
||||
return self.poeUsageNotes(usage, now: input.now)
|
||||
}
|
||||
|
||||
if input.provider == .ollama,
|
||||
input.snapshot?.identity?.loginMethod == "API key"
|
||||
{
|
||||
return [L("API key verified. Ollama does not expose Cloud quota limits through the API.")]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func openAIAPIUsageNotes(_ usage: OpenAIAPIUsageSnapshot) -> [String] {
|
||||
let today = usage.currentDay
|
||||
let seven = usage.last7Days
|
||||
let thirty = usage.last30Days
|
||||
let historyLabel = usage.historyWindowLabel
|
||||
let todayNote = String(
|
||||
format: L("Today: %@ · %@ tokens"),
|
||||
UsageFormatter.usdString(today.costUSD),
|
||||
UsageFormatter.tokenCountString(today.totalTokens))
|
||||
let sevenDayNote = "7d: \(UsageFormatter.usdString(seven.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(seven.requests)) \(L("requests"))"
|
||||
let thirtyDayNote =
|
||||
"\(historyLabel): \(UsageFormatter.tokenCountString(thirty.totalTokens)) \(L("tokens")) · " +
|
||||
"\(UsageFormatter.tokenCountString(thirty.requests)) \(L("requests"))"
|
||||
var notes: [String] = [
|
||||
todayNote,
|
||||
sevenDayNote,
|
||||
thirtyDayNote,
|
||||
]
|
||||
if let topModel = usage.topModels.first {
|
||||
notes.append("\(L("Top model")): \(topModel.name)")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
static func poeUsageNotes(
|
||||
_ usage: PoeUsageHistorySnapshot,
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current) -> [String]
|
||||
{
|
||||
let today = usage.currentDay(now: now, calendar: calendar)
|
||||
let week = usage.last7Days
|
||||
let month = usage.last30Days
|
||||
let todayUSD = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
|
||||
let weekUSD = week.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
|
||||
let monthUSD = month.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
|
||||
let todayLine = "Today: \(Self.pointsSummary(today.points)) · " +
|
||||
"\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayUSD)"
|
||||
let weekLine = "7d: \(Self.pointsSummary(week.points)) · " +
|
||||
"\(UsageFormatter.tokenCountString(week.requests)) \(L("requests"))\(weekUSD)"
|
||||
let monthLine = "30d: \(Self.pointsSummary(month.points)) · " +
|
||||
"\(UsageFormatter.tokenCountString(month.requests)) \(L("requests"))\(monthUSD)"
|
||||
var notes = [
|
||||
todayLine,
|
||||
weekLine,
|
||||
monthLine,
|
||||
]
|
||||
if let topModel = usage.topModels.first {
|
||||
notes.append("\(L("Top model")): \(topModel.name) (\(Self.pointsSummary(topModel.points)))")
|
||||
}
|
||||
if !usage.topUsageTypes.isEmpty {
|
||||
let mix = usage.topUsageTypes.prefix(2)
|
||||
.map { "\($0.name): \(Self.pointsSummary($0.points))" }
|
||||
.joined(separator: " · ")
|
||||
notes.append("Usage mix: \(mix)")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
static func inlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? {
|
||||
guard var model = self.resolveInlineUsageDashboard(input: input) else { return nil }
|
||||
model.barColor = Self.inlineDashboardBarColor(for: input.provider)
|
||||
return model
|
||||
}
|
||||
|
||||
/// Provider branding color for the inline usage bars, matching the provider's switcher tab and
|
||||
/// detailed cost-history chart.
|
||||
static func inlineDashboardBarColor(for provider: UsageProvider) -> Color {
|
||||
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
|
||||
return Color(red: color.red, green: color.green, blue: color.blue)
|
||||
}
|
||||
|
||||
private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? {
|
||||
if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider),
|
||||
let tokenSnapshot = primaryCostHistorySnapshot(input: input),
|
||||
!tokenSnapshot.daily.isEmpty
|
||||
{
|
||||
return self.costHistoryInlineDashboard(
|
||||
provider: input.provider,
|
||||
snapshot: tokenSnapshot,
|
||||
comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled)
|
||||
}
|
||||
if input.provider == .claude,
|
||||
let usage = input.snapshot?.claudeAdminAPIUsage
|
||||
{
|
||||
return Self.claudeAdminAPIInlineDashboard(usage)
|
||||
}
|
||||
if input.provider == .openrouter,
|
||||
let usage = input.snapshot?.openRouterUsage
|
||||
{
|
||||
return Self.openRouterInlineDashboard(usage)
|
||||
}
|
||||
if input.provider == .crossmodel,
|
||||
let usage = input.snapshot?.crossModelUsage
|
||||
{
|
||||
return Self.crossModelInlineDashboard(usage)
|
||||
}
|
||||
if input.provider == .zai,
|
||||
let modelUsage = input.snapshot?.zaiUsage?.modelUsage
|
||||
{
|
||||
return Self.zaiInlineDashboard(modelUsage: modelUsage, now: input.now)
|
||||
}
|
||||
if input.provider == .minimax,
|
||||
input.showOptionalCreditsAndExtraUsage,
|
||||
let billing = input.snapshot?.minimaxUsage?.billingSummary,
|
||||
!billing.daily.isEmpty
|
||||
{
|
||||
return Self.minimaxInlineDashboard(billing)
|
||||
}
|
||||
if input.provider == .deepseek,
|
||||
input.showOptionalCreditsAndExtraUsage,
|
||||
let usage = input.snapshot?.deepseekUsage,
|
||||
!usage.daily.isEmpty
|
||||
{
|
||||
return Self.deepseekInlineDashboard(usage)
|
||||
}
|
||||
if input.provider == .poe,
|
||||
let usage = input.snapshot?.poeUsage,
|
||||
!usage.daily.isEmpty
|
||||
{
|
||||
return Self.poeInlineDashboard(usage, now: input.now)
|
||||
}
|
||||
if [.codex, .claude, .vertexai, .bedrock].contains(input.provider),
|
||||
input.tokenCostInlineDashboardEnabled,
|
||||
let tokenSnapshot = input.tokenSnapshot,
|
||||
!tokenSnapshot.daily.isEmpty
|
||||
{
|
||||
return Self.costHistoryInlineDashboard(
|
||||
provider: input.provider,
|
||||
snapshot: tokenSnapshot,
|
||||
comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool {
|
||||
provider == .openai || provider == .mistral
|
||||
}
|
||||
|
||||
static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? {
|
||||
switch input.provider {
|
||||
case .openai:
|
||||
if let projected = input.snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() {
|
||||
return projected
|
||||
}
|
||||
return input.snapshot == nil ? input.tokenSnapshot : nil
|
||||
case .mistral:
|
||||
if let projected = input.snapshot?.mistralUsage?.toCostUsageTokenSnapshot() {
|
||||
return projected
|
||||
}
|
||||
return input.snapshot == nil ? input.tokenSnapshot : nil
|
||||
default:
|
||||
return input.tokenSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
static func poeInlineDashboard(
|
||||
_ usage: PoeUsageHistorySnapshot,
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current) -> InlineUsageDashboardModel
|
||||
{
|
||||
let today = usage.currentDay(now: now, calendar: calendar)
|
||||
let week = usage.last7Days
|
||||
let month = usage.last30Days
|
||||
let points = usage.daily.suffix(30).map {
|
||||
InlineUsageDashboardModel.Point(
|
||||
id: $0.day,
|
||||
label: Self.shortDayLabel($0.day),
|
||||
value: $0.points,
|
||||
accessibilityValue: "\($0.day): \(Self.pointsSummary($0.points))")
|
||||
}
|
||||
var details = ["30d requests: \(UsageFormatter.tokenCountString(month.requests))"]
|
||||
if let topModel = usage.topModel {
|
||||
details.append("\(L("Top model")): \(topModel)")
|
||||
}
|
||||
if !usage.topUsageTypes.isEmpty {
|
||||
let mix = usage.topUsageTypes.prefix(3)
|
||||
.map { "\($0.name): \(Self.pointsSummary($0.points))" }
|
||||
.joined(separator: " · ")
|
||||
details.append("Usage mix: \(mix)")
|
||||
}
|
||||
if let usd = today.costUSD, usd > 0 {
|
||||
details.append("Today USD: \(UsageFormatter.usdString(usd))")
|
||||
}
|
||||
if let usd = week.costUSD, usd > 0 {
|
||||
details.append("7d USD: \(UsageFormatter.usdString(usd))")
|
||||
}
|
||||
if let usd = month.costUSD, usd > 0 {
|
||||
details.append("30d USD: \(UsageFormatter.usdString(usd))")
|
||||
}
|
||||
let recent = usage.recentEntries(limit: 2)
|
||||
if !recent.isEmpty {
|
||||
let text = recent.map { "\($0.model) \(Self.pointsSummary($0.points))" }.joined(separator: " · ")
|
||||
details.append("Recent: \(text)")
|
||||
}
|
||||
return InlineUsageDashboardModel(
|
||||
accessibilityLabel: "Poe points usage trend",
|
||||
valueStyle: .points,
|
||||
kpis: [
|
||||
.init(title: L("Today"), value: Self.pointsSummary(today.points), emphasis: true),
|
||||
.init(title: "7d", value: Self.pointsSummary(week.points), emphasis: false),
|
||||
.init(title: "30d", value: Self.pointsSummary(month.points), emphasis: false),
|
||||
.init(title: L("Requests"), value: UsageFormatter.tokenCountString(month.requests), emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: details)
|
||||
}
|
||||
|
||||
static func pointsSummary(_ value: Double) -> String {
|
||||
let clamped = max(0, value)
|
||||
if clamped.rounded() == clamped {
|
||||
return "\(UsageFormatter.tokenCountString(Int(clamped))) points"
|
||||
}
|
||||
return "\(String(format: "%.1f", clamped)) points"
|
||||
}
|
||||
|
||||
private static func costHistoryInlineDashboard(
|
||||
provider: UsageProvider,
|
||||
snapshot: CostUsageTokenSnapshot,
|
||||
comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel
|
||||
{
|
||||
let historyDays = max(1, min(365, snapshot.historyDays))
|
||||
let historyTitle = snapshot.historyLabel
|
||||
?? (historyDays == 1
|
||||
? L("Today")
|
||||
: historyDays == 30
|
||||
? L("30d cost")
|
||||
: "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))")
|
||||
let tokenHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("tokens"))" }
|
||||
?? (historyDays == 1
|
||||
? L("Today tokens")
|
||||
: historyDays == 30
|
||||
? L("30d tokens")
|
||||
: String(format: L("%@ tokens"), String(format: L("Last %d days"), historyDays)))
|
||||
let requestHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("requests"))" }
|
||||
?? (historyDays == 1
|
||||
? L("Today requests")
|
||||
: historyDays == 30
|
||||
? L("30d requests")
|
||||
: String(format: L("%@ requests"), String(format: L("Last %d days"), historyDays)))
|
||||
let periodLabel = snapshot.historyLabel?.lowercased()
|
||||
?? (historyDays == 1 ? "today" : "\(historyDays) day")
|
||||
let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in
|
||||
guard let cost = entry.costUSD else { return nil }
|
||||
return InlineUsageDashboardModel.Point(
|
||||
id: entry.date,
|
||||
label: Self.shortDayLabel(entry.date),
|
||||
value: cost,
|
||||
accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))")
|
||||
}
|
||||
let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily)
|
||||
let usesLatestPrimary = provider == .bedrock || provider == .mistral
|
||||
let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD
|
||||
var details: [String] = []
|
||||
if comparisonPeriodsEnabled {
|
||||
details.append(contentsOf: snapshot.comparisonSummaries().map {
|
||||
Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode)
|
||||
})
|
||||
}
|
||||
if let topModel = Self.topCostModel(from: snapshot.daily) {
|
||||
details.append("\(L("Top model")): \(Self.shortModelName(topModel))")
|
||||
}
|
||||
if let requestCount = snapshot.last30DaysRequests {
|
||||
details.append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) \(L("requests"))")
|
||||
}
|
||||
if let hint = Self.tokenUsageHint(provider: provider) {
|
||||
details.append(hint)
|
||||
} else {
|
||||
details.append(L("cost_estimate_hint"))
|
||||
}
|
||||
let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue
|
||||
var model = InlineUsageDashboardModel(
|
||||
accessibilityLabel: "\(providerName) \(periodLabel) cost trend",
|
||||
valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode),
|
||||
kpis: [
|
||||
.init(
|
||||
title: usesLatestPrimary ? L("Latest") : L("Today"),
|
||||
value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
|
||||
emphasis: true),
|
||||
.init(
|
||||
title: historyTitle,
|
||||
value: snapshot.last30DaysCostUSD
|
||||
.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: tokenHistoryTitle,
|
||||
value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—",
|
||||
emphasis: false),
|
||||
] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest),
|
||||
points: points,
|
||||
detailLines: details)
|
||||
model.currencyCode = snapshot.currencyCode
|
||||
return model
|
||||
}
|
||||
|
||||
private static func costHistoryTrailingKPIs(
|
||||
snapshot: CostUsageTokenSnapshot,
|
||||
latest: CostUsageDailyReport.Entry?)
|
||||
-> [InlineUsageDashboardModel.KPI]
|
||||
{
|
||||
if let requests = snapshot.last30DaysRequests {
|
||||
return [
|
||||
.init(
|
||||
title: L("Requests"),
|
||||
value: UsageFormatter.tokenCountString(requests),
|
||||
emphasis: false),
|
||||
]
|
||||
}
|
||||
return [
|
||||
.init(
|
||||
title: L("Latest tokens"),
|
||||
value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—",
|
||||
emphasis: false),
|
||||
]
|
||||
}
|
||||
|
||||
fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot)
|
||||
-> InlineUsageDashboardModel
|
||||
{
|
||||
let today = usage.currentDay
|
||||
let last7 = usage.last7Days
|
||||
let last30 = usage.last30Days
|
||||
let points = usage.daily.suffix(30).map {
|
||||
InlineUsageDashboardModel.Point(
|
||||
id: $0.day,
|
||||
label: Self.shortDayLabel($0.day),
|
||||
value: $0.costUSD,
|
||||
accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))")
|
||||
}
|
||||
var details = [
|
||||
"30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))",
|
||||
"\(L("Cache read")): \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) \(L("tokens"))",
|
||||
]
|
||||
if let topModel = usage.topModels.first {
|
||||
details.append("\(L("Top model")): \(Self.shortModelName(topModel.name))")
|
||||
}
|
||||
var model = InlineUsageDashboardModel(
|
||||
accessibilityLabel: L("Claude Admin API 30 day spend trend"),
|
||||
valueStyle: .currencyUSD,
|
||||
kpis: [
|
||||
.init(title: L("Today"), value: UsageFormatter.usdString(today.costUSD), emphasis: true),
|
||||
.init(title: L("7d spend"), value: UsageFormatter.usdString(last7.costUSD), emphasis: false),
|
||||
.init(
|
||||
title: L("30d spend"),
|
||||
value: UsageFormatter.usdString(last30.costUSD),
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Today tokens"),
|
||||
value: UsageFormatter.tokenCountString(today.totalTokens),
|
||||
emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: details)
|
||||
model.currencyCode = "USD"
|
||||
return model
|
||||
}
|
||||
|
||||
private static func crossModelInlineDashboard(_ usage: CrossModelUsageSnapshot) -> InlineUsageDashboardModel? {
|
||||
let periodValues: [(String, String, Double?)] = [
|
||||
("day", L("Today"), usage.daily?.cost),
|
||||
("week", L("Week"), usage.weekly?.cost),
|
||||
("month", L("Month"), usage.monthly?.cost),
|
||||
]
|
||||
let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in
|
||||
guard let value else { return nil }
|
||||
return InlineUsageDashboardModel.Point(
|
||||
id: id,
|
||||
label: label,
|
||||
value: value,
|
||||
accessibilityValue: "\(label): \(usage.currencyString(value))")
|
||||
}
|
||||
var model = InlineUsageDashboardModel(
|
||||
accessibilityLabel: L("CrossModel API spend trend"),
|
||||
valueStyle: Self.costValueStyle(currencyCode: usage.currency),
|
||||
kpis: [
|
||||
.init(title: L("Balance"), value: usage.balanceDisplay, emphasis: true),
|
||||
.init(
|
||||
title: L("Today"),
|
||||
value: usage.daily.map { usage.currencyString($0.cost) } ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Week"),
|
||||
value: usage.weekly.map { usage.currencyString($0.cost) } ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Month"),
|
||||
value: usage.monthly.map { usage.currencyString($0.cost) } ?? "—",
|
||||
emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: [])
|
||||
model.currencyCode = usage.currency
|
||||
return model
|
||||
}
|
||||
|
||||
private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? {
|
||||
let periodValues: [(String, String, Double?)] = [
|
||||
("day", L("Today"), usage.keyUsageDaily),
|
||||
("week", L("Week"), usage.keyUsageWeekly),
|
||||
("month", L("Month"), usage.keyUsageMonthly),
|
||||
]
|
||||
let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in
|
||||
guard let value else { return nil }
|
||||
let formattedValue = Self.openRouterCurrencyString(value)
|
||||
return InlineUsageDashboardModel.Point(
|
||||
id: id,
|
||||
label: label,
|
||||
value: value,
|
||||
accessibilityValue: String(format: L("%@: %@"), label, formattedValue))
|
||||
}
|
||||
guard !points.isEmpty else { return nil }
|
||||
var details: [String] = []
|
||||
if let rate = usage.rateLimit {
|
||||
details.append(String(format: L("Rate limit: %d / %@"), rate.requests, rate.interval))
|
||||
}
|
||||
switch usage.keyQuotaStatus {
|
||||
case .available:
|
||||
if let remaining = usage.keyRemaining {
|
||||
details.append(String(
|
||||
format: L("%@: %@"),
|
||||
L("Key remaining"),
|
||||
Self.openRouterCurrencyString(remaining)))
|
||||
}
|
||||
case .noLimitConfigured:
|
||||
details.append(L("No limit set for the API key"))
|
||||
case .unavailable:
|
||||
details.append(L("API key limit unavailable right now"))
|
||||
}
|
||||
var model = InlineUsageDashboardModel(
|
||||
accessibilityLabel: L("OpenRouter API key spend trend"),
|
||||
valueStyle: .currencyUSD,
|
||||
kpis: [
|
||||
.init(title: L("Balance"), value: Self.openRouterCurrencyString(usage.balance), emphasis: true),
|
||||
.init(
|
||||
title: L("Today"),
|
||||
value: usage.keyUsageDaily.map(Self.openRouterCurrencyString) ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Week"),
|
||||
value: usage.keyUsageWeekly.map(Self.openRouterCurrencyString) ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Month"),
|
||||
value: usage.keyUsageMonthly.map(Self.openRouterCurrencyString) ?? "—",
|
||||
emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: details)
|
||||
model.currencyCode = "USD"
|
||||
return model
|
||||
}
|
||||
|
||||
private static func zaiInlineDashboard(modelUsage: ZaiModelUsageData, now: Date) -> InlineUsageDashboardModel? {
|
||||
let bars = ZaiHourlyBars.from(modelData: modelUsage, range: .last24h, now: now)
|
||||
guard !bars.isEmpty else { return nil }
|
||||
let total = bars.reduce(0) { $0 + $1.totalTokens }
|
||||
let latest = bars.last
|
||||
let peak = bars.max { $0.totalTokens < $1.totalTokens }
|
||||
let points = bars.enumerated().map { index, bar in
|
||||
InlineUsageDashboardModel.Point(
|
||||
id: "\(index)-\(bar.label)",
|
||||
label: bar.label,
|
||||
value: Double(bar.totalTokens),
|
||||
accessibilityValue: "\(bar.label): \(UsageFormatter.tokenCountString(bar.totalTokens)) \(L("tokens"))")
|
||||
}
|
||||
let topModel = Self.topZaiModel(from: bars)
|
||||
return InlineUsageDashboardModel(
|
||||
accessibilityLabel: L("z.ai hourly token trend"),
|
||||
valueStyle: .tokens,
|
||||
kpis: [
|
||||
.init(title: L("24h tokens"), value: UsageFormatter.tokenCountString(total), emphasis: true),
|
||||
.init(
|
||||
title: L("Latest hour"),
|
||||
value: latest.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Peak hour"),
|
||||
value: peak.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—",
|
||||
emphasis: false),
|
||||
.init(title: L("Models"), value: "\(modelUsage.modelNames.count)", emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: topModel.map { ["\(L("Top model")): \(Self.shortModelName($0))"] } ?? [])
|
||||
}
|
||||
|
||||
private static func minimaxInlineDashboard(_ billing: MiniMaxBillingSummary) -> InlineUsageDashboardModel {
|
||||
let points = billing.daily.suffix(30).map {
|
||||
InlineUsageDashboardModel.Point(
|
||||
id: $0.day,
|
||||
label: Self.shortDayLabel($0.day),
|
||||
value: Double($0.tokens),
|
||||
accessibilityValue: "\($0.day): \(UsageFormatter.tokenCountString($0.tokens)) \(L("tokens"))")
|
||||
}
|
||||
var details = [L("30d billing history from MiniMax web session")]
|
||||
if let topModel = billing.topModels.first {
|
||||
details.append("\(L("Top model")): \(Self.shortModelName(topModel.name))")
|
||||
}
|
||||
if let topMethod = billing.topMethods.first {
|
||||
details.append("\(L("Top method")): \(Self.shortModelName(topMethod.name))")
|
||||
}
|
||||
if let cash = billing.last30DaysCash {
|
||||
details.append("\(L("30d cash")): \(Self.minimaxCashString(cash))")
|
||||
}
|
||||
return InlineUsageDashboardModel(
|
||||
accessibilityLabel: L("MiniMax 30 day token usage trend"),
|
||||
valueStyle: .tokens,
|
||||
kpis: [
|
||||
.init(
|
||||
title: L("Today"),
|
||||
value: UsageFormatter.tokenCountString(billing.todayTokens),
|
||||
emphasis: true),
|
||||
.init(
|
||||
title: L("30d tokens"),
|
||||
value: UsageFormatter.tokenCountString(billing.last30DaysTokens),
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Today cash"),
|
||||
value: billing.todayCash.map(Self.minimaxCashString) ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Models"),
|
||||
value: "\(billing.topModels.count)",
|
||||
emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: details)
|
||||
}
|
||||
|
||||
private static func deepseekInlineDashboard(_ usage: DeepSeekUsageSummary) -> InlineUsageDashboardModel {
|
||||
let symbol = usage.currency == "CNY" ? "¥" : "$"
|
||||
let points = usage.daily.suffix(30).map {
|
||||
InlineUsageDashboardModel.Point(
|
||||
id: $0.date,
|
||||
label: Self.shortDayLabel($0.date),
|
||||
value: Double($0.totalTokens),
|
||||
accessibilityValue: "\($0.date): \(UsageFormatter.tokenCountString($0.totalTokens)) \(L("tokens"))")
|
||||
}
|
||||
var details: [String] = []
|
||||
if let topModel = usage.topModel {
|
||||
details.append("\(L("Top model")): \(Self.shortModelName(topModel))")
|
||||
}
|
||||
if let cacheHit = usage.categoryBreakdown.first(where: { $0.category == .promptCacheHitToken }) {
|
||||
details.append("\(L("cache-hit input")): \(UsageFormatter.tokenCountString(cacheHit.tokens))")
|
||||
}
|
||||
if let cacheMiss = usage.categoryBreakdown.first(where: { $0.category == .promptCacheMissToken }) {
|
||||
details.append("\(L("cache-miss input")): \(UsageFormatter.tokenCountString(cacheMiss.tokens))")
|
||||
}
|
||||
if let output = usage.categoryBreakdown.first(where: { $0.category == .responseToken }) {
|
||||
details.append("\(L("output")): \(UsageFormatter.tokenCountString(output.tokens))")
|
||||
}
|
||||
details.append("\(L("requests")): \(usage.currentMonthRequestCount)")
|
||||
|
||||
let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—"
|
||||
let monthCostStr = usage.currentMonthCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—"
|
||||
let monthTokensStr = UsageFormatter.tokenCountString(usage.currentMonthTokens)
|
||||
|
||||
return InlineUsageDashboardModel(
|
||||
accessibilityLabel: L("DeepSeek 30 day token usage trend"),
|
||||
valueStyle: .tokens,
|
||||
kpis: [
|
||||
.init(
|
||||
title: L("Today"),
|
||||
value: "\(todayCostStr) · \(UsageFormatter.tokenCountString(usage.todayTokens))",
|
||||
emphasis: true),
|
||||
.init(
|
||||
title: L("This month"),
|
||||
value: "\(monthCostStr) · \(monthTokensStr)",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Models"),
|
||||
value: usage.topModel.map { Self.shortModelName($0) } ?? "—",
|
||||
emphasis: false),
|
||||
.init(
|
||||
title: L("Requests"),
|
||||
value: "\(usage.currentMonthRequestCount)",
|
||||
emphasis: false),
|
||||
],
|
||||
points: points,
|
||||
detailLines: details)
|
||||
}
|
||||
|
||||
private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? {
|
||||
var tokens: [String: Int] = [:]
|
||||
for entry in entries {
|
||||
for model in entry.models {
|
||||
tokens[model.name, default: 0] += model.totalTokens
|
||||
}
|
||||
}
|
||||
return tokens.max {
|
||||
if $0.value == $1.value { return $0.key > $1.key }
|
||||
return $0.value < $1.value
|
||||
}?.key
|
||||
}
|
||||
|
||||
private static func topZaiModel(from bars: [ZaiHourlyBar]) -> String? {
|
||||
var tokens: [String: Int] = [:]
|
||||
for bar in bars {
|
||||
for segment in bar.segments {
|
||||
tokens[segment.model, default: 0] += segment.tokens
|
||||
}
|
||||
}
|
||||
return tokens.max {
|
||||
if $0.value == $1.value { return $0.key > $1.key }
|
||||
return $0.value < $1.value
|
||||
}?.key
|
||||
}
|
||||
|
||||
private static func openRouterCurrencyString(_ value: Double) -> String {
|
||||
String(format: "$%.2f", value)
|
||||
}
|
||||
|
||||
private static func minimaxCashString(_ value: Double) -> String {
|
||||
String(format: "%.2f", max(0, value))
|
||||
}
|
||||
|
||||
private static func costString(_ value: Double, currencyCode: String) -> String {
|
||||
UsageFormatter.currencyString(value, currencyCode: currencyCode)
|
||||
}
|
||||
|
||||
private static func costValueStyle(currencyCode: String) -> InlineUsageDashboardModel.ValueStyle {
|
||||
if currencyCode == "USD" { return .currencyUSD }
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currencyCode
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
let symbol = formatter.currencySymbol ?? currencyCode
|
||||
return .currency(symbol: symbol)
|
||||
}
|
||||
|
||||
private static func shortDayLabel(_ day: String) -> String {
|
||||
let pieces = day.split(separator: "-")
|
||||
guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day }
|
||||
return "\(rawDay)"
|
||||
}
|
||||
|
||||
private static func shortModelName(_ name: String) -> String {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count > 26 else { return trimmed }
|
||||
return String(trimmed.prefix(25)) + "…"
|
||||
}
|
||||
|
||||
private static func topCostModel(from entries: [CostUsageDailyReport.Entry]) -> String? {
|
||||
var scores: [String: (cost: Double, tokens: Int)] = [:]
|
||||
for entry in entries {
|
||||
for model in entry.modelBreakdowns ?? [] {
|
||||
var score = scores[model.modelName] ?? (0, 0)
|
||||
score.cost += model.costUSD ?? 0
|
||||
score.tokens += model.totalTokens ?? 0
|
||||
scores[model.modelName] = score
|
||||
}
|
||||
}
|
||||
return scores.max {
|
||||
if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens }
|
||||
return $0.value.cost < $1.value.cost
|
||||
}?.key
|
||||
}
|
||||
}
|
||||
|
||||
struct InlineUsageDashboardContent: View {
|
||||
private let model: InlineUsageDashboardModel
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
init(model: InlineUsageDashboardModel) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.kpis
|
||||
if !self.model.points.isEmpty {
|
||||
MiniUsageBars(model: self.model)
|
||||
.frame(height: 58)
|
||||
.accessibilityLabel(self.model.accessibilityLabel)
|
||||
}
|
||||
self.detailLines
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var kpis: some View {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(minimum: 118), alignment: .leading),
|
||||
GridItem(.flexible(minimum: 100), alignment: .leading),
|
||||
],
|
||||
alignment: .leading,
|
||||
spacing: 6)
|
||||
{
|
||||
ForEach(Array(self.model.kpis.enumerated()), id: \.offset) { _, kpi in
|
||||
KPIBlock(title: kpi.title, value: kpi.value, emphasis: kpi.emphasis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detailLines: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(Array(self.model.detailLines.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KPIBlock: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let emphasis: Bool
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(self.title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
||||
.lineLimit(1)
|
||||
Text(self.value)
|
||||
.font(self.emphasis ? .headline : .subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniUsageBars: View {
|
||||
let model: InlineUsageDashboardModel
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
var body: some View {
|
||||
let scale = UsageChartScale(values: self.model.points.map(\.value))
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if let currencyCode = self.model.currencyCode, scale.maximum > 0 {
|
||||
Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .bottom, spacing: 2) {
|
||||
ForEach(self.model.points) { point in
|
||||
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
||||
.fill(self.fill(for: point, scale: scale))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: self.height(for: point, scale: scale, available: geometry.size.height))
|
||||
.accessibilityLabel(point.accessibilityValue)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
Rectangle()
|
||||
.fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22))
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func height(
|
||||
for point: InlineUsageDashboardModel.Point,
|
||||
scale: UsageChartScale,
|
||||
available: CGFloat) -> CGFloat
|
||||
{
|
||||
let ratio = scale.fraction(for: point.value)
|
||||
guard ratio > 0 else { return 1 }
|
||||
return max(3, CGFloat(ratio) * available)
|
||||
}
|
||||
|
||||
private func fill(for point: InlineUsageDashboardModel.Point, scale: UsageChartScale) -> Color {
|
||||
let ratio = max(0.18, scale.fraction(for: point.value))
|
||||
if self.isHighlighted {
|
||||
return Color.white.opacity(0.55 + ratio * 0.35)
|
||||
}
|
||||
return self.baseColor.opacity(0.42 + ratio * 0.58)
|
||||
}
|
||||
|
||||
private var baseColor: Color {
|
||||
if let barColor = self.model.barColor {
|
||||
return barColor
|
||||
}
|
||||
switch self.model.valueStyle {
|
||||
case .currencyUSD, .currency:
|
||||
return Color(red: 0.81, green: 0.56, blue: 0.24)
|
||||
case .tokens:
|
||||
return Color(red: 0.48, green: 0.41, blue: 0.86)
|
||||
case .points:
|
||||
return Color(red: 0.16, green: 0.62, blue: 0.36)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum InstallOrigin {
|
||||
static func isHomebrewCask(appBundleURL: URL) -> Bool {
|
||||
let resolved = appBundleURL.resolvingSymlinksInPath()
|
||||
let path = resolved.path
|
||||
return path.contains("/Caskroom/") || path.contains("/Homebrew/Caskroom/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import KeyboardShortcuts
|
||||
|
||||
@MainActor
|
||||
extension KeyboardShortcuts.Name {
|
||||
static let openMenu = Self("openMenu")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
/// to prevent permission prompts on every rebuild during development.
|
||||
enum KeychainMigration {
|
||||
private static let log = CodexBarLog.logger(LogCategories.keychainMigration)
|
||||
private static let migrationKey = "KeychainMigrationV1Completed"
|
||||
|
||||
struct MigrationItem: Hashable {
|
||||
let service: String
|
||||
let account: String?
|
||||
|
||||
var label: String {
|
||||
let accountLabel = self.account ?? "<any>"
|
||||
return "\(self.service):\(accountLabel)"
|
||||
}
|
||||
}
|
||||
|
||||
static let itemsToMigrate: [MigrationItem] = [
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "codex-cookie"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "claude-cookie"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "cursor-cookie"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "factory-cookie"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "minimax-cookie"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "minimax-api-token"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "augment-cookie"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "copilot-api-token"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "zai-api-token"),
|
||||
MigrationItem(service: "com.steipete.CodexBar", account: "synthetic-api-key"),
|
||||
]
|
||||
|
||||
/// Run migration once per installation
|
||||
static func migrateIfNeeded() {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
self.log.info("Keychain access disabled; skipping migration")
|
||||
return
|
||||
}
|
||||
|
||||
if !UserDefaults.standard.bool(forKey: self.migrationKey) {
|
||||
self.log.info("Starting keychain migration to reduce permission prompts")
|
||||
|
||||
var migratedCount = 0
|
||||
var errorCount = 0
|
||||
|
||||
for item in self.itemsToMigrate {
|
||||
do {
|
||||
if try self.migrateItem(item) {
|
||||
migratedCount += 1
|
||||
}
|
||||
} catch {
|
||||
errorCount += 1
|
||||
self.log.error("Failed to migrate \(item.label): \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors")
|
||||
UserDefaults.standard.set(true, forKey: self.migrationKey)
|
||||
|
||||
if migratedCount > 0 {
|
||||
self.log.info("✅ Future rebuilds will not prompt for keychain access")
|
||||
}
|
||||
} else {
|
||||
self.log.debug("Keychain migration already completed, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate a single keychain item to the new accessibility level
|
||||
/// Returns true if item was migrated, false if item didn't exist
|
||||
private static func migrateItem(_ item: MigrationItem) throws -> Bool {
|
||||
// First, try to read the existing item
|
||||
var result: CFTypeRef?
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: item.service,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
kSecReturnAttributes as String: true,
|
||||
]
|
||||
if let account = item.account {
|
||||
query[kSecAttrAccount as String] = account
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
|
||||
if status == errSecItemNotFound {
|
||||
// Item doesn't exist, nothing to migrate
|
||||
return false
|
||||
}
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainMigrationError.readFailed(status)
|
||||
}
|
||||
|
||||
guard let rawItem = result as? [String: Any],
|
||||
let data = rawItem[kSecValueData as String] as? Data,
|
||||
let accessible = rawItem[kSecAttrAccessible as String] as? String
|
||||
else {
|
||||
throw KeychainMigrationError.invalidItemFormat
|
||||
}
|
||||
|
||||
// Check if already using the correct accessibility
|
||||
if accessible == (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) {
|
||||
self.log.debug("\(item.label) already using correct accessibility")
|
||||
return false
|
||||
}
|
||||
|
||||
// Delete the old item
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: item.service,
|
||||
]
|
||||
if let account = item.account {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
|
||||
let deleteStatus = KeychainSecurity.delete(deleteQuery as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess else {
|
||||
throw KeychainMigrationError.deleteFailed(deleteStatus)
|
||||
}
|
||||
|
||||
// Add it back with the new accessibility
|
||||
var addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: item.service,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
if let account = item.account {
|
||||
addQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw KeychainMigrationError.addFailed(addStatus)
|
||||
}
|
||||
|
||||
self.log.info("Migrated \(item.label) to new accessibility level")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Reset migration flag (for testing)
|
||||
static func resetMigrationFlag() {
|
||||
UserDefaults.standard.removeObject(forKey: self.migrationKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainMigrationError: Error {
|
||||
case readFailed(OSStatus)
|
||||
case deleteFailed(OSStatus)
|
||||
case addFailed(OSStatus)
|
||||
case invalidItemFormat
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import SweetCookieKit
|
||||
|
||||
private enum KeychainPromptMessage {
|
||||
static let browserCookie =
|
||||
"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies " +
|
||||
"and authenticate your account. Click OK to continue."
|
||||
|
||||
static let claudeOAuth =
|
||||
"CodexBar will ask macOS Keychain for the Claude Code OAuth token " +
|
||||
"so it can fetch your Claude usage. Click OK to continue."
|
||||
static let codexCookie =
|
||||
"CodexBar will ask macOS Keychain for your OpenAI cookie header " +
|
||||
"so it can fetch Codex dashboard extras. Click OK to continue."
|
||||
static let claudeCookie =
|
||||
"CodexBar will ask macOS Keychain for your Claude cookie header " +
|
||||
"so it can fetch Claude web usage. Click OK to continue."
|
||||
static let cursorCookie =
|
||||
"CodexBar will ask macOS Keychain for your Cursor cookie header " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let openCodeCookie =
|
||||
"CodexBar will ask macOS Keychain for your OpenCode cookie header " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let factoryCookie =
|
||||
"CodexBar will ask macOS Keychain for your Factory cookie header " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let zaiToken =
|
||||
"CodexBar will ask macOS Keychain for your z.ai API token " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let syntheticToken =
|
||||
"CodexBar will ask macOS Keychain for your Synthetic API key " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let copilotToken =
|
||||
"CodexBar will ask macOS Keychain for your GitHub Copilot token " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let kimiToken =
|
||||
"CodexBar will ask macOS Keychain for your Kimi auth token " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let kimiK2Token =
|
||||
"CodexBar will ask macOS Keychain for your Kimi K2 API key " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let minimaxCookie =
|
||||
"CodexBar will ask macOS Keychain for your MiniMax cookie header " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let minimaxToken =
|
||||
"CodexBar will ask macOS Keychain for your MiniMax API token " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let augmentCookie =
|
||||
"CodexBar will ask macOS Keychain for your Augment cookie header " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
static let ampCookie =
|
||||
"CodexBar will ask macOS Keychain for your Amp cookie header " +
|
||||
"so it can fetch usage. Click OK to continue."
|
||||
}
|
||||
|
||||
struct KeychainPromptAlertModel: Equatable {
|
||||
let title: String
|
||||
let message: String
|
||||
let primaryButtonTitle: String
|
||||
let learnMoreButtonTitle: String
|
||||
let documentationURL: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class KeychainPromptLearnMoreTarget: NSObject {
|
||||
private let documentationURL: String
|
||||
|
||||
init(documentationURL: String) {
|
||||
self.documentationURL = documentationURL
|
||||
}
|
||||
|
||||
@objc func openDocumentation() {
|
||||
guard let url = URL(string: self.documentationURL) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainPromptCoordinator {
|
||||
private static let promptLock = NSLock()
|
||||
private static let log = CodexBarLog.logger(LogCategories.keychainPrompt)
|
||||
private static let documentationURL =
|
||||
"https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md"
|
||||
|
||||
static func install() {
|
||||
KeychainPromptHandler.handler = { context in
|
||||
self.presentKeychainPrompt(context)
|
||||
}
|
||||
BrowserCookieKeychainPromptHandler.handler = { context in
|
||||
self.presentBrowserCookiePrompt(context)
|
||||
}
|
||||
self.disableKeychainForUnbundledExecutableIfNeeded()
|
||||
}
|
||||
|
||||
private static let unbundledExecutableCheckLock = NSLock()
|
||||
private nonisolated(unsafe) static var didCheckUnbundledExecutable = false
|
||||
|
||||
static func disableKeychainForUnbundledExecutableIfNeeded() {
|
||||
self.unbundledExecutableCheckLock.lock()
|
||||
guard !self.didCheckUnbundledExecutable else {
|
||||
self.unbundledExecutableCheckLock.unlock()
|
||||
return
|
||||
}
|
||||
self.didCheckUnbundledExecutable = true
|
||||
self.unbundledExecutableCheckLock.unlock()
|
||||
|
||||
let executablePath = Bundle.main.executableURL?.path ?? ""
|
||||
guard Self.isUnbundledCodexBarExecutable(executablePath) else { return }
|
||||
KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable")
|
||||
Self.log.warning(
|
||||
"Unbundled CodexBar executable detected; disabling keychain access to avoid repeated prompts",
|
||||
metadata: ["doc": "docs/DEVELOPMENT_SETUP.md"])
|
||||
}
|
||||
|
||||
static func isUnbundledCodexBarExecutable(_ executablePath: String) -> Bool {
|
||||
guard executablePath.hasPrefix("/") else { return false }
|
||||
let executableURL = URL(fileURLWithPath: executablePath).standardizedFileURL
|
||||
return executableURL.lastPathComponent == "CodexBar"
|
||||
&& !executableURL.pathComponents.contains(where: { $0.hasSuffix(".app") })
|
||||
}
|
||||
|
||||
private static func presentKeychainPrompt(_ context: KeychainPromptContext) {
|
||||
let model = self.alertModel(for: context)
|
||||
self.log.info("Keychain prompt requested", metadata: ["kind": "\(context.kind)"])
|
||||
self.presentAlert(model)
|
||||
}
|
||||
|
||||
private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) {
|
||||
let model = self.browserCookieAlertModel(label: context.label)
|
||||
self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label])
|
||||
self.presentAlert(model)
|
||||
}
|
||||
|
||||
static func alertModel(for context: KeychainPromptContext) -> KeychainPromptAlertModel {
|
||||
let purpose = switch context.kind {
|
||||
case .claudeOAuth:
|
||||
L(KeychainPromptMessage.claudeOAuth)
|
||||
case .codexCookie:
|
||||
L(KeychainPromptMessage.codexCookie)
|
||||
case .claudeCookie:
|
||||
L(KeychainPromptMessage.claudeCookie)
|
||||
case .cursorCookie:
|
||||
L(KeychainPromptMessage.cursorCookie)
|
||||
case .opencodeCookie:
|
||||
L(KeychainPromptMessage.openCodeCookie)
|
||||
case .factoryCookie:
|
||||
L(KeychainPromptMessage.factoryCookie)
|
||||
case .zaiToken:
|
||||
L(KeychainPromptMessage.zaiToken)
|
||||
case .syntheticToken:
|
||||
L(KeychainPromptMessage.syntheticToken)
|
||||
case .copilotToken:
|
||||
L(KeychainPromptMessage.copilotToken)
|
||||
case .kimiToken:
|
||||
L(KeychainPromptMessage.kimiToken)
|
||||
case .kimiK2Token:
|
||||
L(KeychainPromptMessage.kimiK2Token)
|
||||
case .minimaxCookie:
|
||||
L(KeychainPromptMessage.minimaxCookie)
|
||||
case .minimaxToken:
|
||||
L(KeychainPromptMessage.minimaxToken)
|
||||
case .augmentCookie:
|
||||
L(KeychainPromptMessage.augmentCookie)
|
||||
case .ampCookie:
|
||||
L(KeychainPromptMessage.ampCookie)
|
||||
}
|
||||
return self.alertModel(purpose: purpose)
|
||||
}
|
||||
|
||||
static func browserCookieAlertModel(label: String) -> KeychainPromptAlertModel {
|
||||
self.alertModel(purpose: L(KeychainPromptMessage.browserCookie, label))
|
||||
}
|
||||
|
||||
private static func alertModel(purpose: String) -> KeychainPromptAlertModel {
|
||||
KeychainPromptAlertModel(
|
||||
title: L("Keychain Access Required"),
|
||||
message: "\(purpose)\n\n\(L("keychain_prompt_privacy_note"))",
|
||||
primaryButtonTitle: L("OK"),
|
||||
learnMoreButtonTitle: L("keychain_prompt_learn_more"),
|
||||
documentationURL: self.documentationURL)
|
||||
}
|
||||
|
||||
private static func presentAlert(_ model: KeychainPromptAlertModel) {
|
||||
self.promptLock.lock()
|
||||
defer { self.promptLock.unlock() }
|
||||
|
||||
if Thread.isMainThread {
|
||||
MainActor.assumeIsolated {
|
||||
self.showAlert(model)
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.sync {
|
||||
MainActor.assumeIsolated {
|
||||
self.showAlert(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func showAlert(_ model: KeychainPromptAlertModel) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = model.title
|
||||
alert.informativeText = model.message
|
||||
alert.addButton(withTitle: model.primaryButtonTitle)
|
||||
|
||||
let learnMoreTarget = KeychainPromptLearnMoreTarget(documentationURL: model.documentationURL)
|
||||
let learnMoreButton = NSButton(
|
||||
title: model.learnMoreButtonTitle,
|
||||
target: learnMoreTarget,
|
||||
action: #selector(KeychainPromptLearnMoreTarget.openDocumentation))
|
||||
learnMoreButton.isBordered = false
|
||||
learnMoreButton.contentTintColor = .linkColor
|
||||
learnMoreButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
learnMoreButton.sizeToFit()
|
||||
alert.accessoryView = learnMoreButton
|
||||
|
||||
withExtendedLifetime(learnMoreTarget) {
|
||||
_ = alert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol KimiK2TokenStoring: Sendable {
|
||||
func loadToken() throws -> String?
|
||||
func storeToken(_ token: String?) throws
|
||||
}
|
||||
|
||||
enum KimiK2TokenStoreError: LocalizedError {
|
||||
case keychainStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .keychainStatus(status):
|
||||
"Keychain error: \(status)"
|
||||
case .invalidData:
|
||||
"Keychain returned invalid data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainKimiK2TokenStore: KimiK2TokenStoring {
|
||||
private static let log = CodexBarLog.logger(LogCategories.kimiK2TokenStore)
|
||||
|
||||
private let service = "com.steipete.CodexBar"
|
||||
private let account = "kimi-k2-api-token"
|
||||
|
||||
func loadToken() throws -> String? {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token load")
|
||||
return nil
|
||||
}
|
||||
var result: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
if case .interactionRequired = KeychainAccessPreflight
|
||||
.checkGenericPassword(service: self.service, account: self.account)
|
||||
{
|
||||
KeychainPromptHandler.handler?(KeychainPromptContext(
|
||||
kind: .kimiK2Token,
|
||||
service: self.service,
|
||||
account: self.account))
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
Self.log.error("Keychain read failed: \(status)")
|
||||
throw KimiK2TokenStoreError.keychainStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw KimiK2TokenStoreError.invalidData
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let token, !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeToken(_ token: String?) throws {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token store")
|
||||
return
|
||||
}
|
||||
let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned == nil || cleaned?.isEmpty == true {
|
||||
try self.deleteTokenIfPresent()
|
||||
return
|
||||
}
|
||||
|
||||
let data = cleaned!.data(using: .utf8)!
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
if updateStatus != errSecItemNotFound {
|
||||
Self.log.error("Keychain update failed: \(updateStatus)")
|
||||
throw KimiK2TokenStoreError.keychainStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
for (key, value) in attributes {
|
||||
addQuery[key] = value
|
||||
}
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
Self.log.error("Keychain add failed: \(addStatus)")
|
||||
throw KimiK2TokenStoreError.keychainStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteTokenIfPresent() throws {
|
||||
guard !KeychainAccessGate.isDisabled else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let status = KeychainSecurity.delete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
Self.log.error("Keychain delete failed: \(status)")
|
||||
throw KimiK2TokenStoreError.keychainStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol KimiTokenStoring: Sendable {
|
||||
func loadToken() throws -> String?
|
||||
func storeToken(_ token: String?) throws
|
||||
}
|
||||
|
||||
enum KimiTokenStoreError: LocalizedError {
|
||||
case keychainStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .keychainStatus(status):
|
||||
"Keychain error: \(status)"
|
||||
case .invalidData:
|
||||
"Keychain returned invalid data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainKimiTokenStore: KimiTokenStoring {
|
||||
private static let log = CodexBarLog.logger(LogCategories.kimiTokenStore)
|
||||
|
||||
private let service = "com.steipete.CodexBar"
|
||||
private let account = "kimi-auth-token"
|
||||
|
||||
func loadToken() throws -> String? {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token load")
|
||||
return nil
|
||||
}
|
||||
var result: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
if case .interactionRequired = KeychainAccessPreflight
|
||||
.checkGenericPassword(service: self.service, account: self.account)
|
||||
{
|
||||
KeychainPromptHandler.handler?(KeychainPromptContext(
|
||||
kind: .kimiToken,
|
||||
service: self.service,
|
||||
account: self.account))
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
Self.log.error("Keychain read failed: \(status)")
|
||||
throw KimiTokenStoreError.keychainStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw KimiTokenStoreError.invalidData
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let token, !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeToken(_ token: String?) throws {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token store")
|
||||
return
|
||||
}
|
||||
let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned == nil || cleaned?.isEmpty == true {
|
||||
try self.deleteTokenIfPresent()
|
||||
return
|
||||
}
|
||||
|
||||
let data = cleaned!.data(using: .utf8)!
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
if updateStatus != errSecItemNotFound {
|
||||
Self.log.error("Keychain update failed: \(updateStatus)")
|
||||
throw KimiTokenStoreError.keychainStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
for (key, value) in attributes {
|
||||
addQuery[key] = value
|
||||
}
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
Self.log.error("Keychain add failed: \(addStatus)")
|
||||
throw KimiTokenStoreError.keychainStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteTokenIfPresent() throws {
|
||||
guard !KeychainAccessGate.isDisabled else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let status = KeychainSecurity.delete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
Self.log.error("Keychain delete failed: \(status)")
|
||||
throw KimiTokenStoreError.keychainStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import CodexBarCore
|
||||
import ServiceManagement
|
||||
|
||||
enum LaunchAtLoginManager {
|
||||
typealias StatusProvider = () -> SMAppService.Status
|
||||
typealias RegistrationAction = () throws -> Void
|
||||
|
||||
private static let isRunningTests: Bool = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if env["XCTestConfigurationFilePath"] != nil { return true }
|
||||
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
|
||||
if env["SWIFT_TESTING"] != nil { return true }
|
||||
return NSClassFromString("XCTestCase") != nil
|
||||
}()
|
||||
|
||||
static func setEnabled(_ enabled: Bool) {
|
||||
if self.isRunningTests { return }
|
||||
let service = SMAppService.mainApp
|
||||
self.setEnabled(
|
||||
enabled,
|
||||
status: { service.status },
|
||||
register: { try service.register() },
|
||||
unregister: { try service.unregister() })
|
||||
}
|
||||
|
||||
static func setEnabled(
|
||||
_ enabled: Bool,
|
||||
status: StatusProvider,
|
||||
register: RegistrationAction,
|
||||
unregister: RegistrationAction)
|
||||
{
|
||||
do {
|
||||
if enabled {
|
||||
switch status() {
|
||||
case .enabled, .requiresApproval:
|
||||
return
|
||||
case .notRegistered, .notFound:
|
||||
try register()
|
||||
@unknown default:
|
||||
try register()
|
||||
}
|
||||
} else {
|
||||
switch status() {
|
||||
case .enabled, .requiresApproval:
|
||||
try unregister()
|
||||
case .notRegistered, .notFound:
|
||||
return
|
||||
@unknown default:
|
||||
try unregister()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
CodexBarLog.logger(LogCategories.launchAtLogin).error("Failed to update login item: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
enum LoadingPattern: String, CaseIterable, Identifiable {
|
||||
case knightRider
|
||||
case cylon
|
||||
case outsideIn
|
||||
case race
|
||||
case pulse
|
||||
case unbraid
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .knightRider: "Knight Rider"
|
||||
case .cylon: "Cylon"
|
||||
case .outsideIn: "Outside-In"
|
||||
case .race: "Race"
|
||||
case .pulse: "Pulse"
|
||||
case .unbraid: "Unbraid (logo → bars)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary offset so the lower bar moves differently.
|
||||
var secondaryOffset: Double {
|
||||
switch self {
|
||||
case .knightRider: .pi
|
||||
case .cylon: .pi / 2
|
||||
case .outsideIn: .pi
|
||||
case .race: .pi / 3
|
||||
case .pulse: .pi / 2
|
||||
case .unbraid: .pi / 2
|
||||
}
|
||||
}
|
||||
|
||||
func value(phase: Double) -> Double {
|
||||
let v: Double
|
||||
switch self {
|
||||
case .knightRider:
|
||||
v = 0.5 + 0.5 * sin(phase) // ping-pong
|
||||
case .cylon:
|
||||
let t = phase.truncatingRemainder(dividingBy: .pi * 2) / (.pi * 2)
|
||||
v = t // sawtooth 0→1
|
||||
case .outsideIn:
|
||||
v = abs(cos(phase)) // high at edges, dip center
|
||||
case .race:
|
||||
let t = (phase * 1.2).truncatingRemainder(dividingBy: .pi * 2) / (.pi * 2)
|
||||
v = t
|
||||
case .pulse:
|
||||
v = 0.4 + 0.6 * (0.5 + 0.5 * sin(phase)) // 40–100%
|
||||
case .unbraid:
|
||||
v = 0.5 + 0.5 * sin(phase) // smooth 0→1 for morph
|
||||
}
|
||||
return max(0, min(v * 100, 100))
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let codexbarDebugReplayAllAnimations = Notification.Name("codexbarDebugReplayAllAnimations")
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum CodexBarLocalizationOverride {
|
||||
@TaskLocal static var appLanguage: String?
|
||||
}
|
||||
|
||||
enum AppLanguagePreferenceMigration {
|
||||
private static let appleLanguagesKey = "AppleLanguages"
|
||||
|
||||
static func clearLegacyOverrideIfOwned(
|
||||
storedAppLanguage: String,
|
||||
defaults: UserDefaults = .standard)
|
||||
{
|
||||
let language = storedAppLanguage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !language.isEmpty,
|
||||
defaults.stringArray(forKey: self.appleLanguagesKey) == [language]
|
||||
else { return }
|
||||
|
||||
defaults.removeObject(forKey: self.appleLanguagesKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func appLanguageDefaults() -> UserDefaults {
|
||||
if Bundle.main.bundleIdentifier != nil {
|
||||
return .standard
|
||||
}
|
||||
if UserDefaults.standard.object(forKey: "appLanguage") != nil {
|
||||
return .standard
|
||||
}
|
||||
// Fallback for running outside a .app bundle (swift run / debug builds)
|
||||
return UserDefaults(suiteName: "CodexBar") ?? .standard
|
||||
}
|
||||
|
||||
private let isRunningTestsProcessAtStartup: Bool = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if env["XCTestConfigurationFilePath"] != nil { return true }
|
||||
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
|
||||
if env["SWIFT_TESTING"] != nil { return true }
|
||||
return NSClassFromString("XCTestCase") != nil
|
||||
}()
|
||||
|
||||
private func isRunningTestsProcess() -> Bool {
|
||||
isRunningTestsProcessAtStartup
|
||||
}
|
||||
|
||||
private func resolvedAppLanguage() -> String {
|
||||
if let override = CodexBarLocalizationOverride.appLanguage {
|
||||
return override
|
||||
}
|
||||
if isRunningTestsProcess() {
|
||||
return "en"
|
||||
}
|
||||
return appLanguageDefaults().string(forKey: "appLanguage") ?? ""
|
||||
}
|
||||
|
||||
func codexBarLocalizationSignature() -> String {
|
||||
resolvedAppLanguage()
|
||||
}
|
||||
|
||||
/// Resolving the `.lproj`/resource bundles repeats `Bundle(url:)`/`Bundle(path:)` filesystem lookups,
|
||||
/// which are surprisingly hot: every `L(…)` and `codexBarLocalizationSignature()` call runs them, and
|
||||
/// menu row bodies (`MetricRow`, `ProviderCostContent`, `UsageMenuCardView.Model`) re-evaluate them on
|
||||
/// every closed-menu rebuild tick on the main thread (#1347). The resolved bundles never change unless
|
||||
/// the language changes, so cache them. A single lock with compute-happening-outside-the-lock keeps the
|
||||
/// disk work off the critical section and avoids re-entrant deadlock when the localized-bundle compute
|
||||
/// closure calls back into the resource-bundle accessor.
|
||||
private enum LocalizationBundleCache {
|
||||
private static let lock = NSLock()
|
||||
private nonisolated(unsafe) static var resourceBundle: Bundle?
|
||||
private nonisolated(unsafe) static var localizedBundlesByLanguage: [String: Bundle] = [:]
|
||||
|
||||
static func defaultResourceBundle(_ compute: () -> Bundle) -> Bundle {
|
||||
self.lock.lock()
|
||||
if let resourceBundle {
|
||||
self.lock.unlock()
|
||||
return resourceBundle
|
||||
}
|
||||
self.lock.unlock()
|
||||
let computed = compute()
|
||||
self.lock.lock()
|
||||
resourceBundle = computed
|
||||
self.lock.unlock()
|
||||
return computed
|
||||
}
|
||||
|
||||
static func localizedBundle(forLanguage language: String, _ compute: () -> Bundle) -> Bundle {
|
||||
self.lock.lock()
|
||||
if let cachedLocalizedBundle = self.localizedBundlesByLanguage[language] {
|
||||
self.lock.unlock()
|
||||
return cachedLocalizedBundle
|
||||
}
|
||||
self.lock.unlock()
|
||||
let computed = compute()
|
||||
self.lock.lock()
|
||||
self.localizedBundlesByLanguage[language] = computed
|
||||
self.lock.unlock()
|
||||
return computed
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
self.lock.lock()
|
||||
self.resourceBundle = nil
|
||||
self.localizedBundlesByLanguage = [:]
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func codexBarLocalizationResourceBundle(
|
||||
mainBundle: Bundle = .main,
|
||||
bundleName: String = "CodexBar_CodexBar") -> Bundle
|
||||
{
|
||||
// Only the default (process `.main`) resolution is cached: it is constant for the lifetime of the
|
||||
// process. Custom arguments (tests) keep resolving directly so they stay isolated from the cache.
|
||||
guard mainBundle === Bundle.main, bundleName == "CodexBar_CodexBar" else {
|
||||
return resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName)
|
||||
}
|
||||
return LocalizationBundleCache.defaultResourceBundle {
|
||||
resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveLocalizationResourceBundle(mainBundle: Bundle, bundleName: String) -> Bundle {
|
||||
guard mainBundle.bundleURL.pathExtension == "app" else {
|
||||
return Bundle.module
|
||||
}
|
||||
|
||||
if let url = mainBundle.url(forResource: bundleName, withExtension: "bundle"),
|
||||
let bundle = Bundle(url: url)
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
|
||||
if let resourceURL = mainBundle.resourceURL?.absoluteURL,
|
||||
let bundle = Bundle(url: resourceURL.appendingPathComponent("\(bundleName).bundle"))
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
|
||||
return mainBundle
|
||||
}
|
||||
|
||||
private func localizedBundle() -> Bundle {
|
||||
// Keyed on the resolved language so a language switch (settings change or test override) transparently
|
||||
// re-resolves; otherwise the cached bundle is returned without touching the filesystem.
|
||||
let language = resolvedAppLanguage()
|
||||
return localizedBundle(forLanguage: language)
|
||||
}
|
||||
|
||||
private func localizedBundle(forLanguage language: String) -> Bundle {
|
||||
LocalizationBundleCache.localizedBundle(forLanguage: language) {
|
||||
resolveLocalizedBundle(forLanguage: language)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveLocalizedBundle(forLanguage language: String) -> Bundle {
|
||||
let resourceBundle = codexBarLocalizationResourceBundle()
|
||||
if !language.isEmpty {
|
||||
if let bundle = lprojBundle(named: language, in: resourceBundle) {
|
||||
return bundle
|
||||
}
|
||||
} else {
|
||||
// System mode: follow macOS language preferences
|
||||
let localizations = resourceBundle.localizations.filter { $0 != "Base" }
|
||||
let preferred = Bundle.preferredLocalizations(
|
||||
from: localizations,
|
||||
forPreferences: Locale.preferredLanguages).first
|
||||
if let preferred,
|
||||
let bundle = lprojBundle(named: preferred, in: resourceBundle)
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
// Fallback to en.lproj
|
||||
if let path = resourceBundle.path(forResource: "en", ofType: "lproj"),
|
||||
let bundle = Bundle(path: path)
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
return resourceBundle
|
||||
}
|
||||
|
||||
private func lprojBundle(named language: String, in resourceBundle: Bundle) -> Bundle? {
|
||||
let candidates = [language, language.lowercased()]
|
||||
for candidate in candidates where !candidate.isEmpty {
|
||||
if let path = resourceBundle.path(forResource: candidate, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path)
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func L(_ key: String) -> String {
|
||||
let resourceBundle = codexBarLocalizationResourceBundle()
|
||||
return codexBarLocalizedString(key, bundle: localizedBundle(), resourceBundle: resourceBundle)
|
||||
}
|
||||
|
||||
func L(_ key: String, _ arguments: CVarArg...) -> String {
|
||||
String(format: L(key), arguments: arguments)
|
||||
}
|
||||
|
||||
func L(_ key: String, language: String) -> String {
|
||||
let resourceBundle = codexBarLocalizationResourceBundle()
|
||||
let bundle = localizedBundle(forLanguage: language)
|
||||
return codexBarLocalizedString(key, bundle: bundle, resourceBundle: resourceBundle)
|
||||
}
|
||||
|
||||
func codexBarLocalizedLocale() -> Locale {
|
||||
let language = resolvedAppLanguage()
|
||||
guard !language.isEmpty else { return .current }
|
||||
switch language.lowercased() {
|
||||
case "zh-hans":
|
||||
return Locale(identifier: "zh-Hans")
|
||||
case "zh-hant":
|
||||
return Locale(identifier: "zh-Hant")
|
||||
case "pt-br":
|
||||
return Locale(identifier: "pt-BR")
|
||||
default:
|
||||
return Locale(identifier: language)
|
||||
}
|
||||
}
|
||||
|
||||
func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bundle) -> String {
|
||||
let value = bundle.localizedString(forKey: key, value: nil, table: nil)
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, value != key {
|
||||
return value
|
||||
}
|
||||
|
||||
guard bundle.bundleURL.lastPathComponent != "en.lproj",
|
||||
let englishBundle = lprojBundle(named: "en", in: resourceBundle)
|
||||
else {
|
||||
return trimmed.isEmpty ? key : value
|
||||
}
|
||||
|
||||
let fallback = englishBundle.localizedString(forKey: key, value: nil, table: nil)
|
||||
return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback
|
||||
}
|
||||
|
||||
func resetCodexBarLocalizationCache() {
|
||||
LocalizationBundleCache.reset()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func codexBarLocalizedBundleForTesting() -> Bundle {
|
||||
localizedBundle()
|
||||
}
|
||||
|
||||
func resetCodexBarLocalizationCacheForTesting() {
|
||||
resetCodexBarLocalizationCache()
|
||||
}
|
||||
#endif
|
||||
|
||||
func configureUsageFormatterLocalizationProvider() {
|
||||
UsageFormatter.setLocalizationProvider { key in
|
||||
let resourceBundle = codexBarLocalizationResourceBundle()
|
||||
return codexBarLocalizedString(key, bundle: localizedBundle(), resourceBundle: resourceBundle)
|
||||
}
|
||||
UsageFormatter.setLocaleProvider {
|
||||
codexBarLocalizedLocale()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
/// Tracks what the main thread is currently doing so hang reports can name the
|
||||
/// operation even when the stall happens in uninstrumented code.
|
||||
enum MainThreadActivityBreadcrumb {
|
||||
private final class State: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var stack: [String] = []
|
||||
}
|
||||
|
||||
private static let state = State()
|
||||
|
||||
static var current: String? {
|
||||
guard MainThreadHangWatchdog.isEnabledForCurrentProcess else { return nil }
|
||||
return self.state.lock.withLock { self.state.stack.last }
|
||||
}
|
||||
|
||||
static func push(_ label: String) {
|
||||
guard MainThreadHangWatchdog.isEnabledForCurrentProcess else { return }
|
||||
self.state.lock.withLock {
|
||||
self.state.stack.append(label)
|
||||
}
|
||||
}
|
||||
|
||||
static func pop() {
|
||||
guard MainThreadHangWatchdog.isEnabledForCurrentProcess else { return }
|
||||
self.state.lock.withLock {
|
||||
_ = self.state.stack.popLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects main-queue response delays and records breadcrumbs for the work that
|
||||
/// occupied the main thread. Long hangs launch `/usr/bin/sample` asynchronously
|
||||
/// so sampling cannot delay recovery detection or inflate the reported duration.
|
||||
final class MainThreadHangWatchdog: @unchecked Sendable {
|
||||
static let shared = MainThreadHangWatchdog()
|
||||
static let isEnabledForCurrentProcess: Bool = {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
return environment["CODEXBAR_MAIN_THREAD_HANG_WATCHDOG"] == "1" ||
|
||||
UserDefaults.standard.bool(forKey: "debugMainThreadHangWatchdog")
|
||||
#endif
|
||||
}()
|
||||
|
||||
private let logger = CodexBarLog.logger(LogCategories.app)
|
||||
private let pingInterval: TimeInterval
|
||||
private let hangThreshold: TimeInterval
|
||||
private let sampleThreshold: TimeInterval
|
||||
private let sampleCooldown: TimeInterval
|
||||
private let sampleCaptureOverride: (@Sendable () -> String?)?
|
||||
private let schedulePing: @Sendable (@escaping @Sendable () -> Void) -> Void
|
||||
private let lock = NSLock()
|
||||
private var isRunning = false
|
||||
private var lastSampleAt: Date?
|
||||
private var activeSampleProcesses: [ObjectIdentifier: Process] = [:]
|
||||
var onHangForTesting: ((TimeInterval, [String]) -> Void)?
|
||||
#if DEBUG
|
||||
var onHangDetectionForTesting: (() -> Void)?
|
||||
private var onSampleAttemptForTesting: (() -> Void)?
|
||||
#endif
|
||||
|
||||
private enum SampleCaptureResult {
|
||||
case coolingDown
|
||||
case attempted(String?)
|
||||
}
|
||||
|
||||
init(
|
||||
pingInterval: TimeInterval = 0.025,
|
||||
hangThreshold: TimeInterval = 0.15,
|
||||
sampleThreshold: TimeInterval = 2.0,
|
||||
sampleCooldown: TimeInterval = 300,
|
||||
sampleCaptureOverride: (@Sendable () -> String?)? = nil,
|
||||
schedulePing: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void = { response in
|
||||
DispatchQueue.main.async(execute: response)
|
||||
})
|
||||
{
|
||||
self.pingInterval = pingInterval
|
||||
self.hangThreshold = hangThreshold
|
||||
self.sampleThreshold = sampleThreshold
|
||||
self.sampleCooldown = sampleCooldown
|
||||
self.sampleCaptureOverride = sampleCaptureOverride
|
||||
self.schedulePing = schedulePing
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
guard !self.isRunning else { return }
|
||||
self.isRunning = true
|
||||
let thread = Thread { [weak self] in self?.run() }
|
||||
thread.name = "CodexBar.MainThreadHangWatchdog"
|
||||
thread.qualityOfService = .utility
|
||||
thread.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.lock.withLock {
|
||||
self.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldRun: Bool {
|
||||
self.lock.withLock { self.isRunning }
|
||||
}
|
||||
|
||||
private final class PingBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let semaphore = DispatchSemaphore(value: 0)
|
||||
private var _respondedAt: DispatchTime?
|
||||
|
||||
func markResponded() {
|
||||
self.lock.withLock {
|
||||
self._respondedAt = .now()
|
||||
}
|
||||
self.semaphore.signal()
|
||||
}
|
||||
|
||||
var respondedAt: DispatchTime? {
|
||||
self.lock.withLock { self._respondedAt }
|
||||
}
|
||||
|
||||
func waitForResponse(timeout: TimeInterval) -> Bool {
|
||||
self.semaphore.wait(timeout: .now() + timeout) == .success
|
||||
}
|
||||
}
|
||||
|
||||
private func run() {
|
||||
while self.shouldRun {
|
||||
let box = PingBox()
|
||||
let pingSentAt = DispatchTime.now()
|
||||
self.schedulePing { box.markResponded() }
|
||||
if !box.waitForResponse(timeout: self.hangThreshold) {
|
||||
guard self.shouldRun else { return }
|
||||
#if DEBUG
|
||||
self.onHangDetectionForTesting?()
|
||||
#endif
|
||||
self.traceHang(box: box, pingSentAt: pingSentAt)
|
||||
}
|
||||
guard self.shouldRun else { return }
|
||||
Thread.sleep(forTimeInterval: self.pingInterval)
|
||||
}
|
||||
}
|
||||
|
||||
private func traceHang(box: PingBox, pingSentAt: DispatchTime) {
|
||||
// One delayed ping can span several main-thread operations, so retain each
|
||||
// distinct breadcrumb observed until the queued ping finally executes.
|
||||
var activities: [String] = []
|
||||
func recordActivity() {
|
||||
guard activities.count < 8,
|
||||
let activity = MainThreadActivityBreadcrumb.current,
|
||||
!activities.contains(activity)
|
||||
else { return }
|
||||
activities.append(activity)
|
||||
}
|
||||
|
||||
recordActivity()
|
||||
var sampleFile: String?
|
||||
var didAttemptSample = false
|
||||
while box.respondedAt == nil, self.shouldRun {
|
||||
recordActivity()
|
||||
if !didAttemptSample, self.elapsedSeconds(since: pingSentAt) >= self.sampleThreshold {
|
||||
#if DEBUG
|
||||
self.onSampleAttemptForTesting?()
|
||||
#endif
|
||||
switch self.captureSampleIfAllowed() {
|
||||
case .coolingDown:
|
||||
break
|
||||
case let .attempted(file):
|
||||
didAttemptSample = true
|
||||
sampleFile = file
|
||||
}
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.025)
|
||||
}
|
||||
guard let respondedAt = box.respondedAt else { return }
|
||||
let duration = self.elapsedSeconds(from: pingSentAt, to: respondedAt)
|
||||
var metadata: [String: String] = [
|
||||
"durationMs": String(format: "%.0f", duration * 1000),
|
||||
"activity": activities.isEmpty ? "unknown" : activities.joined(separator: ","),
|
||||
]
|
||||
if let sampleFile {
|
||||
metadata["sampleRequested"] = sampleFile
|
||||
}
|
||||
self.logger.warning("main thread hang", metadata: metadata)
|
||||
self.onHangForTesting?(duration, activities)
|
||||
}
|
||||
|
||||
private func elapsedSeconds(since start: DispatchTime) -> TimeInterval {
|
||||
self.elapsedSeconds(from: start, to: .now())
|
||||
}
|
||||
|
||||
private func elapsedSeconds(from start: DispatchTime, to end: DispatchTime) -> TimeInterval {
|
||||
TimeInterval(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000
|
||||
}
|
||||
|
||||
private func captureSampleIfAllowed() -> SampleCaptureResult {
|
||||
let now = Date()
|
||||
let shouldCapture = self.lock.withLock {
|
||||
if self.lastSampleAt.map({ now.timeIntervalSince($0) < self.sampleCooldown }) ?? false {
|
||||
return false
|
||||
}
|
||||
self.lastSampleAt = now
|
||||
return true
|
||||
}
|
||||
guard shouldCapture else { return .coolingDown }
|
||||
|
||||
let file = if let sampleCaptureOverride {
|
||||
sampleCaptureOverride()
|
||||
} else {
|
||||
self.launchSample()
|
||||
}
|
||||
return .attempted(file)
|
||||
}
|
||||
|
||||
private func launchSample() -> String? {
|
||||
let directory = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Logs/CodexBar", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"main thread hang sample failed",
|
||||
metadata: ["error": "\(error)"])
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withFullDate, .withTime]
|
||||
let stamp = formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
||||
let file = directory.appendingPathComponent("hang-sample-\(stamp).txt")
|
||||
|
||||
let process = Process()
|
||||
let processID = ObjectIdentifier(process)
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/sample")
|
||||
process.arguments = ["\(ProcessInfo.processInfo.processIdentifier)", "3", "-file", file.path]
|
||||
process.terminationHandler = { [weak self] completedProcess in
|
||||
self?.sampleDidFinish(completedProcess, processID: processID, file: file)
|
||||
}
|
||||
self.lock.withLock {
|
||||
self.activeSampleProcesses[processID] = process
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
_ = self.lock.withLock {
|
||||
self.activeSampleProcesses.removeValue(forKey: processID)
|
||||
}
|
||||
self.logger.warning(
|
||||
"main thread hang sample failed",
|
||||
metadata: ["error": "\(error)"])
|
||||
return nil
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
private func sampleDidFinish(_ process: Process, processID: ObjectIdentifier, file: URL) {
|
||||
_ = self.lock.withLock {
|
||||
self.activeSampleProcesses.removeValue(forKey: processID)
|
||||
}
|
||||
guard process.terminationStatus == 0,
|
||||
FileManager.default.fileExists(atPath: file.path)
|
||||
else {
|
||||
self.logger.warning(
|
||||
"main thread hang sample failed",
|
||||
metadata: ["status": "\(process.terminationStatus)"])
|
||||
return
|
||||
}
|
||||
self.logger.info(
|
||||
"main thread hang sample captured",
|
||||
metadata: ["sample": file.path])
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func traceHangForTesting(
|
||||
responseDelay: TimeInterval,
|
||||
waitForSampleAttempt: Bool = false,
|
||||
responseBeforeTrace: Bool = false)
|
||||
{
|
||||
self.lock.withLock {
|
||||
self.isRunning = true
|
||||
}
|
||||
defer {
|
||||
self.lock.withLock {
|
||||
self.isRunning = false
|
||||
}
|
||||
self.onSampleAttemptForTesting = nil
|
||||
}
|
||||
|
||||
let box = PingBox()
|
||||
let pingSentAt = DispatchTime.now()
|
||||
let scheduleResponse = {
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + responseDelay) {
|
||||
box.markResponded()
|
||||
}
|
||||
}
|
||||
if responseBeforeTrace {
|
||||
Thread.sleep(forTimeInterval: responseDelay)
|
||||
box.markResponded()
|
||||
} else if waitForSampleAttempt {
|
||||
self.onSampleAttemptForTesting = scheduleResponse
|
||||
} else {
|
||||
scheduleResponse()
|
||||
}
|
||||
self.traceHang(box: box, pingSentAt: pingSentAt)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum ManagedCodexAccountCoordinatorError: Error, Equatable {
|
||||
case authenticationInProgress
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ManagedCodexAccountCoordinator {
|
||||
let service: ManagedCodexAccountService
|
||||
private(set) var isAuthenticatingManagedAccount: Bool = false
|
||||
private(set) var authenticatingManagedAccountID: UUID?
|
||||
private(set) var isRemovingManagedAccount: Bool = false
|
||||
private(set) var removingManagedAccountID: UUID?
|
||||
var onManagedAccountsDidChange: (@MainActor () -> Void)?
|
||||
|
||||
var hasConflictingManagedAccountOperationInFlight: Bool {
|
||||
self.isAuthenticatingManagedAccount || self.isRemovingManagedAccount
|
||||
}
|
||||
|
||||
init(service: ManagedCodexAccountService = ManagedCodexAccountService()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func authenticateManagedAccount(
|
||||
existingAccountID: UUID? = nil,
|
||||
timeout: TimeInterval = 120)
|
||||
async throws -> ManagedCodexAccount
|
||||
{
|
||||
guard self.isAuthenticatingManagedAccount == false else {
|
||||
throw ManagedCodexAccountCoordinatorError.authenticationInProgress
|
||||
}
|
||||
|
||||
self.isAuthenticatingManagedAccount = true
|
||||
self.authenticatingManagedAccountID = existingAccountID
|
||||
defer {
|
||||
self.isAuthenticatingManagedAccount = false
|
||||
self.authenticatingManagedAccountID = nil
|
||||
}
|
||||
|
||||
let account = try await self.service.authenticateManagedAccount(
|
||||
existingAccountID: existingAccountID,
|
||||
timeout: timeout)
|
||||
self.onManagedAccountsDidChange?()
|
||||
return account
|
||||
}
|
||||
|
||||
func removeManagedAccount(id: UUID) async throws {
|
||||
self.isRemovingManagedAccount = true
|
||||
self.removingManagedAccountID = id
|
||||
defer {
|
||||
self.isRemovingManagedAccount = false
|
||||
self.removingManagedAccountID = nil
|
||||
}
|
||||
|
||||
try await self.service.removeManagedAccount(id: id)
|
||||
self.onManagedAccountsDidChange?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
protocol ManagedCodexHomeProducing: Sendable {
|
||||
func makeHomeURL() -> URL
|
||||
func validateManagedHomeForDeletion(_ url: URL) throws
|
||||
}
|
||||
|
||||
protocol ManagedCodexLoginRunning: Sendable {
|
||||
func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result
|
||||
}
|
||||
|
||||
protocol ManagedCodexIdentityReading: Sendable {
|
||||
func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount
|
||||
}
|
||||
|
||||
protocol ManagedCodexWorkspaceResolving: Sendable {
|
||||
func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity?
|
||||
func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity]
|
||||
}
|
||||
|
||||
extension ManagedCodexWorkspaceResolving {
|
||||
func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
protocol ManagedCodexWorkspaceSelecting: Sendable {
|
||||
@MainActor
|
||||
func selectWorkspace(
|
||||
email: String,
|
||||
currentWorkspaceID: String?,
|
||||
workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?
|
||||
}
|
||||
|
||||
enum ManagedCodexAccountServiceError: Error, Equatable {
|
||||
case loginFailed(CodexLoginRunner.Result)
|
||||
case missingEmail
|
||||
case workspaceSelectionCancelled
|
||||
case unsafeManagedHome(String)
|
||||
}
|
||||
|
||||
extension ManagedCodexAccountServiceError {
|
||||
var userFacingMessage: String {
|
||||
switch self {
|
||||
case let .loginFailed(result):
|
||||
CodexLoginAlertPresentation.managedLoginFailureMessage(for: result)
|
||||
case .missingEmail:
|
||||
L("managed_login_missing_email")
|
||||
case .workspaceSelectionCancelled:
|
||||
L("workspace_selection_cancelled")
|
||||
case let .unsafeManagedHome(path):
|
||||
String(format: L("unsafe_managed_home"), path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ManagedCodexHomeFactory: ManagedCodexHomeProducing {
|
||||
let root: URL
|
||||
|
||||
init(root: URL = Self.defaultRootURL(), fileManager: FileManager = .default) {
|
||||
let standardizedRoot = root.standardizedFileURL
|
||||
if standardizedRoot.path != root.path {
|
||||
self.root = standardizedRoot
|
||||
} else {
|
||||
self.root = root
|
||||
}
|
||||
_ = fileManager
|
||||
}
|
||||
|
||||
func makeHomeURL() -> URL {
|
||||
self.root.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
}
|
||||
|
||||
func validateManagedHomeForDeletion(_ url: URL) throws {
|
||||
let rootPath = self.root.standardizedFileURL.path
|
||||
let targetPath = url.standardizedFileURL.path
|
||||
let rootPrefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
|
||||
guard targetPath.hasPrefix(rootPrefix), targetPath != rootPath else {
|
||||
throw ManagedCodexAccountServiceError.unsafeManagedHome(url.path)
|
||||
}
|
||||
}
|
||||
|
||||
static func defaultRootURL(fileManager: FileManager = .default) -> URL {
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.homeDirectoryForCurrentUser
|
||||
return base
|
||||
.appendingPathComponent("CodexBar", isDirectory: true)
|
||||
.appendingPathComponent("managed-codex-homes", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultManagedCodexLoginRunner: ManagedCodexLoginRunning {
|
||||
func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result {
|
||||
await CodexLoginRunner.run(homePath: homePath, timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultManagedCodexIdentityReader: ManagedCodexIdentityReading {
|
||||
func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount {
|
||||
let env = CodexHomeScope.scopedEnvironment(
|
||||
base: ProcessInfo.processInfo.environment,
|
||||
codexHome: homePath)
|
||||
return UsageFetcher(environment: env).loadAuthBackedCodexAccount()
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving {
|
||||
private let workspaceCache: CodexOpenAIWorkspaceIdentityCache
|
||||
|
||||
init(
|
||||
workspaceCache: CodexOpenAIWorkspaceIdentityCache = CodexOpenAIWorkspaceIdentityCache())
|
||||
{
|
||||
self.workspaceCache = workspaceCache
|
||||
}
|
||||
|
||||
func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? {
|
||||
let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID)
|
||||
?? providerAccountID
|
||||
let env = CodexHomeScope.scopedEnvironment(
|
||||
base: ProcessInfo.processInfo.environment,
|
||||
codexHome: homePath)
|
||||
|
||||
if let credentials = try? CodexOAuthCredentialsStore.load(env: env),
|
||||
let authoritativeIdentity = try? await CodexOpenAIWorkspaceResolver.resolve(credentials: credentials)
|
||||
{
|
||||
try? self.workspaceCache.store(authoritativeIdentity)
|
||||
return authoritativeIdentity
|
||||
}
|
||||
|
||||
let cachedLabel = self.workspaceCache.workspaceLabel(for: normalizedProviderAccountID)
|
||||
return CodexOpenAIWorkspaceIdentity(
|
||||
workspaceAccountID: normalizedProviderAccountID,
|
||||
workspaceLabel: cachedLabel)
|
||||
}
|
||||
|
||||
func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] {
|
||||
let env = CodexHomeScope.scopedEnvironment(
|
||||
base: ProcessInfo.processInfo.environment,
|
||||
codexHome: homePath)
|
||||
guard let credentials = try? CodexOAuthCredentialsStore.load(env: env),
|
||||
let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
for identity in identities {
|
||||
try? self.workspaceCache.store(identity)
|
||||
}
|
||||
return identities
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting {
|
||||
@MainActor
|
||||
func selectWorkspace(
|
||||
email: String,
|
||||
currentWorkspaceID: String?,
|
||||
workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?
|
||||
{
|
||||
guard workspaces.count > 1 else { return workspaces.first }
|
||||
|
||||
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false)
|
||||
let sortedWorkspaces = workspaces.sorted { lhs, rhs in
|
||||
self.workspaceTitle(lhs) < self.workspaceTitle(rhs)
|
||||
}
|
||||
for workspace in sortedWorkspaces {
|
||||
popup.addItem(withTitle: self.workspaceTitle(workspace))
|
||||
popup.lastItem?.representedObject = workspace.workspaceAccountID
|
||||
}
|
||||
if let currentWorkspaceID,
|
||||
let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID })
|
||||
{
|
||||
popup.selectItem(at: selectedIndex)
|
||||
}
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = L("Choose Codex workspace")
|
||||
alert.informativeText = String(format: L("multiple_workspaces_found"), email)
|
||||
alert.alertStyle = .informational
|
||||
alert.accessoryView = popup
|
||||
alert.addButton(withTitle: L("Add Workspace"))
|
||||
alert.addButton(withTitle: L("Cancel"))
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else {
|
||||
return nil
|
||||
}
|
||||
let selectedWorkspaceID = popup.selectedItem?.representedObject as? String
|
||||
return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID }
|
||||
}
|
||||
|
||||
private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String {
|
||||
workspace.workspaceLabel ?? workspace.workspaceAccountID
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ManagedCodexAccountService {
|
||||
private let store: any ManagedCodexAccountStoring
|
||||
private let homeFactory: any ManagedCodexHomeProducing
|
||||
private let loginRunner: any ManagedCodexLoginRunning
|
||||
private let identityReader: any ManagedCodexIdentityReading
|
||||
private let workspaceResolver: any ManagedCodexWorkspaceResolving
|
||||
private let workspaceSelector: any ManagedCodexWorkspaceSelecting
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(
|
||||
store: any ManagedCodexAccountStoring,
|
||||
homeFactory: any ManagedCodexHomeProducing,
|
||||
loginRunner: any ManagedCodexLoginRunning,
|
||||
identityReader: any ManagedCodexIdentityReading,
|
||||
workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(),
|
||||
workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(),
|
||||
fileManager: FileManager = .default)
|
||||
{
|
||||
self.store = store
|
||||
self.homeFactory = homeFactory
|
||||
self.loginRunner = loginRunner
|
||||
self.identityReader = identityReader
|
||||
self.workspaceResolver = workspaceResolver
|
||||
self.workspaceSelector = workspaceSelector
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
convenience init(fileManager: FileManager = .default) {
|
||||
self.init(
|
||||
store: FileManagedCodexAccountStore(fileManager: fileManager),
|
||||
homeFactory: ManagedCodexHomeFactory(fileManager: fileManager),
|
||||
loginRunner: DefaultManagedCodexLoginRunner(),
|
||||
identityReader: DefaultManagedCodexIdentityReader(),
|
||||
workspaceResolver: DefaultManagedCodexWorkspaceResolver(),
|
||||
workspaceSelector: CodexWorkspaceAlertSelector(),
|
||||
fileManager: fileManager)
|
||||
}
|
||||
|
||||
func authenticateManagedAccount(
|
||||
existingAccountID: UUID? = nil,
|
||||
timeout: TimeInterval = 120)
|
||||
async throws -> ManagedCodexAccount
|
||||
{
|
||||
let snapshot = try self.store.loadAccounts()
|
||||
let homeURL = self.homeFactory.makeHomeURL()
|
||||
try self.fileManager.createDirectory(at: homeURL, withIntermediateDirectories: true)
|
||||
let account: ManagedCodexAccount
|
||||
let existingHomePathsToDelete: [String]
|
||||
|
||||
do {
|
||||
let result = await self.loginRunner.run(homePath: homeURL.path, timeout: timeout)
|
||||
guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed(result) }
|
||||
|
||||
let identity = try self.identityReader.loadAccountIdentity(homePath: homeURL.path)
|
||||
guard let rawEmail = identity.email?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawEmail.isEmpty
|
||||
else {
|
||||
throw ManagedCodexAccountServiceError.missingEmail
|
||||
}
|
||||
let authenticatedProviderAccountID: String? = switch identity.identity {
|
||||
case let .providerAccount(id):
|
||||
ManagedCodexAccount.normalizeProviderAccountID(id)
|
||||
case .emailOnly, .unresolved:
|
||||
nil
|
||||
}
|
||||
let selectedWorkspace = try await self.selectedWorkspaceIdentity(
|
||||
email: rawEmail,
|
||||
homePath: homeURL.path,
|
||||
authenticatedProviderAccountID: authenticatedProviderAccountID)
|
||||
let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID
|
||||
let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace {
|
||||
selectedWorkspace
|
||||
} else {
|
||||
await self.resolvedWorkspaceIdentity(
|
||||
homePath: homeURL.path,
|
||||
providerAccountID: providerAccountID)
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
let existing = self.reconciledExistingAccount(
|
||||
authenticatedEmail: rawEmail,
|
||||
providerAccountID: providerAccountID,
|
||||
existingAccountID: existingAccountID,
|
||||
snapshot: snapshot)
|
||||
let persistedMetadata = self.persistedProviderMetadata(
|
||||
authenticatedProviderAccountID: providerAccountID,
|
||||
resolvedWorkspaceIdentity: workspaceIdentity,
|
||||
existingAccount: existing)
|
||||
|
||||
account = ManagedCodexAccount(
|
||||
id: existing?.id ?? UUID(),
|
||||
email: rawEmail,
|
||||
providerAccountID: persistedMetadata.providerAccountID,
|
||||
workspaceLabel: persistedMetadata.workspaceLabel,
|
||||
workspaceAccountID: persistedMetadata.workspaceAccountID,
|
||||
authFingerprint: CodexAuthFingerprint.fingerprint(
|
||||
homePath: homeURL.path,
|
||||
fileManager: self.fileManager),
|
||||
managedHomePath: homeURL.path,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
lastAuthenticatedAt: now)
|
||||
let replacedAccountIDs = self.replacedAccountIDs(
|
||||
authenticatedEmail: rawEmail,
|
||||
providerAccountID: providerAccountID,
|
||||
existingAccountID: existingAccountID,
|
||||
matchedAccountID: existing?.id,
|
||||
snapshot: snapshot)
|
||||
existingHomePathsToDelete = snapshot.accounts
|
||||
.filter { replacedAccountIDs.contains($0.id) }
|
||||
.map(\.managedHomePath)
|
||||
|
||||
let updatedSnapshot = ManagedCodexAccountSet(
|
||||
version: snapshot.version,
|
||||
accounts: snapshot.accounts.filter { replacedAccountIDs.contains($0.id) == false } + [account])
|
||||
try self.store.storeAccounts(updatedSnapshot)
|
||||
} catch {
|
||||
try? self.removeManagedHomeIfSafe(atPath: homeURL.path)
|
||||
throw error
|
||||
}
|
||||
|
||||
for existingHomePathToDelete in existingHomePathsToDelete where existingHomePathToDelete != homeURL.path {
|
||||
try? self.removeManagedHomeIfSafe(atPath: existingHomePathToDelete)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func removeManagedAccount(id: UUID) async throws {
|
||||
let snapshot = try self.store.loadAccounts()
|
||||
guard let account = snapshot.account(id: id) else { return }
|
||||
|
||||
let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true)
|
||||
let canDeleteHome = (try? self.homeFactory.validateManagedHomeForDeletion(homeURL)) != nil
|
||||
|
||||
let remaining = snapshot.accounts.filter { $0.id != id }
|
||||
try self.store.storeAccounts(ManagedCodexAccountSet(
|
||||
version: snapshot.version,
|
||||
accounts: remaining))
|
||||
|
||||
if canDeleteHome, self.fileManager.fileExists(atPath: homeURL.path) {
|
||||
try? self.fileManager.removeItem(at: homeURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeManagedHomeIfSafe(atPath path: String) throws {
|
||||
let homeURL = URL(fileURLWithPath: path, isDirectory: true)
|
||||
try self.homeFactory.validateManagedHomeForDeletion(homeURL)
|
||||
if self.fileManager.fileExists(atPath: homeURL.path) {
|
||||
try self.fileManager.removeItem(at: homeURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedWorkspaceIdentity(
|
||||
email: String,
|
||||
homePath: String,
|
||||
authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity?
|
||||
{
|
||||
let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath)
|
||||
guard workspaces.count > 1 else {
|
||||
return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID }
|
||||
}
|
||||
guard let selected = await self.workspaceSelector.selectWorkspace(
|
||||
email: email,
|
||||
currentWorkspaceID: authenticatedProviderAccountID,
|
||||
workspaces: workspaces)
|
||||
else {
|
||||
throw ManagedCodexAccountServiceError.workspaceSelectionCancelled
|
||||
}
|
||||
try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath)
|
||||
return selected
|
||||
}
|
||||
|
||||
private func resolvedWorkspaceIdentity(
|
||||
homePath: String,
|
||||
providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity?
|
||||
{
|
||||
guard let providerAccountID else { return nil }
|
||||
return await self.workspaceResolver.resolveWorkspaceIdentity(
|
||||
homePath: homePath,
|
||||
providerAccountID: providerAccountID)
|
||||
}
|
||||
|
||||
private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws {
|
||||
let env = CodexHomeScope.scopedEnvironment(
|
||||
base: ProcessInfo.processInfo.environment,
|
||||
codexHome: homePath)
|
||||
let credentials = try CodexOAuthCredentialsStore.load(env: env)
|
||||
try CodexOAuthCredentialsStore.save(
|
||||
CodexOAuthCredentials(
|
||||
accessToken: credentials.accessToken,
|
||||
refreshToken: credentials.refreshToken,
|
||||
idToken: credentials.idToken,
|
||||
accountId: workspaceID,
|
||||
lastRefresh: credentials.lastRefresh),
|
||||
env: env)
|
||||
}
|
||||
|
||||
private func reconciledExistingAccount(
|
||||
authenticatedEmail: String,
|
||||
providerAccountID: String?,
|
||||
existingAccountID: UUID?,
|
||||
snapshot: ManagedCodexAccountSet)
|
||||
-> ManagedCodexAccount?
|
||||
{
|
||||
if let providerAccountID,
|
||||
let existingByProviderAccountID = snapshot.account(
|
||||
email: authenticatedEmail,
|
||||
providerAccountID: providerAccountID)
|
||||
{
|
||||
return existingByProviderAccountID
|
||||
}
|
||||
if let existingAccountID,
|
||||
let existingByID = snapshot.account(id: existingAccountID),
|
||||
existingByID.email == Self.normalizeEmail(authenticatedEmail),
|
||||
providerAccountID == nil || existingByID.providerAccountID == nil
|
||||
{
|
||||
return existingByID
|
||||
}
|
||||
guard providerAccountID == nil else {
|
||||
return nil
|
||||
}
|
||||
// Email-only reconciliation is a legacy/hardening fallback. Once an auth payload carries a
|
||||
// provider account ID, matching must stay on that ID so same-email workspaces can coexist.
|
||||
return snapshot.account(email: authenticatedEmail)
|
||||
}
|
||||
|
||||
private func replacedAccountIDs(
|
||||
authenticatedEmail: String,
|
||||
providerAccountID: String?,
|
||||
existingAccountID: UUID?,
|
||||
matchedAccountID: UUID?,
|
||||
snapshot: ManagedCodexAccountSet) -> Set<UUID>
|
||||
{
|
||||
var ids: Set<UUID> = []
|
||||
let normalizedEmail = Self.normalizeEmail(authenticatedEmail)
|
||||
if let matchedAccountID {
|
||||
ids.insert(matchedAccountID)
|
||||
}
|
||||
|
||||
if providerAccountID != nil {
|
||||
let legacySameEmailIDs = snapshot.accounts
|
||||
.filter {
|
||||
$0.id != matchedAccountID &&
|
||||
$0.providerAccountID == nil &&
|
||||
$0.email == normalizedEmail
|
||||
}
|
||||
.map(\.id)
|
||||
ids.formUnion(legacySameEmailIDs)
|
||||
}
|
||||
|
||||
guard let existingAccountID,
|
||||
existingAccountID != matchedAccountID,
|
||||
let existingByID = snapshot.account(id: existingAccountID)
|
||||
else {
|
||||
return ids
|
||||
}
|
||||
|
||||
if existingByID.providerAccountID == nil,
|
||||
existingByID.email == normalizedEmail,
|
||||
providerAccountID != nil
|
||||
{
|
||||
ids.insert(existingAccountID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
private static func normalizeEmail(_ email: String) -> String {
|
||||
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private func persistedProviderMetadata(
|
||||
authenticatedProviderAccountID: String?,
|
||||
resolvedWorkspaceIdentity: CodexOpenAIWorkspaceIdentity?,
|
||||
existingAccount: ManagedCodexAccount?) -> (
|
||||
providerAccountID: String?,
|
||||
workspaceLabel: String?,
|
||||
workspaceAccountID: String?)
|
||||
{
|
||||
if let authenticatedProviderAccountID {
|
||||
let isExistingProviderMatch = existingAccount?.providerAccountID == authenticatedProviderAccountID
|
||||
return (
|
||||
providerAccountID: authenticatedProviderAccountID,
|
||||
workspaceLabel: resolvedWorkspaceIdentity?.workspaceLabel
|
||||
?? (isExistingProviderMatch ? existingAccount?.workspaceLabel : nil),
|
||||
workspaceAccountID: resolvedWorkspaceIdentity?.workspaceAccountID ??
|
||||
(isExistingProviderMatch ? existingAccount?.workspaceAccountID : nil) ??
|
||||
authenticatedProviderAccountID)
|
||||
}
|
||||
|
||||
guard let existingAccount, existingAccount.providerAccountID != nil else {
|
||||
return (providerAccountID: nil, workspaceLabel: nil, workspaceAccountID: nil)
|
||||
}
|
||||
|
||||
return (
|
||||
providerAccountID: existingAccount.providerAccountID,
|
||||
workspaceLabel: existingAccount.workspaceLabel,
|
||||
workspaceAccountID: existingAccount.workspaceAccountID ?? existingAccount.providerAccountID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import CodexBarCore
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct MemoryPressureCacheTrimSummary: Equatable {
|
||||
var menuCardHeights = 0
|
||||
var menuWidths = 0
|
||||
var mergedSwitcherSelections = 0
|
||||
var recycledMenuCardViews = 0
|
||||
var openAIWebDebugLines = 0
|
||||
|
||||
var total: Int {
|
||||
self.menuCardHeights +
|
||||
self.menuWidths +
|
||||
self.mergedSwitcherSelections +
|
||||
self.recycledMenuCardViews +
|
||||
self.openAIWebDebugLines
|
||||
}
|
||||
|
||||
var metadata: [String: String] {
|
||||
[
|
||||
"menuCardHeights": "\(self.menuCardHeights)",
|
||||
"menuWidths": "\(self.menuWidths)",
|
||||
"mergedSwitcherSelections": "\(self.mergedSwitcherSelections)",
|
||||
"recycledMenuCardViews": "\(self.recycledMenuCardViews)",
|
||||
"openAIWebDebugLines": "\(self.openAIWebDebugLines)",
|
||||
"total": "\(self.total)",
|
||||
]
|
||||
}
|
||||
|
||||
mutating func merge(_ other: MemoryPressureCacheTrimSummary) {
|
||||
self.menuCardHeights += other.menuCardHeights
|
||||
self.menuWidths += other.menuWidths
|
||||
self.mergedSwitcherSelections += other.mergedSwitcherSelections
|
||||
self.recycledMenuCardViews += other.recycledMenuCardViews
|
||||
self.openAIWebDebugLines += other.openAIWebDebugLines
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MemoryPressureMonitor {
|
||||
typealias CacheTrimHandler = @MainActor () -> MemoryPressureCacheTrimSummary
|
||||
|
||||
private let logger = CodexBarLog.logger(LogCategories.memoryPressure)
|
||||
private let releaseFreeMallocPages: @Sendable () -> Void
|
||||
private let trimAppCaches: CacheTrimHandler
|
||||
private var source: DispatchSourceMemoryPressure?
|
||||
|
||||
init(
|
||||
trimAppCaches: @escaping CacheTrimHandler = { MemoryPressureCacheTrimSummary() },
|
||||
releaseFreeMallocPages: @escaping @Sendable () -> Void = {
|
||||
MemoryPressureRelief.releaseFreeMallocPages()
|
||||
})
|
||||
{
|
||||
self.trimAppCaches = trimAppCaches
|
||||
self.releaseFreeMallocPages = releaseFreeMallocPages
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.source == nil else { return }
|
||||
|
||||
let source = DispatchSource.makeMemoryPressureSource(
|
||||
eventMask: [.warning, .critical],
|
||||
queue: .global(qos: .utility))
|
||||
source.setEventHandler(handler: Self.makeEventHandler(
|
||||
source: source,
|
||||
handle: { [weak self] isWarning, isCritical in
|
||||
self?.handleMemoryPressure(isWarning: isWarning, isCritical: isCritical)
|
||||
}))
|
||||
self.source = source
|
||||
source.resume()
|
||||
}
|
||||
|
||||
nonisolated static func makeEventHandler(
|
||||
source: DispatchSourceMemoryPressure,
|
||||
handle: @escaping @MainActor @Sendable (_ isWarning: Bool, _ isCritical: Bool) -> Void)
|
||||
-> @Sendable () -> Void
|
||||
{
|
||||
self.makeEventHandler(
|
||||
eventReader: { [weak source] in source?.data ?? [] },
|
||||
handle: handle)
|
||||
}
|
||||
|
||||
nonisolated static func makeEventHandler(
|
||||
eventReader: @escaping @Sendable () -> DispatchSource.MemoryPressureEvent,
|
||||
handle: @escaping @MainActor @Sendable (_ isWarning: Bool, _ isCritical: Bool) -> Void)
|
||||
-> @Sendable () -> Void
|
||||
{
|
||||
// DispatchSource invokes this on the utility queue. Keep the handler
|
||||
// nonisolated, then hop to MainActor for app-state cleanup.
|
||||
{ @Sendable in
|
||||
let event = eventReader()
|
||||
let isWarning = event.contains(.warning)
|
||||
let isCritical = event.contains(.critical)
|
||||
Task { @MainActor in
|
||||
handle(isWarning, isCritical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.source?.cancel()
|
||||
self.source = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.source?.cancel()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func handleMemoryPressureForTesting(isWarning: Bool, isCritical: Bool) {
|
||||
self.handleMemoryPressure(isWarning: isWarning, isCritical: isCritical)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func handleMemoryPressure(isWarning: Bool, isCritical: Bool) {
|
||||
let level = if isCritical {
|
||||
"critical"
|
||||
} else if isWarning {
|
||||
"warning"
|
||||
} else {
|
||||
"normal"
|
||||
}
|
||||
self.logger.warning("System memory pressure", metadata: ["level": level])
|
||||
#if DEBUG
|
||||
let cachedWebViewsBefore = OpenAIDashboardFetcher.cachedWebViewCountForTesting()
|
||||
#endif
|
||||
OpenAIDashboardFetcher.evictIdleCachedWebViews()
|
||||
#if DEBUG
|
||||
let cachedWebViewsAfter = OpenAIDashboardFetcher.cachedWebViewCountForTesting()
|
||||
self.logger.info(
|
||||
"Memory pressure OpenAI webview cache",
|
||||
metadata: [
|
||||
"before": "\(cachedWebViewsBefore)",
|
||||
"after": "\(cachedWebViewsAfter)",
|
||||
"evicted": "\(max(0, cachedWebViewsBefore - cachedWebViewsAfter))",
|
||||
])
|
||||
#endif
|
||||
let trimSummary = self.trimAppCaches()
|
||||
if trimSummary.total > 0 {
|
||||
self.logger.info("Trimmed app caches for memory pressure", metadata: trimSummary.metadata)
|
||||
}
|
||||
let releaseFreeMallocPages = self.releaseFreeMallocPages
|
||||
Task.detached(priority: .utility) {
|
||||
releaseFreeMallocPages()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Darwin
|
||||
|
||||
enum MemoryPressureRelief {
|
||||
static func releaseFreeMallocPages() {
|
||||
_ = malloc_zone_pressure_relief(nil, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Controls what the menu bar displays when brand icon mode is enabled.
|
||||
enum MenuBarDisplayMode: String, CaseIterable, Identifiable {
|
||||
case percent
|
||||
case pace
|
||||
case both
|
||||
case resetTime
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .percent: L("display_mode_percent")
|
||||
case .pace: L("display_mode_pace")
|
||||
case .both: L("display_mode_both")
|
||||
case .resetTime: L("display_mode_reset_time")
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .percent: L("display_mode_percent_desc")
|
||||
case .pace: L("display_mode_pace_desc")
|
||||
case .both: L("display_mode_both_desc")
|
||||
case .resetTime: L("display_mode_reset_time_desc")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum MenuBarDisplayText {
|
||||
static func percentText(window: RateWindow?, showUsed: Bool) -> String? {
|
||||
guard let window else { return nil }
|
||||
let percent = showUsed ? window.usedPercent : window.remainingPercent
|
||||
return UsageFormatter.percentString(percent)
|
||||
}
|
||||
|
||||
static func paceText(pace: UsagePace?) -> String? {
|
||||
guard let pace else { return nil }
|
||||
let deltaValue = Int(abs(pace.deltaPercent).rounded())
|
||||
if deltaValue == 0 { return "0%" }
|
||||
let sign = pace.deltaPercent >= 0 ? "+" : "-"
|
||||
return "\(sign)\(deltaValue)%"
|
||||
}
|
||||
|
||||
/// Combined "session · weekly" menu-bar text shared by providers that expose both a
|
||||
/// session (5h) and weekly (7d) lane, e.g. Codex and Claude.
|
||||
static func combinedSessionWeeklyPercentText(
|
||||
sessionWindow: RateWindow?,
|
||||
weeklyWindow: RateWindow?,
|
||||
showUsed: Bool,
|
||||
resetTimeDisplayStyle: ResetTimeDisplayStyle = .countdown,
|
||||
showsResetTimeWhenExhausted: Bool = false,
|
||||
now: Date = .init())
|
||||
-> String?
|
||||
{
|
||||
var parts: [String] = []
|
||||
if let sessionWindow,
|
||||
let session = self.laneValueText(
|
||||
window: sessionWindow,
|
||||
showUsed: showUsed,
|
||||
resetTimeDisplayStyle: resetTimeDisplayStyle,
|
||||
showsResetTimeWhenExhausted: showsResetTimeWhenExhausted,
|
||||
now: now)
|
||||
{
|
||||
parts.append("\(self.sessionWindowLabel(window: sessionWindow)) \(session)")
|
||||
}
|
||||
if let weeklyWindow,
|
||||
let weekly = self.laneValueText(
|
||||
window: weeklyWindow,
|
||||
showUsed: showUsed,
|
||||
resetTimeDisplayStyle: resetTimeDisplayStyle,
|
||||
showsResetTimeWhenExhausted: showsResetTimeWhenExhausted,
|
||||
now: now)
|
||||
{
|
||||
parts.append("W \(weekly)")
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func laneValueText(
|
||||
window: RateWindow,
|
||||
showUsed: Bool,
|
||||
resetTimeDisplayStyle: ResetTimeDisplayStyle,
|
||||
showsResetTimeWhenExhausted: Bool,
|
||||
now: Date) -> String?
|
||||
{
|
||||
if let resetText = self.exhaustedResetText(
|
||||
window: window,
|
||||
enabled: showsResetTimeWhenExhausted,
|
||||
style: resetTimeDisplayStyle,
|
||||
now: now)
|
||||
{
|
||||
return resetText
|
||||
}
|
||||
return self.percentText(window: window, showUsed: showUsed)
|
||||
}
|
||||
|
||||
private static func sessionWindowLabel(window: RateWindow) -> String {
|
||||
guard let minutes = window.windowMinutes, minutes > 0 else { return "S" }
|
||||
guard minutes.isMultiple(of: 60) else { return "\(minutes)m" }
|
||||
return "\(minutes / 60)h"
|
||||
}
|
||||
|
||||
static func displayText(
|
||||
mode: MenuBarDisplayMode,
|
||||
percentWindow: RateWindow?,
|
||||
pace: UsagePace? = nil,
|
||||
showUsed: Bool,
|
||||
resetTimeDisplayStyle: ResetTimeDisplayStyle = .countdown,
|
||||
showsResetTimeWhenExhausted: Bool = false,
|
||||
now: Date = .init()) -> String?
|
||||
{
|
||||
if mode != .resetTime,
|
||||
showsResetTimeWhenExhausted,
|
||||
let percentWindow,
|
||||
percentWindow.remainingPercent <= 0
|
||||
{
|
||||
if let resetText = self.exhaustedResetText(
|
||||
window: percentWindow,
|
||||
enabled: true,
|
||||
style: resetTimeDisplayStyle,
|
||||
now: now)
|
||||
{
|
||||
return resetText
|
||||
}
|
||||
// Smart mode cannot replace an exhausted percentage unless the reset is concrete, future,
|
||||
// and schedulable. Preserve the quota signal in pace/both modes too; a pace from another
|
||||
// combined lane must not hide that this displayed lane is already exhausted.
|
||||
return self.percentText(window: percentWindow, showUsed: showUsed)
|
||||
}
|
||||
switch mode {
|
||||
case .percent:
|
||||
return self.percentText(window: percentWindow, showUsed: showUsed)
|
||||
case .pace:
|
||||
// Pace can be temporarily unavailable near a reset or when a provider omits window metadata.
|
||||
// Keep the selected quota visible instead of collapsing the status item to an icon-only state.
|
||||
return self.paceText(pace: pace)
|
||||
?? self.percentText(window: percentWindow, showUsed: showUsed)
|
||||
case .both:
|
||||
guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil }
|
||||
// Fall back to percent-only when pace is unavailable (e.g. Copilot)
|
||||
guard let paceText = Self.paceText(pace: pace) else { return percent }
|
||||
return "\(percent) · \(paceText)"
|
||||
case .resetTime:
|
||||
guard let percentWindow else { return nil }
|
||||
return self.resetTimeText(window: percentWindow, style: resetTimeDisplayStyle, now: now)
|
||||
?? self.percentText(window: percentWindow, showUsed: showUsed)
|
||||
}
|
||||
}
|
||||
|
||||
/// "↻ …" reset text for a window, or nil when it carries no usable reset metadata.
|
||||
static func resetTimeText(
|
||||
window: RateWindow,
|
||||
style: ResetTimeDisplayStyle,
|
||||
now: Date) -> String?
|
||||
{
|
||||
if let resetsAt = window.resetsAt {
|
||||
let description = switch style {
|
||||
case .countdown:
|
||||
UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
|
||||
case .absolute:
|
||||
UsageFormatter.resetDescription(from: resetsAt, now: now)
|
||||
}
|
||||
return "↻ \(description)"
|
||||
}
|
||||
if let resetDescription = self.resetMetadataText(window.resetDescription) {
|
||||
return "↻ \(resetDescription)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Smart-mode replacement: when enabled and the quota is exhausted (0% remaining, regardless of
|
||||
/// whether the display shows used or remaining), surface the reset time instead of a dead percent.
|
||||
///
|
||||
/// Requires a concrete, still-future `resetsAt`. The smart option only replaces the percent when it
|
||||
/// has a reset time it can both render as a live countdown/clock AND hand to the refresh scheduler,
|
||||
/// so the lane keeps ticking and flips back to the percentage once the reset passes. Windows with
|
||||
/// only textual reset metadata (`resetDescription`, no `resetsAt`) or an already-elapsed reset can't
|
||||
/// be scheduled, so they keep showing the percent instead of freezing on stale reset text.
|
||||
private static func exhaustedResetText(
|
||||
window: RateWindow?,
|
||||
enabled: Bool,
|
||||
style: ResetTimeDisplayStyle,
|
||||
now: Date) -> String?
|
||||
{
|
||||
guard enabled, let window, window.remainingPercent <= 0 else { return nil }
|
||||
guard let resetsAt = window.resetsAt, resetsAt > now else { return nil }
|
||||
return self.resetTimeText(window: window, style: style, now: now)
|
||||
}
|
||||
|
||||
private static func resetMetadataText(_ description: String?) -> String? {
|
||||
guard let description else { return nil }
|
||||
let trimmed = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
// RateWindow.resetDescription predates provider-specific detail fields and is also used for
|
||||
// request/token summaries. Only trust phrases that explicitly describe reset timing.
|
||||
let normalized = trimmed.lowercased()
|
||||
let resetPrefixes = [
|
||||
"reset ", "resets ", "in ", "today ", "today,", "tomorrow ", "tomorrow,", "next ",
|
||||
"expire ", "expires ", "refill ", "refills ",
|
||||
]
|
||||
let exactResetDescriptions = ["today", "tomorrow", "expired", "now", "soon"]
|
||||
return exactResetDescriptions.contains(normalized) || resetPrefixes.contains(where: normalized.hasPrefix)
|
||||
? trimmed
|
||||
: nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum MenuBarMetricWindowResolver {
|
||||
private enum Lane {
|
||||
case primary
|
||||
case secondary
|
||||
case tertiary
|
||||
}
|
||||
|
||||
static func rateWindow(
|
||||
preference: MenuBarMetricPreference,
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?,
|
||||
supportsAverage: Bool)
|
||||
-> RateWindow?
|
||||
{
|
||||
guard let snapshot else { return nil }
|
||||
switch preference {
|
||||
case .monthlyPlan:
|
||||
return snapshot.extraRateWindows?.first { $0.id == "mistral-monthly-plan" }?.window
|
||||
case .extraUsage:
|
||||
return Self.extraUsageWindow(snapshot: snapshot)
|
||||
case .tertiary:
|
||||
return Self.requestedWindow(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
lanes: Self.tertiaryOrder(for: provider))
|
||||
case .primary:
|
||||
return Self.requestedWindow(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
lanes: Self.primaryOrder(for: provider))
|
||||
case .secondary:
|
||||
return Self.requestedWindow(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
lanes: Self.secondaryOrder(for: provider))
|
||||
case .primaryAndSecondary:
|
||||
// Claude accounts that only expose an enterprise/extra-usage spend limit have no real
|
||||
// session/weekly lanes; surface the spend limit (as `.automatic` does) instead of an empty
|
||||
// or 0% placeholder lane.
|
||||
if provider == .claude, let spendLimit = Self.claudeSpendLimitWindow(snapshot: snapshot) {
|
||||
return spendLimit
|
||||
}
|
||||
return Self.mostConstrainedWindow(
|
||||
primary: snapshot.primary,
|
||||
secondary: snapshot.secondary,
|
||||
tertiary: nil)
|
||||
case .average:
|
||||
return Self.averageWindow(provider: provider, snapshot: snapshot, supportsAverage: supportsAverage)
|
||||
case .automatic:
|
||||
return Self.automaticWindow(provider: provider, snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private static func tertiaryOrder(for provider: UsageProvider) -> [Lane] {
|
||||
if provider == .zai {
|
||||
return [.tertiary, .primary, .secondary]
|
||||
}
|
||||
if provider == .perplexity || provider == .cursor || provider == .antigravity {
|
||||
return [.tertiary, .secondary, .primary]
|
||||
}
|
||||
return [.primary, .secondary]
|
||||
}
|
||||
|
||||
private static func primaryOrder(for provider: UsageProvider) -> [Lane] {
|
||||
if provider == .zai {
|
||||
return [.primary, .tertiary, .secondary]
|
||||
}
|
||||
if provider == .perplexity || provider == .antigravity {
|
||||
return [.primary, .secondary, .tertiary]
|
||||
}
|
||||
return [.primary, .secondary]
|
||||
}
|
||||
|
||||
private static func secondaryOrder(for provider: UsageProvider) -> [Lane] {
|
||||
if provider == .zai || provider == .antigravity {
|
||||
return [.secondary, .primary, .tertiary]
|
||||
}
|
||||
if provider == .perplexity {
|
||||
return [.secondary, .tertiary, .primary]
|
||||
}
|
||||
return [.secondary, .primary]
|
||||
}
|
||||
|
||||
private static func averageWindow(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot,
|
||||
supportsAverage: Bool)
|
||||
-> RateWindow?
|
||||
{
|
||||
guard supportsAverage,
|
||||
let primary = snapshot.primary,
|
||||
let secondary = snapshot.secondary
|
||||
else {
|
||||
if provider == .antigravity {
|
||||
return self.window(in: snapshot, following: [.primary, .secondary, .tertiary])
|
||||
}
|
||||
return snapshot.primary ?? snapshot.secondary
|
||||
}
|
||||
|
||||
let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2
|
||||
return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
|
||||
}
|
||||
|
||||
private static func automaticWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? {
|
||||
if provider == .antigravity {
|
||||
if let window = mostConstrainedAntigravityQuotaSummaryWindow(snapshot: snapshot) {
|
||||
return window
|
||||
}
|
||||
return self.mostConstrainedWindow(
|
||||
primary: snapshot.primary,
|
||||
secondary: snapshot.secondary,
|
||||
tertiary: snapshot.tertiary)
|
||||
?? self.mostConstrainedAntigravityLegacyExtraWindow(snapshot: snapshot)
|
||||
}
|
||||
if provider == .perplexity {
|
||||
return snapshot.automaticPerplexityWindow()
|
||||
}
|
||||
if provider == .zai {
|
||||
return self.mostConstrainedWindow(
|
||||
primary: snapshot.primary,
|
||||
secondary: snapshot.tertiary,
|
||||
tertiary: nil) ?? snapshot.secondary
|
||||
}
|
||||
if provider == .factory || provider == .kimi {
|
||||
return snapshot.secondary ?? snapshot.primary
|
||||
}
|
||||
if provider == .litellm {
|
||||
return snapshot.secondary ?? snapshot.primary
|
||||
}
|
||||
if provider == .copilot,
|
||||
let primary = snapshot.primary,
|
||||
let secondary = snapshot.secondary
|
||||
{
|
||||
return primary.usedPercent >= secondary.usedPercent ? primary : secondary
|
||||
}
|
||||
if provider == .cursor {
|
||||
return Self.mostConstrainedCursorWindow(
|
||||
total: snapshot.primary,
|
||||
auto: snapshot.secondary,
|
||||
api: snapshot.tertiary)
|
||||
}
|
||||
if provider == .minimax {
|
||||
return Self.mostConstrainedWindow(
|
||||
primary: snapshot.primary,
|
||||
secondary: snapshot.secondary,
|
||||
tertiary: snapshot.tertiary)
|
||||
}
|
||||
if provider == .claude, let spendLimit = Self.claudeSpendLimitWindow(snapshot: snapshot) {
|
||||
return spendLimit
|
||||
}
|
||||
return snapshot.primary ?? snapshot.secondary
|
||||
}
|
||||
|
||||
private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-"
|
||||
private static let antigravityCompactFallbackWindowIDPrefix = "antigravity-compact-fallback-"
|
||||
|
||||
private static func mostConstrainedAntigravityQuotaSummaryWindow(snapshot: UsageSnapshot) -> RateWindow? {
|
||||
let windows = snapshot.extraRateWindows?
|
||||
.filter { $0.usageKnown && $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) }
|
||||
.map(\.window) ?? []
|
||||
guard !windows.isEmpty else { return nil }
|
||||
|
||||
let usableWindows = windows.filter { $0.usedPercent < 100 }
|
||||
if let maxUsable = usableWindows.max(by: { $0.usedPercent < $1.usedPercent }) {
|
||||
return maxUsable
|
||||
}
|
||||
return windows.max(by: { $0.usedPercent < $1.usedPercent })
|
||||
}
|
||||
|
||||
private static func mostConstrainedAntigravityLegacyExtraWindow(snapshot: UsageSnapshot) -> RateWindow? {
|
||||
let windows = snapshot.extraRateWindows?
|
||||
.filter {
|
||||
$0.usageKnown && $0.id.hasPrefix(Self.antigravityCompactFallbackWindowIDPrefix)
|
||||
}
|
||||
.map(\.window) ?? []
|
||||
guard !windows.isEmpty else { return nil }
|
||||
|
||||
let usableWindows = windows.filter { $0.usedPercent < 100 }
|
||||
if let maxUsable = usableWindows.max(by: { $0.usedPercent < $1.usedPercent }) {
|
||||
return maxUsable
|
||||
}
|
||||
return windows.max(by: { $0.usedPercent < $1.usedPercent })
|
||||
}
|
||||
|
||||
private static func requestedWindow(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot,
|
||||
lanes: [Lane]) -> RateWindow?
|
||||
{
|
||||
self.window(in: snapshot, following: lanes)
|
||||
?? (provider == .antigravity
|
||||
? self.mostConstrainedAntigravityLegacyExtraWindow(snapshot: snapshot)
|
||||
: nil)
|
||||
}
|
||||
|
||||
private static func window(in snapshot: UsageSnapshot, following lanes: [Lane]) -> RateWindow? {
|
||||
for lane in lanes {
|
||||
if let window = self.window(in: snapshot, lane: lane) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func window(in snapshot: UsageSnapshot, lane: Lane) -> RateWindow? {
|
||||
switch lane {
|
||||
case .primary:
|
||||
snapshot.primary
|
||||
case .secondary:
|
||||
snapshot.secondary
|
||||
case .tertiary:
|
||||
snapshot.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
private static func mostConstrainedWindow(
|
||||
primary: RateWindow?,
|
||||
secondary: RateWindow?,
|
||||
tertiary: RateWindow?)
|
||||
-> RateWindow?
|
||||
{
|
||||
let windows = [primary, secondary, tertiary].compactMap(\.self)
|
||||
guard !windows.isEmpty else { return nil }
|
||||
return windows.max(by: { $0.usedPercent < $1.usedPercent })
|
||||
}
|
||||
|
||||
private static func mostConstrainedCursorWindow(
|
||||
total: RateWindow?,
|
||||
auto: RateWindow?,
|
||||
api: RateWindow?)
|
||||
-> RateWindow?
|
||||
{
|
||||
if let total, total.usedPercent >= 100 {
|
||||
return total
|
||||
}
|
||||
|
||||
let subquotaWindows = [auto, api].compactMap(\.self)
|
||||
let usableSubquotaWindows = subquotaWindows.filter { $0.usedPercent < 100 }
|
||||
if !subquotaWindows.isEmpty, usableSubquotaWindows.isEmpty {
|
||||
return subquotaWindows.max(by: { $0.usedPercent < $1.usedPercent })
|
||||
}
|
||||
|
||||
return ([total].compactMap(\.self) + usableSubquotaWindows)
|
||||
.max(by: { $0.usedPercent < $1.usedPercent })
|
||||
}
|
||||
|
||||
/// The Claude spend-limit window when the account only exposes an enterprise/extra-usage spend limit
|
||||
/// and has no real session/weekly quota lanes (`primary` nil, a `.spendLimit` window, or an explicitly
|
||||
/// marked placeholder). Lets the automatic and combined metrics surface the spend limit instead of an empty
|
||||
/// or 0% placeholder lane. Returns nil for accounts that expose genuine quota lanes.
|
||||
static func claudeSpendLimitWindow(snapshot: UsageSnapshot) -> RateWindow? {
|
||||
guard self.shouldUseClaudeSpendLimit(providerCost: snapshot.providerCost, snapshot: snapshot) else {
|
||||
return nil
|
||||
}
|
||||
return self.extraUsageWindow(snapshot: snapshot)
|
||||
}
|
||||
|
||||
private static func shouldUseClaudeSpendLimit(
|
||||
providerCost: ProviderCostSnapshot?,
|
||||
snapshot: UsageSnapshot)
|
||||
-> Bool
|
||||
{
|
||||
guard providerCost?.limit ?? 0 > 0,
|
||||
snapshot.secondary == nil,
|
||||
snapshot.tertiary == nil
|
||||
else { return false }
|
||||
guard let primary = snapshot.primary else { return true }
|
||||
return primary.isSyntheticPlaceholder
|
||||
}
|
||||
|
||||
private static func extraUsageWindow(snapshot: UsageSnapshot?) -> RateWindow? {
|
||||
guard let cost = snapshot?.providerCost, cost.limit > 0 else { return nil }
|
||||
let usedPercent = max(0, min(100, (cost.used / cost.limit) * 100))
|
||||
return RateWindow(
|
||||
usedPercent: usedPercent,
|
||||
windowMinutes: nil,
|
||||
resetsAt: cost.resetsAt,
|
||||
resetDescription: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
enum MenuBarStatusItemDefaultsRepair {
|
||||
static let didRepairKey = "hasRepairedHiddenStatusItemVisibilityDefaults"
|
||||
private static let visibilityPrefix = "NSStatusItem VisibleCC "
|
||||
private static let legacyAutosavePrefix = "codexbar-"
|
||||
|
||||
static func repairHiddenVisibilityDefaultsIfNeeded(defaults: UserDefaults) -> [String] {
|
||||
guard !defaults.bool(forKey: self.didRepairKey) else { return [] }
|
||||
|
||||
let repairedKeys = defaults.dictionaryRepresentation().keys
|
||||
.filter { key in
|
||||
self.shouldRepair(key: key, value: defaults.object(forKey: key))
|
||||
}
|
||||
.sorted()
|
||||
|
||||
for key in repairedKeys {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
defaults.set(true, forKey: self.didRepairKey)
|
||||
return repairedKeys
|
||||
}
|
||||
|
||||
static func shouldRepair(key: String, value: Any?) -> Bool {
|
||||
guard key.hasPrefix(self.visibilityPrefix), self.isFalse(value) else { return false }
|
||||
let itemName = String(key.dropFirst(self.visibilityPrefix.count))
|
||||
return itemName.hasPrefix(self.legacyAutosavePrefix) || self.isDefaultStatusItemName(itemName)
|
||||
}
|
||||
|
||||
static func visibilityDefault(defaults: UserDefaults, autosaveName: String) -> Bool? {
|
||||
guard !autosaveName.isEmpty else { return nil }
|
||||
return self.boolValue(defaults.object(forKey: self.visibilityPrefix + autosaveName))
|
||||
}
|
||||
|
||||
private static func isDefaultStatusItemName(_ itemName: String) -> Bool {
|
||||
guard itemName.hasPrefix("Item-") else { return false }
|
||||
return itemName.dropFirst("Item-".count).allSatisfy(\.isNumber)
|
||||
}
|
||||
|
||||
private static func isFalse(_ value: Any?) -> Bool {
|
||||
self.boolValue(value) == false
|
||||
}
|
||||
|
||||
private static func boolValue(_ value: Any?) -> Bool? {
|
||||
switch value {
|
||||
case let number as NSNumber:
|
||||
number.boolValue
|
||||
case let bool as Bool:
|
||||
bool
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum MenuBarStatusItemPlacementPreflight {
|
||||
static let preferredPositionPrefix = "NSStatusItem Preferred Position "
|
||||
static let suspiciousPreferredPositionPadding: Double = 512
|
||||
|
||||
static func preferredPositionKey(autosaveName: String) -> String {
|
||||
"\(self.preferredPositionPrefix)\(autosaveName)"
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func prepare(
|
||||
defaults: UserDefaults,
|
||||
autosaveName: String,
|
||||
legacyDefaultItemIndex: Int? = nil,
|
||||
maximumPreferredPosition: Double? = currentMaximumPreferredPosition())
|
||||
-> Bool
|
||||
{
|
||||
let key = self.preferredPositionKey(autosaveName: autosaveName)
|
||||
var repaired = self.clearPreferredPositionIfNeeded(
|
||||
defaults: defaults,
|
||||
key: key,
|
||||
maximumPreferredPosition: maximumPreferredPosition)
|
||||
if let legacyDefaultItemIndex {
|
||||
let legacyKey = self.preferredPositionKey(autosaveName: "Item-\(legacyDefaultItemIndex)")
|
||||
repaired = self.clearPreferredPositionIfNeeded(
|
||||
defaults: defaults,
|
||||
key: legacyKey,
|
||||
maximumPreferredPosition: maximumPreferredPosition) || repaired
|
||||
}
|
||||
return repaired
|
||||
}
|
||||
|
||||
static func shouldClearPreferredPosition(_ value: Any, maximumPreferredPosition: Double?) -> Bool {
|
||||
guard let number = value as? NSNumber else { return true }
|
||||
let position = number.doubleValue
|
||||
if position <= 0 {
|
||||
return true
|
||||
}
|
||||
guard let maximumPreferredPosition else { return false }
|
||||
return position > maximumPreferredPosition + self.suspiciousPreferredPositionPadding
|
||||
}
|
||||
|
||||
private static func clearPreferredPositionIfNeeded(
|
||||
defaults: UserDefaults,
|
||||
key: String,
|
||||
maximumPreferredPosition: Double?)
|
||||
-> Bool
|
||||
{
|
||||
guard let value = defaults.object(forKey: key),
|
||||
self.shouldClearPreferredPosition(value, maximumPreferredPosition: maximumPreferredPosition)
|
||||
else { return false }
|
||||
defaults.removeObject(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func currentMaximumPreferredPosition() -> Double? {
|
||||
NSScreen.screens.map { Double($0.frame.maxX) }.max()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
struct MenuBarStatusItemWindowSnapshot: Equatable, CustomStringConvertible {
|
||||
let name: String
|
||||
let ownerName: String
|
||||
let bounds: CGRect
|
||||
let isOnscreen: Bool
|
||||
let displayBounds: CGRect?
|
||||
|
||||
var isWithinDisplayBounds: Bool {
|
||||
guard let displayBounds else { return false }
|
||||
return displayBounds.contains(self.bounds)
|
||||
}
|
||||
|
||||
var isTahoeBlockedProxy: Bool {
|
||||
self.ownerName == "Control Center"
|
||||
&& self.isOnscreen
|
||||
&& abs(self.bounds.minX) <= 1
|
||||
&& self.bounds.maxY <= 0
|
||||
&& self.bounds.width > 0
|
||||
&& self.bounds.height > 0
|
||||
&& !self.isWithinDisplayBounds
|
||||
}
|
||||
|
||||
var description: String {
|
||||
let display = self.displayBounds.map {
|
||||
"display=\(Int($0.minX)),\(Int($0.minY)) \(Int($0.width))x\(Int($0.height))"
|
||||
} ?? "display=nil"
|
||||
return "name=\(self.name),owner=\(self.ownerName),x=\(Int(self.bounds.minX)),"
|
||||
+ "w=\(Int(self.bounds.width)),onscreen=\(self.isOnscreen),"
|
||||
+ "withinDisplay=\(self.isWithinDisplayBounds),\(display)"
|
||||
}
|
||||
}
|
||||
|
||||
enum MenuBarStatusItemWindowProbe {
|
||||
static func snapshots(matching names: Set<String>) -> [MenuBarStatusItemWindowSnapshot] {
|
||||
self.snapshots(
|
||||
matching: names,
|
||||
windowInfo: self.windowInfo(),
|
||||
displayBounds: NSScreen.screens.map(\.frame))
|
||||
}
|
||||
|
||||
static func snapshots(
|
||||
matching names: Set<String>,
|
||||
windowInfo: [[String: Any]],
|
||||
displayBounds: [CGRect])
|
||||
-> [MenuBarStatusItemWindowSnapshot]
|
||||
{
|
||||
guard !names.isEmpty else { return [] }
|
||||
return windowInfo.compactMap { record in
|
||||
self.snapshot(record: record, matching: names, displayBounds: displayBounds)
|
||||
}
|
||||
}
|
||||
|
||||
private static func windowInfo() -> [[String: Any]] {
|
||||
guard let windows = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
private static func snapshot(
|
||||
record: [String: Any],
|
||||
matching names: Set<String>,
|
||||
displayBounds: [CGRect])
|
||||
-> MenuBarStatusItemWindowSnapshot?
|
||||
{
|
||||
guard let name = record[kCGWindowName as String] as? String,
|
||||
names.contains(name),
|
||||
let bounds = self.bounds(record[kCGWindowBounds as String])
|
||||
else { return nil }
|
||||
let ownerName = record[kCGWindowOwnerName as String] as? String ?? "unknown"
|
||||
let isOnscreen = (record[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue
|
||||
?? record[kCGWindowIsOnscreen as String] as? Bool
|
||||
?? false
|
||||
return MenuBarStatusItemWindowSnapshot(
|
||||
name: name,
|
||||
ownerName: ownerName,
|
||||
bounds: bounds,
|
||||
isOnscreen: isOnscreen,
|
||||
displayBounds: displayBounds.first { $0.intersects(bounds) })
|
||||
}
|
||||
|
||||
private static func bounds(_ value: Any?) -> CGRect? {
|
||||
guard let dictionary = value as? [String: Any],
|
||||
let x = self.double(dictionary["X"]),
|
||||
let y = self.double(dictionary["Y"]),
|
||||
let width = self.double(dictionary["Width"]),
|
||||
let height = self.double(dictionary["Height"])
|
||||
else { return nil }
|
||||
return CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
|
||||
private static func double(_ value: Any?) -> Double? {
|
||||
switch value {
|
||||
case let number as NSNumber:
|
||||
number.doubleValue
|
||||
case let double as Double:
|
||||
double
|
||||
case let int as Int:
|
||||
Double(int)
|
||||
case let cgFloat as CGFloat:
|
||||
Double(cgFloat)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct StatusItemVisibilitySnapshot: Equatable {
|
||||
let isVisible: Bool
|
||||
let hasButton: Bool
|
||||
let hasWindow: Bool
|
||||
let hasScreen: Bool
|
||||
let isOnCurrentScreen: Bool
|
||||
let buttonWidth: CGFloat
|
||||
|
||||
init(
|
||||
isVisible: Bool,
|
||||
hasButton: Bool,
|
||||
hasWindow: Bool,
|
||||
hasScreen: Bool,
|
||||
isOnCurrentScreen: Bool = true,
|
||||
buttonWidth: CGFloat)
|
||||
{
|
||||
self.isVisible = isVisible
|
||||
self.hasButton = hasButton
|
||||
self.hasWindow = hasWindow
|
||||
self.hasScreen = hasScreen
|
||||
self.isOnCurrentScreen = isOnCurrentScreen
|
||||
self.buttonWidth = buttonWidth
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusItemVisibilitySnapshot: CustomStringConvertible {
|
||||
var description: String {
|
||||
"visible=\(self.isVisible),button=\(self.hasButton),window=\(self.hasWindow),"
|
||||
+ "screen=\(self.hasScreen),currentScreen=\(self.isOnCurrentScreen),"
|
||||
+ "width=\(String(format: "%.1f", Double(self.buttonWidth)))"
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusItemStartupVisibilityEvidence: Equatable, CustomStringConvertible {
|
||||
let autosaveName: String
|
||||
let expectsVisibility: Bool
|
||||
let visibilityDefault: Bool?
|
||||
let snapshot: StatusItemVisibilitySnapshot
|
||||
|
||||
var description: String {
|
||||
"name=\(self.autosaveName),expected=\(self.expectsVisibility),"
|
||||
+ "default=\(self.visibilityDefault.map(String.init) ?? "unset"),\(self.snapshot)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isStatusItemBlocked(_ item: NSStatusItem) -> Bool {
|
||||
MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item))
|
||||
}
|
||||
|
||||
enum MenuBarVisibilityWatcher {
|
||||
static let guidanceShownKey = "hasShownTahoeAllowListGuidance"
|
||||
static let guidanceLastShownAtKey = "tahoeAllowListGuidanceLastShownAt"
|
||||
static let guidanceRepeatInterval: TimeInterval = 24 * 60 * 60
|
||||
static let startupFreshnessInterval: TimeInterval = 10
|
||||
static let startupCheckDelay: TimeInterval = 2
|
||||
static let screenChangeCheckDelay: Duration = .milliseconds(750)
|
||||
static let screenChangeFollowUpDelay: Duration = .seconds(2)
|
||||
static let settingsURL = URL(string: "x-apple.systempreferences:com.apple.MenuBarSettings")!
|
||||
|
||||
@MainActor
|
||||
static func visibilitySnapshot(_ item: NSStatusItem) -> StatusItemVisibilitySnapshot {
|
||||
let screen = item.button?.window?.screen
|
||||
return StatusItemVisibilitySnapshot(
|
||||
isVisible: item.isVisible,
|
||||
hasButton: item.button != nil,
|
||||
hasWindow: item.button?.window != nil,
|
||||
hasScreen: screen != nil,
|
||||
isOnCurrentScreen: screen.map(self.isCurrentScreen) ?? false,
|
||||
buttonWidth: item.button?.frame.size.width ?? 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func isCurrentScreen(_ screen: NSScreen) -> Bool {
|
||||
let screenNumber = self.screenNumber(screen)
|
||||
return NSScreen.screens.contains { candidate in
|
||||
if let screenNumber, let candidateNumber = self.screenNumber(candidate) {
|
||||
return candidateNumber == screenNumber
|
||||
}
|
||||
return candidate === screen
|
||||
}
|
||||
}
|
||||
|
||||
private static func screenNumber(_ screen: NSScreen) -> NSNumber? {
|
||||
screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
|
||||
}
|
||||
|
||||
static func isBlockedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool {
|
||||
guard snapshot.isVisible else { return false }
|
||||
guard snapshot.hasButton else { return true }
|
||||
// Menu bar managers can park status-item windows off the current screen while preserving the
|
||||
// underlying NSStatusItem. Recreating in that state makes those managers see a new item.
|
||||
return !snapshot.hasWindow || snapshot.buttonWidth <= 0
|
||||
}
|
||||
|
||||
static func isDisplacedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool {
|
||||
guard snapshot.isVisible, snapshot.hasButton, snapshot.hasWindow, snapshot.buttonWidth > 0 else {
|
||||
return false
|
||||
}
|
||||
return !snapshot.hasScreen || !snapshot.isOnCurrentScreen
|
||||
}
|
||||
|
||||
static func hasBlockedVisibleSnapshots(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
|
||||
let visibleItems = snapshots.filter(\.isVisible)
|
||||
guard !visibleItems.isEmpty else { return false }
|
||||
return visibleItems.allSatisfy { snapshot in
|
||||
self.isBlockedSnapshot(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
static func hasAnyBlockedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
|
||||
snapshots.contains { snapshot in
|
||||
snapshot.isVisible && self.isBlockedSnapshot(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
static func hasAnyDisplacedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
|
||||
snapshots.contains { snapshot in
|
||||
self.isDisplacedSnapshot(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
static func hasAnyStartupRecoveryCandidate(
|
||||
snapshots: [StatusItemVisibilitySnapshot],
|
||||
evidence: [StatusItemStartupVisibilityEvidence] = [],
|
||||
windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [],
|
||||
detectTahoeBlockedStatusItem: Bool = false)
|
||||
-> Bool
|
||||
{
|
||||
if self.hasAnyBlockedVisibleSnapshot(snapshots) {
|
||||
return true
|
||||
}
|
||||
if detectTahoeBlockedStatusItem,
|
||||
self.hasAnyTahoeHiddenNoProxyCandidate(evidence: evidence, windowSnapshots: windowSnapshots)
|
||||
{
|
||||
return true
|
||||
}
|
||||
guard detectTahoeBlockedStatusItem,
|
||||
self.hasAnyDisplacedVisibleSnapshot(snapshots),
|
||||
windowSnapshots.contains(where: \.isTahoeBlockedProxy)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func hasAnyTahoeHiddenNoProxyCandidate(
|
||||
evidence: [StatusItemStartupVisibilityEvidence],
|
||||
windowSnapshots: [MenuBarStatusItemWindowSnapshot])
|
||||
-> Bool
|
||||
{
|
||||
evidence.contains { item in
|
||||
// Tahoe can destroy the Control Center scene while leaving its enabled default behind.
|
||||
// Requiring both app intent and that default avoids treating ordinary hidden items as blocked.
|
||||
item.expectsVisibility
|
||||
&& item.visibilityDefault == true
|
||||
&& !item.snapshot.isVisible
|
||||
&& !item.snapshot.hasWindow
|
||||
&& !windowSnapshots.contains {
|
||||
$0.name == item.autosaveName && $0.isOnscreen && $0.isWithinDisplayBounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] {
|
||||
items.map { item in
|
||||
self.visibilitySnapshot(item)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func hasBlockedVisibleStatusItems(_ items: [NSStatusItem]) -> Bool {
|
||||
self.hasBlockedVisibleSnapshots(self.visibilitySnapshots(items))
|
||||
}
|
||||
|
||||
static func shouldAttemptStartupRecovery(
|
||||
appLaunchedAt: Date,
|
||||
now: Date = Date(),
|
||||
snapshots: [StatusItemVisibilitySnapshot],
|
||||
evidence: [StatusItemStartupVisibilityEvidence] = [],
|
||||
windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [],
|
||||
detectTahoeBlockedStatusItem: Bool = false)
|
||||
-> Bool
|
||||
{
|
||||
guard now.timeIntervalSince(appLaunchedAt) <= self.startupFreshnessInterval else { return false }
|
||||
return self.hasAnyStartupRecoveryCandidate(
|
||||
snapshots: snapshots,
|
||||
evidence: evidence,
|
||||
windowSnapshots: windowSnapshots,
|
||||
detectTahoeBlockedStatusItem: detectTahoeBlockedStatusItem)
|
||||
}
|
||||
|
||||
static func shouldRefreshScreenChangePlacement(
|
||||
previousScreenCount _: Int,
|
||||
currentScreenCount _: Int,
|
||||
snapshots: [StatusItemVisibilitySnapshot])
|
||||
-> Bool
|
||||
{
|
||||
self.hasAnyDisplacedVisibleSnapshot(snapshots)
|
||||
}
|
||||
|
||||
static func shouldAttemptScreenChangeRecovery(snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
|
||||
self.hasAnyBlockedVisibleSnapshot(snapshots)
|
||||
}
|
||||
|
||||
static func shouldShowGuidance(defaults: UserDefaults, now: Date = Date()) -> Bool {
|
||||
guard defaults.bool(forKey: self.guidanceShownKey) else { return true }
|
||||
let lastShownAt = defaults.double(forKey: self.guidanceLastShownAtKey)
|
||||
guard lastShownAt > 0 else { return false }
|
||||
return now.timeIntervalSince1970 - lastShownAt >= self.guidanceRepeatInterval
|
||||
}
|
||||
|
||||
static func markGuidanceShown(defaults: UserDefaults, now: Date = Date()) {
|
||||
defaults.set(true, forKey: self.guidanceShownKey)
|
||||
defaults.set(now.timeIntervalSince1970, forKey: self.guidanceLastShownAtKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func presentGuidance(
|
||||
defaults: UserDefaults,
|
||||
now: Date = Date(),
|
||||
openURL: (URL) -> Void = { NSWorkspace.shared.open($0) })
|
||||
{
|
||||
self.markGuidanceShown(defaults: defaults, now: now)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = L("CodexBar can't show its menu bar icon")
|
||||
alert.informativeText = L(
|
||||
"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. "
|
||||
+ "CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on.")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: L("Open Menu Bar Settings"))
|
||||
alert.addButton(withTitle: L("Dismiss"))
|
||||
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
openURL(self.settingsURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusItemController {
|
||||
func scheduleStartupStatusItemVisibilityCheck(appLaunchedAt: Date = Date()) {
|
||||
guard !SettingsStore.isRunningTests else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + MenuBarVisibilityWatcher.startupCheckDelay) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.checkStartupStatusItemVisibility(appLaunchedAt: appLaunchedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkStartupStatusItemVisibility(appLaunchedAt: Date, now: Date = Date()) {
|
||||
let evidence = self.startupStatusItemVisibilityEvidence()
|
||||
let snapshots = evidence.map(\.snapshot)
|
||||
let windowSnapshots = self.statusItemWindowSnapshots()
|
||||
guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
|
||||
appLaunchedAt: appLaunchedAt,
|
||||
now: now,
|
||||
snapshots: snapshots,
|
||||
evidence: evidence,
|
||||
windowSnapshots: windowSnapshots,
|
||||
detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
self.menuLogger.error(
|
||||
"Status item failed to materialize or remained detached; recreating status items",
|
||||
metadata: [
|
||||
"snapshots": snapshots.map(\.description).joined(separator: " | "),
|
||||
"evidence": evidence.map(\.description).joined(separator: " | "),
|
||||
"windows": self.statusItemWindowDiagnosticsDescription(windowSnapshots),
|
||||
])
|
||||
self.recreateStatusItemsForVisibilityRecovery()
|
||||
|
||||
let recoveredEvidence = self.startupStatusItemVisibilityEvidence()
|
||||
let recoveredSnapshots = recoveredEvidence.map(\.snapshot)
|
||||
let recoveredWindowSnapshots = self.statusItemWindowSnapshots()
|
||||
guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery(
|
||||
appLaunchedAt: appLaunchedAt,
|
||||
now: now,
|
||||
snapshots: recoveredSnapshots,
|
||||
evidence: recoveredEvidence,
|
||||
windowSnapshots: recoveredWindowSnapshots,
|
||||
detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem)
|
||||
else {
|
||||
self.menuLogger.info(
|
||||
"Status item materialized after recreation",
|
||||
metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")])
|
||||
return
|
||||
}
|
||||
|
||||
self.menuLogger.error(
|
||||
"Status item still unavailable after recreation",
|
||||
metadata: [
|
||||
"snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "),
|
||||
"evidence": recoveredEvidence.map(\.description).joined(separator: " | "),
|
||||
"windows": self.statusItemWindowDiagnosticsDescription(recoveredWindowSnapshots),
|
||||
])
|
||||
guard #available(macOS 26.0, *),
|
||||
MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now)
|
||||
else {
|
||||
return
|
||||
}
|
||||
MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults, now: now)
|
||||
}
|
||||
|
||||
@objc func handleScreenParametersDidChange(_: Notification) {
|
||||
let previousScreenCount = max(
|
||||
self.pendingScreenChangePreviousCount ?? self.lastKnownScreenCount,
|
||||
self.lastKnownScreenCount)
|
||||
let currentScreenCount = NSScreen.screens.count
|
||||
self.pendingScreenChangePreviousCount = previousScreenCount
|
||||
self.lastKnownScreenCount = currentScreenCount
|
||||
self.scheduleScreenChangeStatusItemVisibilityCheck(
|
||||
previousScreenCount: previousScreenCount,
|
||||
currentScreenCount: currentScreenCount)
|
||||
}
|
||||
|
||||
private func scheduleScreenChangeStatusItemVisibilityCheck(
|
||||
previousScreenCount: Int,
|
||||
currentScreenCount: Int)
|
||||
{
|
||||
guard !SettingsStore.isRunningTests else { return }
|
||||
self.screenChangeVisibilityTask?.cancel()
|
||||
self.screenChangeVisibilityTask = Task { @MainActor [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: MenuBarVisibilityWatcher.screenChangeCheckDelay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
self?.checkScreenChangeStatusItemVisibility(
|
||||
previousScreenCount: previousScreenCount,
|
||||
currentScreenCount: currentScreenCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkScreenChangeStatusItemVisibility(previousScreenCount: Int, currentScreenCount: Int) {
|
||||
self.pendingScreenChangePreviousCount = nil
|
||||
let settledCurrentScreenCount = NSScreen.screens.count
|
||||
self.lastKnownScreenCount = settledCurrentScreenCount
|
||||
let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems)
|
||||
if MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: snapshots) {
|
||||
self.menuLogger.error(
|
||||
"Display configuration changed; recreating status items",
|
||||
metadata: [
|
||||
"previousScreenCount": "\(previousScreenCount)",
|
||||
"currentScreenCount": "\(settledCurrentScreenCount)",
|
||||
"capturedScreenCount": "\(currentScreenCount)",
|
||||
"snapshots": snapshots.map(\.description).joined(separator: " | "),
|
||||
"windows": self.statusItemWindowDiagnosticsDescription(),
|
||||
])
|
||||
self.recreateStatusItemsForVisibilityRecovery()
|
||||
self.schedulePostScreenChangeRecoveryVerification(attempt: 1)
|
||||
return
|
||||
}
|
||||
|
||||
guard MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement(
|
||||
previousScreenCount: previousScreenCount,
|
||||
currentScreenCount: settledCurrentScreenCount,
|
||||
snapshots: snapshots)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
self.menuLogger.info(
|
||||
"Display configuration changed; refreshing existing status items",
|
||||
metadata: [
|
||||
"previousScreenCount": "\(previousScreenCount)",
|
||||
"currentScreenCount": "\(settledCurrentScreenCount)",
|
||||
"capturedScreenCount": "\(currentScreenCount)",
|
||||
"snapshots": snapshots.map(\.description).joined(separator: " | "),
|
||||
])
|
||||
self.refreshExistingStatusItemsForVisibilityRecovery()
|
||||
}
|
||||
|
||||
private func schedulePostScreenChangeRecoveryVerification(attempt: Int) {
|
||||
self.screenChangeVisibilityTask = Task { @MainActor [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: MenuBarVisibilityWatcher.screenChangeFollowUpDelay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
self?.verifyScreenChangeRecoveryIfNeeded(attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyScreenChangeRecoveryIfNeeded(attempt: Int) {
|
||||
let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems)
|
||||
guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots) else {
|
||||
self.menuLogger.info(
|
||||
"Status item recovered after display-change recovery",
|
||||
metadata: ["attempt": "\(attempt)", "snapshots": snapshots.map(\.description).joined(separator: " | ")])
|
||||
return
|
||||
}
|
||||
|
||||
self.menuLogger.error(
|
||||
"Status item still blocked after display-change recovery; recreating status items again",
|
||||
metadata: [
|
||||
"attempt": "\(attempt)",
|
||||
"snapshots": snapshots.map(\.description).joined(separator: " | "),
|
||||
"windows": self.statusItemWindowDiagnosticsDescription(),
|
||||
])
|
||||
self.recreateStatusItemsForVisibilityRecovery()
|
||||
// No further async retries: a menu bar manager may park the newly recreated item in a state
|
||||
// that still looks blocked, causing repeated NSStatusItem destruction that corrupts Control Center.
|
||||
// Instead, do one synchronous re-check to surface guidance if macOS itself is blocking the item.
|
||||
let finalSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems)
|
||||
guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(finalSnapshots) else { return }
|
||||
self.menuLogger.error(
|
||||
"Status item still blocked after display-change recovery recreation",
|
||||
metadata: [
|
||||
"snapshots": finalSnapshots.map(\.description).joined(separator: " | "),
|
||||
"windows": self.statusItemWindowDiagnosticsDescription(),
|
||||
])
|
||||
guard #available(macOS 26.0, *),
|
||||
MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults)
|
||||
else { return }
|
||||
MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults)
|
||||
}
|
||||
|
||||
private var startupVisibilityStatusItems: [NSStatusItem] {
|
||||
[self.statusItem] + Array(self.statusItems.values)
|
||||
}
|
||||
|
||||
private func startupStatusItemVisibilityEvidence() -> [StatusItemStartupVisibilityEvidence] {
|
||||
self.startupVisibilityStatusItems.map { item in
|
||||
let autosaveName = item.autosaveName ?? ""
|
||||
return StatusItemStartupVisibilityEvidence(
|
||||
autosaveName: autosaveName,
|
||||
expectsVisibility: self.expectedVisibleStatusItemAutosaveNames.contains(autosaveName),
|
||||
visibilityDefault: MenuBarStatusItemDefaultsRepair.visibilityDefault(
|
||||
defaults: self.settings.userDefaults,
|
||||
autosaveName: autosaveName),
|
||||
snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item))
|
||||
}
|
||||
}
|
||||
|
||||
private var canDetectTahoeBlockedStatusItem: Bool {
|
||||
if #available(macOS 26.0, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func statusItemWindowSnapshots() -> [MenuBarStatusItemWindowSnapshot] {
|
||||
let names = Set(self.startupVisibilityStatusItems.compactMap { item in
|
||||
item.autosaveName.isEmpty ? nil : item.autosaveName
|
||||
})
|
||||
return MenuBarStatusItemWindowProbe.snapshots(matching: names)
|
||||
}
|
||||
|
||||
private func statusItemWindowDiagnosticsDescription(
|
||||
_ snapshots: [MenuBarStatusItemWindowSnapshot]? = nil)
|
||||
-> String
|
||||
{
|
||||
let snapshots = snapshots ?? self.statusItemWindowSnapshots()
|
||||
guard !snapshots.isEmpty else { return "none" }
|
||||
return snapshots.map(\.description).joined(separator: " | ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Hosts a menu-card SwiftUI row whose selection highlight is rendered entirely by AppKit/Core
|
||||
/// Animation instead of SwiftUI, so moving the highlight while scrolling costs no SwiftUI body
|
||||
/// re-evaluation or content re-rasterization.
|
||||
///
|
||||
/// The reported Overview scroll stutter comes from driving the native selection look through SwiftUI:
|
||||
/// each scroll step flips `menuItemHighlighted`, which re-renders the entire rich row subtree
|
||||
/// (header, usage bars, storage line). A headless benchmark measured ~3–10 ms per toggle with
|
||||
/// spikes past one 120 Hz frame, matching the dropped frames in the bug report.
|
||||
///
|
||||
/// This view keeps the SwiftUI content pinned to its normal (unselected) appearance and recreates
|
||||
/// the selected look in two GPU-composited steps that never touch the SwiftUI graph:
|
||||
/// 1. an `NSVisualEffectView` with the native `.selection` material drawn behind the content, and
|
||||
/// 2. a `CIColorMatrix` content filter that maps the row's pixels to the selected text color —
|
||||
/// this matches the existing design, where every element already becomes
|
||||
/// `selectedMenuItemTextColor` when highlighted.
|
||||
/// Toggling selection then costs a layer property change (~0.05 ms) rather than a SwiftUI pass.
|
||||
@MainActor
|
||||
final class GPUSelectionHostingView<Content: View>: NSView, MenuCardHighlighting, MenuCardMeasuring {
|
||||
private let hosting: NSHostingView<MenuCardSectionContainerView<Content>>
|
||||
private let selectionView = NSVisualEffectView()
|
||||
private var tintFilter: CIFilter?
|
||||
private var isRowHighlighted = false
|
||||
private var onClick: (() -> Void)?
|
||||
private let containsInteractiveControls: Bool
|
||||
private let interactiveRegionStore: MenuCardInteractiveRegionStore?
|
||||
|
||||
private(set) var allowsMenuHighlight: Bool
|
||||
|
||||
/// Selection inset/radius mirror the SwiftUI `MenuCardSectionContainerView` highlight
|
||||
/// (`.padding(.horizontal, 6).padding(.vertical, 2)` with a 6 pt corner radius) so the AppKit
|
||||
/// background lands in the same place the SwiftUI one used to.
|
||||
private static var selectionHorizontalInset: CGFloat {
|
||||
6
|
||||
}
|
||||
|
||||
private static var selectionVerticalInset: CGFloat {
|
||||
2
|
||||
}
|
||||
|
||||
private static var selectionCornerRadius: CGFloat {
|
||||
6
|
||||
}
|
||||
|
||||
/// Short enough that a fast flick still looks crisp, long enough to read as a glide rather than
|
||||
/// a hard cut. Tunable from real-device recordings.
|
||||
private static var selectionFadeDuration: CFTimeInterval {
|
||||
0.06
|
||||
}
|
||||
|
||||
init(
|
||||
rootView: MenuCardSectionContainerView<Content>,
|
||||
allowsMenuHighlight: Bool,
|
||||
containsInteractiveControls: Bool = false,
|
||||
interactiveRegionStore: MenuCardInteractiveRegionStore? = nil,
|
||||
onClick: (() -> Void)?)
|
||||
{
|
||||
self.hosting = NSHostingView(rootView: rootView)
|
||||
self.allowsMenuHighlight = allowsMenuHighlight
|
||||
self.containsInteractiveControls = containsInteractiveControls
|
||||
self.interactiveRegionStore = interactiveRegionStore
|
||||
self.onClick = onClick
|
||||
self.tintFilter = nil
|
||||
super.init(frame: .zero)
|
||||
self.wantsLayer = true
|
||||
self.refreshTintFilter()
|
||||
self.setupSelectionView()
|
||||
self.setupHosting()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var allowsVibrancy: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: self.frame.width, height: self.hosting.intrinsicContentSize.height)
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
self.refreshTintFilter()
|
||||
}
|
||||
|
||||
/// Forward accessibility activation to the click handler, mirroring `MenuCardItemHostingView`.
|
||||
override func accessibilityRole() -> NSAccessibility.Role? {
|
||||
self.onClick == nil ? super.accessibilityRole() : .button
|
||||
}
|
||||
|
||||
override func accessibilityPerformPress() -> Bool {
|
||||
guard let onClick = self.onClick else {
|
||||
return super.accessibilityPerformPress()
|
||||
}
|
||||
onClick()
|
||||
return true
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let descendant = super.hitTest(point)
|
||||
if let descendant {
|
||||
var current: NSView? = descendant
|
||||
while let view = current, view !== self {
|
||||
if view is NSButton || view is NSControl {
|
||||
return descendant
|
||||
}
|
||||
current = view.superview
|
||||
}
|
||||
if self.hitsHostedInteractiveControl(at: point) {
|
||||
return descendant
|
||||
}
|
||||
if descendant !== self, self.onClick != nil {
|
||||
return self
|
||||
}
|
||||
}
|
||||
return descendant
|
||||
}
|
||||
|
||||
private func hitsHostedInteractiveControl(at point: NSPoint) -> Bool {
|
||||
guard self.containsInteractiveControls else { return false }
|
||||
let hostedPoint = self.hosting.convert(point, from: self)
|
||||
return self.interactiveRegionStore?.contains(
|
||||
hostedPoint,
|
||||
hostingBounds: self.hosting.bounds,
|
||||
fittedSize: self.hosting.fittingSize) == true
|
||||
}
|
||||
|
||||
private func locationInView(for event: NSEvent) -> NSPoint {
|
||||
guard self.window != nil else {
|
||||
return event.locationInWindow
|
||||
}
|
||||
return self.convert(event.locationInWindow, from: nil)
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard event.type == .leftMouseDown, self.onClick != nil else {
|
||||
super.mouseDown(with: event)
|
||||
return
|
||||
}
|
||||
guard self.bounds.contains(self.locationInView(for: event)), let window = self.window else { return }
|
||||
|
||||
// A submenu-backed NSMenuItem consumes mouseUp in its nested tracking loop before a custom
|
||||
// view receives it. Track the drag/up sequence directly so release-inside cancellation stays
|
||||
// native while the menu never gets a chance to close before the row action runs.
|
||||
var shouldInvoke = false
|
||||
window.trackEvents(
|
||||
matching: [.leftMouseDragged, .leftMouseUp],
|
||||
timeout: NSEvent.foreverDuration,
|
||||
mode: .eventTracking)
|
||||
{ [weak self] trackedEvent, stop in
|
||||
guard let self, let trackedEvent else {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
if self.primaryPressShouldYieldToMenu(for: trackedEvent) {
|
||||
// We dequeued this drag from the window; put it back so NSMenu's tracking loop can
|
||||
// continue native drag-to-submenu selection from the same event.
|
||||
window.postEvent(trackedEvent, atStart: true)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
guard let decision = self.primaryPressDecision(for: trackedEvent) else { return }
|
||||
shouldInvoke = decision
|
||||
stop.pointee = true
|
||||
}
|
||||
if shouldInvoke {
|
||||
self.onClick?()
|
||||
}
|
||||
}
|
||||
|
||||
private func primaryPressDecision(for event: NSEvent) -> Bool? {
|
||||
guard event.type == .leftMouseUp else { return nil }
|
||||
return self.bounds.contains(self.locationInView(for: event))
|
||||
}
|
||||
|
||||
private func primaryPressShouldYieldToMenu(for event: NSEvent) -> Bool {
|
||||
event.type == .leftMouseDragged && !self.bounds.contains(self.locationInView(for: event))
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
self.selectionView.frame = self.bounds.insetBy(
|
||||
dx: Self.selectionHorizontalInset,
|
||||
dy: Self.selectionVerticalInset)
|
||||
self.selectionView.layer?.cornerRadius = Self.selectionCornerRadius
|
||||
self.hosting.frame = self.bounds
|
||||
}
|
||||
|
||||
func setHighlighted(_ highlighted: Bool) {
|
||||
guard self.isRowHighlighted != highlighted else { return }
|
||||
self.isRowHighlighted = highlighted
|
||||
// Tint the content to the selected text color via a GPU color matrix; clearing the
|
||||
// filter returns it to its normal palette. No SwiftUI invalidation happens here.
|
||||
if let tintFilter {
|
||||
self.hosting.layer?.filters = highlighted ? [tintFilter] : []
|
||||
}
|
||||
// Crossfade the selection background instead of hard-cutting it. As the wheel moves the
|
||||
// highlight, the leaving row fades out while the arriving row fades in, which reads as the
|
||||
// selection gliding between rows rather than teleporting. The fade is short so fast flicks
|
||||
// still resolve crisply. Runs entirely on the GPU via Core Animation.
|
||||
let layer = self.selectionView.layer
|
||||
let fade = CABasicAnimation(keyPath: "opacity")
|
||||
fade.fromValue = layer?.presentation()?.opacity ?? (highlighted ? 0 : 1)
|
||||
fade.toValue = highlighted ? 1 : 0
|
||||
fade.duration = Self.selectionFadeDuration
|
||||
fade.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
layer?.add(fade, forKey: "selectionFade")
|
||||
layer?.opacity = highlighted ? 1 : 0
|
||||
}
|
||||
|
||||
func measuredHeight(width: CGFloat) -> CGFloat {
|
||||
self.hosting.frame = NSRect(origin: self.hosting.frame.origin, size: NSSize(width: width, height: 1))
|
||||
self.hosting.layoutSubtreeIfNeeded()
|
||||
return self.hosting.fittingSize.height
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// True once the menu marks this row highlighted via `setHighlighted`.
|
||||
var isHighlightedForTesting: Bool {
|
||||
self.isRowHighlighted
|
||||
}
|
||||
|
||||
/// The hosted SwiftUI highlight state, which must stay `false` for GPU-selected rows — proving
|
||||
/// selection never re-invalidates the SwiftUI graph while scrolling.
|
||||
var swiftUIHighlightStateIsHighlightedForTesting: Bool {
|
||||
self.hosting.rootView.highlightState.isHighlighted
|
||||
}
|
||||
#endif
|
||||
|
||||
private func setupSelectionView() {
|
||||
self.selectionView.material = .selection
|
||||
self.selectionView.blendingMode = .withinWindow
|
||||
self.selectionView.state = .active
|
||||
self.selectionView.isEmphasized = true
|
||||
self.selectionView.wantsLayer = true
|
||||
self.selectionView.layer?.masksToBounds = true
|
||||
// Visibility is driven by layer opacity (crossfaded in `setHighlighted`) rather than
|
||||
// `isHidden`, so the selection can glide in and out instead of hard-cutting.
|
||||
self.selectionView.layer?.opacity = 0
|
||||
self.selectionView.autoresizingMask = [.width, .height]
|
||||
self.addSubview(self.selectionView)
|
||||
}
|
||||
|
||||
private func setupHosting() {
|
||||
self.hosting.wantsLayer = true
|
||||
self.hosting.autoresizingMask = [.width, .height]
|
||||
self.addSubview(self.hosting)
|
||||
}
|
||||
|
||||
/// Maps every pixel's RGB to the system selected-menu-item text color while preserving alpha,
|
||||
/// reproducing the appearance the SwiftUI rows already adopt when highlighted. The bias is read
|
||||
/// from `NSColor.selectedMenuItemTextColor` rather than hard-coded to white so graphite/
|
||||
/// high-contrast/accessibility appearances tint correctly. Core Image runs this on the GPU
|
||||
/// (Metal), so it composites for free per frame.
|
||||
private func refreshTintFilter() {
|
||||
self.tintFilter = Self.makeSelectedTextTintFilter(appearance: self.effectiveAppearance)
|
||||
if self.isRowHighlighted {
|
||||
self.hosting.layer?.filters = self.tintFilter.map { [$0] } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeSelectedTextTintFilter(appearance: NSAppearance) -> CIFilter? {
|
||||
guard let filter = CIFilter(name: "CIColorMatrix") else { return nil }
|
||||
var tint: NSColor = .white
|
||||
appearance.performAsCurrentDrawingAppearance {
|
||||
tint = NSColor.selectedMenuItemTextColor.usingColorSpace(.deviceRGB) ?? .white
|
||||
}
|
||||
filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputRVector")
|
||||
filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputGVector")
|
||||
filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputBVector")
|
||||
filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector")
|
||||
filter.setValue(
|
||||
CIVector(x: tint.redComponent, y: tint.greenComponent, z: tint.blueComponent, w: 0),
|
||||
forKey: "inputBiasVector")
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GPUSelectionHostingView {
|
||||
func _test_hitsHostedInteractiveControl(at point: NSPoint) -> Bool {
|
||||
self.hitsHostedInteractiveControl(at: point)
|
||||
}
|
||||
|
||||
func _test_simulateRuntimeClick(at point: NSPoint? = nil) -> Bool {
|
||||
let clickPoint = point ?? NSPoint(x: self.bounds.midX, y: self.bounds.midY)
|
||||
guard let onClick = self.onClick, self.hitTest(clickPoint) === self else { return false }
|
||||
guard self.bounds.contains(clickPoint) else { return false }
|
||||
onClick()
|
||||
return true
|
||||
}
|
||||
|
||||
func _test_primaryPressDecision(for event: NSEvent) -> Bool? {
|
||||
self.primaryPressDecision(for: event)
|
||||
}
|
||||
|
||||
func _test_primaryPressShouldYieldToMenu(for event: NSEvent) -> Bool {
|
||||
self.primaryPressShouldYieldToMenu(for: event)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
func heightFingerprint(section: String, additional: [String] = []) -> String {
|
||||
let notesFingerprint = MenuCardHeightFingerprint.join(self.usageNotes.map {
|
||||
MenuCardHeightFingerprint.field("note", $0)
|
||||
})
|
||||
return MenuCardHeightFingerprint.join([
|
||||
"section=\(section)",
|
||||
"provider=\(self.provider.rawValue)",
|
||||
"localization=\(codexBarLocalizationSignature())",
|
||||
MenuCardHeightFingerprint.field("name", self.providerName),
|
||||
MenuCardHeightFingerprint.field("email", self.email),
|
||||
MenuCardHeightFingerprint.field("subtitle", self.subtitleText),
|
||||
"subtitleStyle=\(self.subtitleStyle.heightFingerprint)",
|
||||
MenuCardHeightFingerprint.field("plan", self.planText),
|
||||
MenuCardHeightFingerprint.field("placeholder", self.placeholder),
|
||||
MenuCardHeightFingerprint.field("credits", self.creditsText),
|
||||
"creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")",
|
||||
MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText),
|
||||
MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText),
|
||||
"codexResetCredits=\(self.codexResetCredits?.heightFingerprint ?? "")",
|
||||
"metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))",
|
||||
"notes=\(notesFingerprint)",
|
||||
"dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")",
|
||||
"providerCost=\(self.providerCost?.heightFingerprint ?? "")",
|
||||
"tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")",
|
||||
"openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")",
|
||||
] + additional)
|
||||
}
|
||||
|
||||
static func heightFingerprintField(_ name: String, _ value: String?) -> String {
|
||||
MenuCardHeightFingerprint.field(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
private enum MenuCardHeightFingerprint {
|
||||
private static let hashSalt = UUID()
|
||||
|
||||
static func join(_ values: [String]) -> String {
|
||||
values.map { "\($0.count):\($0)" }.joined(separator: "|")
|
||||
}
|
||||
|
||||
static func field(_ name: String, _ value: String?) -> String {
|
||||
guard let value else {
|
||||
return "\(name)=nil"
|
||||
}
|
||||
return "\(name)=\(Self.stringShape(value))"
|
||||
}
|
||||
|
||||
private static func stringShape(_ value: String) -> String {
|
||||
var hasher = Hasher()
|
||||
hasher.combine(Self.hashSalt)
|
||||
hasher.combine(value)
|
||||
let digest = String(UInt(bitPattern: hasher.finalize()), radix: 16)
|
||||
return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(Self.lineCount(value)),hash:\(digest)"
|
||||
}
|
||||
|
||||
private static func lineCount(_ value: String) -> Int {
|
||||
guard !value.isEmpty else { return 0 }
|
||||
return value.utf8.reduce(1) { count, byte in
|
||||
byte == 10 ? count + 1 : count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model.SubtitleStyle {
|
||||
fileprivate var heightFingerprint: String {
|
||||
switch self {
|
||||
case .info: "info"
|
||||
case .loading: "loading"
|
||||
case .error: "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model.Metric {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
self.id,
|
||||
MenuCardHeightFingerprint.field("title", self.title),
|
||||
"percent=\(Int(self.percent.rounded()))",
|
||||
"percentStyle=\(self.percentStyle.rawValue)",
|
||||
MenuCardHeightFingerprint.field("status", self.statusText),
|
||||
MenuCardHeightFingerprint.field("reset", self.resetText),
|
||||
MenuCardHeightFingerprint.field("detail", self.detailText),
|
||||
MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText),
|
||||
MenuCardHeightFingerprint.field("detailRight", self.detailRightText),
|
||||
self.pacePercent == nil ? "pace=0" : "pace=1",
|
||||
self.paceOnTop ? "paceTop=1" : "paceTop=0",
|
||||
self.cardStyle ? "card=1" : "card=0",
|
||||
"warningMarkers=\(self.warningMarkerPercents.count)",
|
||||
"workdayMarkers=\(self.workdayMarkerPercents.count)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model.ProviderCostSection {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
MenuCardHeightFingerprint.field("title", self.title),
|
||||
MenuCardHeightFingerprint.field("spend", self.spendLine),
|
||||
MenuCardHeightFingerprint.field("percentLine", self.percentLine),
|
||||
MenuCardHeightFingerprint.field("personalSpend", self.personalSpendLine),
|
||||
self.percentUsed == nil ? "percent=0" : "percent=1",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model.TokenUsageSection {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
MenuCardHeightFingerprint.field("session", self.sessionLine),
|
||||
MenuCardHeightFingerprint.field("month", self.monthLine),
|
||||
MenuCardHeightFingerprint.field("comparisons", self.comparisonLines.joined(separator: "|")),
|
||||
MenuCardHeightFingerprint.field("hint", self.hintLine),
|
||||
MenuCardHeightFingerprint.field("error", self.errorLine),
|
||||
MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension CodexResetCreditsPresentation {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
MenuCardHeightFingerprint.field("text", self.text),
|
||||
MenuCardHeightFingerprint.field("expirySummary", self.expirySummaryText),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension InlineUsageDashboardModel {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
MenuCardHeightFingerprint.field("accessibility", self.accessibilityLabel),
|
||||
self.valueStyle.heightFingerprint,
|
||||
MenuCardHeightFingerprint.join(self.kpis.map(\.heightFingerprint)),
|
||||
MenuCardHeightFingerprint.join(self.points.map(\.heightFingerprint)),
|
||||
MenuCardHeightFingerprint.join(self.detailLines.map { MenuCardHeightFingerprint.field("detail", $0) }),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension InlineUsageDashboardModel.KPI {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
MenuCardHeightFingerprint.field("title", self.title),
|
||||
MenuCardHeightFingerprint.field("value", self.value),
|
||||
self.emphasis ? "1" : "0",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension InlineUsageDashboardModel.Point {
|
||||
fileprivate var heightFingerprint: String {
|
||||
MenuCardHeightFingerprint.join([
|
||||
self.id,
|
||||
MenuCardHeightFingerprint.field("label", self.label),
|
||||
MenuCardHeightFingerprint.field("accessibilityValue", self.accessibilityValue),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension InlineUsageDashboardModel.ValueStyle {
|
||||
fileprivate var heightFingerprint: String {
|
||||
switch self {
|
||||
case .currencyUSD:
|
||||
"currencyUSD"
|
||||
case let .currency(symbol):
|
||||
"currency:\(symbol)"
|
||||
case .tokens:
|
||||
"tokens"
|
||||
case .points:
|
||||
"points"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import CodexBarCore
|
||||
|
||||
extension CodexConsumerProjection.RateLane {
|
||||
var quotaWarningWindow: QuotaWarningWindow {
|
||||
switch self {
|
||||
case .session:
|
||||
.session
|
||||
case .weekly:
|
||||
.weekly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
static func warningMarkerPercents(thresholds: [Int]?, showUsed: Bool) -> [Double] {
|
||||
guard let thresholds, !thresholds.isEmpty else { return [] }
|
||||
return QuotaWarningThresholds.active(thresholds)
|
||||
.map { showUsed ? 100 - Double($0) : Double($0) }
|
||||
.filter { $0 > 0 && $0 < 100 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns boundary percentages for work day markers on a weekly progress bar.
|
||||
/// Only valid when windowMinutes == 10080 (standard 7-day week).
|
||||
/// nil workDays means feature is disabled.
|
||||
func workDayMarkerPercents(workDays: Int?, windowMinutes: Int?) -> [Double] {
|
||||
guard workDays != nil, windowMinutes == 10080 else { return [] }
|
||||
guard let wd = workDays, wd >= 2, wd <= 7 else { return [] }
|
||||
return (1..<wd).map { Double($0) * 100.0 / Double(wd) }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import CodexBarCore
|
||||
import Observation
|
||||
|
||||
struct MenuCardLiveSubtitle {
|
||||
let text: String
|
||||
let style: UsageMenuCardView.Model.SubtitleStyle
|
||||
}
|
||||
|
||||
/// Updates values in an already-hosted card without rebuilding its tracked NSMenu.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MenuCardRefreshMonitor {
|
||||
typealias ModelResolver = @MainActor (UsageProvider) -> UsageMenuCardView.Model?
|
||||
typealias ProviderRefreshStateResolver = @MainActor (UsageProvider) -> Bool
|
||||
|
||||
private let resolveModel: ModelResolver
|
||||
private let isProviderRefreshActive: ProviderRefreshStateResolver
|
||||
/// Set while an all-providers refresh is running; individual cards freeze only while their
|
||||
/// provider has active refresh work.
|
||||
private var globalManualRefreshInFlight = false
|
||||
/// Providers with an individual manual refresh in flight. Concurrent entries are allowed so
|
||||
/// refreshing one provider does not stall or unfreeze another.
|
||||
private var manualRefreshProviders: Set<UsageProvider> = []
|
||||
private var frozenManualRefreshModels: [UsageProvider: UsageMenuCardView.Model] = [:]
|
||||
|
||||
/// True while any manual refresh (global or per-provider) is running.
|
||||
var isManualRefreshInFlight: Bool {
|
||||
self.globalManualRefreshInFlight || !self.manualRefreshProviders.isEmpty
|
||||
}
|
||||
|
||||
init(
|
||||
resolveModel: @escaping ModelResolver,
|
||||
isProviderRefreshActive: @escaping ProviderRefreshStateResolver)
|
||||
{
|
||||
self.resolveModel = resolveModel
|
||||
self.isProviderRefreshActive = isProviderRefreshActive
|
||||
}
|
||||
|
||||
func beginManualRefresh(
|
||||
frozenModels: [UsageProvider: UsageMenuCardView.Model],
|
||||
provider: UsageProvider? = nil)
|
||||
{
|
||||
if let provider {
|
||||
self.frozenManualRefreshModels[provider] = frozenModels[provider]
|
||||
self.manualRefreshProviders.insert(provider)
|
||||
} else {
|
||||
self.frozenManualRefreshModels = frozenModels
|
||||
self.globalManualRefreshInFlight = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Balances a `beginManualRefresh` with the same `provider` argument (nil ends the global refresh).
|
||||
func endManualRefresh(for provider: UsageProvider? = nil) {
|
||||
if let provider {
|
||||
self.manualRefreshProviders.remove(provider)
|
||||
self.frozenManualRefreshModels[provider] = nil
|
||||
} else {
|
||||
self.globalManualRefreshInFlight = false
|
||||
self.frozenManualRefreshModels.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
func resetManualRefresh() {
|
||||
self.globalManualRefreshInFlight = false
|
||||
self.manualRefreshProviders.removeAll(keepingCapacity: true)
|
||||
self.frozenManualRefreshModels.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
func isManualRefreshInFlight(for provider: UsageProvider) -> Bool {
|
||||
self.manualRefreshProviders.contains(provider) ||
|
||||
(self.globalManualRefreshInFlight && self.isProviderRefreshActive(provider))
|
||||
}
|
||||
|
||||
func model(
|
||||
for provider: UsageProvider,
|
||||
fallback: UsageMenuCardView.Model) -> UsageMenuCardView.Model
|
||||
{
|
||||
guard !self.isManualRefreshInFlight(for: provider) else {
|
||||
guard let frozen = self.frozenManualRefreshModels[provider] else {
|
||||
return fallback
|
||||
}
|
||||
if fallback.hasCompatibleTrackedLayout(with: frozen) {
|
||||
return frozen
|
||||
}
|
||||
// A rebuilding menu may temporarily lose some metric rows, but retained rows and other sections
|
||||
// must still match the frozen layout.
|
||||
if fallback.hasCompatibleTrackedMetricSubset(of: frozen) {
|
||||
return frozen
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
guard let resolved = self.resolveModel(provider),
|
||||
fallback.hasCompatibleTrackedLayout(with: resolved)
|
||||
else {
|
||||
return fallback
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func subtitle(
|
||||
for provider: UsageProvider,
|
||||
fallback: MenuCardLiveSubtitle) -> MenuCardLiveSubtitle
|
||||
{
|
||||
if self.isManualRefreshInFlight(for: provider) {
|
||||
return MenuCardLiveSubtitle(text: "\(L("Refreshing"))…", style: .loading)
|
||||
}
|
||||
guard let model = self.resolveModel(provider) else { return fallback }
|
||||
return MenuCardLiveSubtitle(text: model.subtitleText, style: model.subtitleStyle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
struct CodexResetCreditPresentationItem: Equatable {
|
||||
let expiryText: String
|
||||
let compactExpiryText: String
|
||||
}
|
||||
|
||||
struct CodexResetCreditsPresentation: Equatable {
|
||||
let text: String
|
||||
let items: [CodexResetCreditPresentationItem]
|
||||
|
||||
var expirySummaryText: String {
|
||||
let visibleItems = self.items.prefix(4).map(\.compactExpiryText)
|
||||
let hiddenCount = self.items.count - visibleItems.count
|
||||
let suffix = hiddenCount > 0 ? ["+\(hiddenCount)"] : []
|
||||
return (visibleItems + suffix).joined(separator: " · ")
|
||||
}
|
||||
|
||||
var helpText: String {
|
||||
self.items.enumerated().map { index, item in
|
||||
"\(index + 1). \(item.expiryText)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
var accessibilityLabel: String {
|
||||
[L("Limit Reset Credits"), self.text, self.helpText]
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
static func make(
|
||||
snapshot: CodexRateLimitResetCreditsSnapshot,
|
||||
resetStyle: ResetTimeDisplayStyle,
|
||||
now: Date) -> CodexResetCreditsPresentation?
|
||||
{
|
||||
let inventory = snapshot.availableInventory(at: now)
|
||||
guard !inventory.credits.isEmpty else { return nil }
|
||||
let items = inventory.credits.map { credit in
|
||||
Self.presentationItem(for: credit, resetStyle: resetStyle, now: now)
|
||||
}
|
||||
return CodexResetCreditsPresentation(
|
||||
text: Self.availableText(count: inventory.count),
|
||||
items: items)
|
||||
}
|
||||
|
||||
private static func availableText(count: Int) -> String {
|
||||
count == 1 ? L("1 available") : String(format: L("%d available"), count)
|
||||
}
|
||||
|
||||
private static func presentationItem(
|
||||
for credit: CodexRateLimitResetCredit,
|
||||
resetStyle: ResetTimeDisplayStyle,
|
||||
now: Date) -> CodexResetCreditPresentationItem
|
||||
{
|
||||
guard let expiresAt = credit.expiresAt else {
|
||||
return CodexResetCreditPresentationItem(expiryText: L("No expiry"), compactExpiryText: L("No expiry"))
|
||||
}
|
||||
let formattedTime = Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now)
|
||||
let compactExpiryText = resetStyle == .countdown && formattedTime.hasPrefix("in ")
|
||||
? String(formattedTime.dropFirst(3))
|
||||
: formattedTime
|
||||
return CodexResetCreditPresentationItem(
|
||||
expiryText: String(format: L("Expires %@"), formattedTime),
|
||||
compactExpiryText: compactExpiryText)
|
||||
}
|
||||
|
||||
private static func formattedTime(
|
||||
_ expiresAt: Date,
|
||||
resetStyle: ResetTimeDisplayStyle,
|
||||
now: Date) -> String
|
||||
{
|
||||
switch resetStyle {
|
||||
case .absolute:
|
||||
return UsageFormatter.resetDescription(from: expiresAt, now: now)
|
||||
case .countdown:
|
||||
let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now)
|
||||
return countdown == "now" ? L("now") : countdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexResetCreditsContent: View {
|
||||
let presentation: CodexResetCreditsPresentation
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(L("Limit Reset Credits"))
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.presentation.text)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 8)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Image(systemName: "clock")
|
||||
.font(.caption2)
|
||||
Text(self.presentation.expirySummaryText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.help(self.presentation.helpText)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(self.presentation.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? {
|
||||
guard input.provider == .codex,
|
||||
let resetCredits = input.snapshot?.codexResetCredits
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return CodexResetCreditsPresentation.make(
|
||||
snapshot: resetCredits,
|
||||
resetStyle: input.resetTimeDisplayStyle,
|
||||
now: input.now)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension UsageMenuCardView.Model.ProviderCostSection {
|
||||
init(
|
||||
title: String,
|
||||
percentUsed: Double?,
|
||||
spendLine: String,
|
||||
percentLine: String?)
|
||||
{
|
||||
self.init(
|
||||
title: title,
|
||||
percentUsed: percentUsed,
|
||||
spendLine: spendLine,
|
||||
percentLine: percentLine,
|
||||
personalSpendLine: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
static func sakanaPayAsYouGoSection(_ usage: SakanaPayAsYouGoSnapshot?) -> ProviderCostSection? {
|
||||
guard let usage else { return nil }
|
||||
return ProviderCostSection(
|
||||
title: L("Extra usage"),
|
||||
percentUsed: nil,
|
||||
spendLine: "\(L("Balance")): \(usage.balanceDetail)",
|
||||
percentLine: usage.periodUsageTotal.map { "\(L("Usage")): \(UsageFormatter.usdString($0))" })
|
||||
}
|
||||
|
||||
static func isRequiredOpenCodeZenBalance(_ snapshot: UsageSnapshot?) -> Bool {
|
||||
snapshot?.primary == nil &&
|
||||
snapshot?.secondary == nil &&
|
||||
snapshot?.providerCost?.period == "Zen balance"
|
||||
}
|
||||
|
||||
static func tokenUsageSnapshot(input: Input) -> CostUsageTokenSnapshot? {
|
||||
if usesProviderCostHistoryAsPrimaryDashboard(input.provider), input.snapshot != nil {
|
||||
return primaryCostHistorySnapshot(input: input)
|
||||
}
|
||||
return input.tokenSnapshot
|
||||
}
|
||||
|
||||
static func creditsLine(
|
||||
metadata: ProviderMetadata,
|
||||
snapshot: UsageSnapshot?,
|
||||
credits: CreditsSnapshot?,
|
||||
error: String?) -> String?
|
||||
{
|
||||
guard metadata.supportsCredits else { return nil }
|
||||
if metadata.id == .codex, credits == nil, error == nil { return nil }
|
||||
if metadata.id == .amp,
|
||||
let ampUsage = snapshot?.ampUsage,
|
||||
let ampCredits = self.ampCreditsLine(ampUsage)
|
||||
{
|
||||
return ampCredits
|
||||
}
|
||||
if let credits {
|
||||
if let creditLimit = credits.codexCreditLimit {
|
||||
return UsageFormatter.creditsString(from: creditLimit.remaining)
|
||||
}
|
||||
return UsageFormatter.creditsString(from: credits.remaining)
|
||||
}
|
||||
if let error, !error.isEmpty {
|
||||
return error.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return L(metadata.creditsHint)
|
||||
}
|
||||
|
||||
static func creditsProgressPercent(credits: CreditsSnapshot?) -> Double? {
|
||||
credits?.codexCreditLimit?.remainingPercent
|
||||
}
|
||||
|
||||
static func creditsScaleText(credits: CreditsSnapshot?) -> String? {
|
||||
guard let limit = credits?.codexCreditLimit else { return nil }
|
||||
return L("of %@", UsageFormatter.creditsNumberString(from: limit.limit))
|
||||
}
|
||||
|
||||
static func codexCreditLimitDetail(credits: CreditsSnapshot?, now: Date) -> String? {
|
||||
guard let limit = credits?.codexCreditLimit else { return nil }
|
||||
var parts = [
|
||||
L("%@ used", UsageFormatter.creditsNumberString(from: limit.used)),
|
||||
]
|
||||
if let resetsAt = limit.resetsAt {
|
||||
parts.append(L("resets %@", UsageFormatter.resetDescription(from: resetsAt, now: now)))
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func ampCreditsLine(_ usage: AmpUsageDetails) -> String? {
|
||||
var lines: [String] = []
|
||||
if let individualCredits = usage.individualCredits {
|
||||
lines.append(
|
||||
"\(L("Individual credits")): \(UsageFormatter.currencyString(individualCredits, currencyCode: "USD"))")
|
||||
}
|
||||
lines.append(contentsOf: usage.workspaceBalances.map { workspace in
|
||||
"\(L("Workspace")) \(workspace.name): " +
|
||||
UsageFormatter.currencyString(workspace.remaining, currencyCode: "USD")
|
||||
})
|
||||
return lines.isEmpty ? nil : lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func tokenUsageSection(
|
||||
provider: UsageProvider,
|
||||
enabled: Bool,
|
||||
comparisonPeriodsEnabled: Bool,
|
||||
snapshot: CostUsageTokenSnapshot?,
|
||||
error: String?) -> TokenUsageSection?
|
||||
{
|
||||
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
|
||||
return nil
|
||||
}
|
||||
guard enabled else { return nil }
|
||||
guard let snapshot else { return nil }
|
||||
|
||||
let sessionCost = snapshot.sessionCostUSD.map {
|
||||
UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode)
|
||||
} ?? "—"
|
||||
let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
|
||||
let sessionLabel = if provider == .bedrock || provider == .mistral {
|
||||
Self.latestBillingDayLabel(from: snapshot)
|
||||
} else {
|
||||
L("Today")
|
||||
}
|
||||
let sessionLine: String = {
|
||||
if let sessionTokens {
|
||||
return String(format: L("%@: %@ · %@ tokens"), sessionLabel, sessionCost, sessionTokens)
|
||||
}
|
||||
return "\(sessionLabel): \(sessionCost)"
|
||||
}()
|
||||
|
||||
let monthCost = snapshot.last30DaysCostUSD.map {
|
||||
UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode)
|
||||
} ?? "—"
|
||||
let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +)
|
||||
let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil)
|
||||
let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) }
|
||||
let windowLabel = snapshot.historyLabel ?? Self.costHistoryWindowLabel(days: snapshot.historyDays)
|
||||
let monthLine: String = {
|
||||
if let monthTokens {
|
||||
return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens)
|
||||
}
|
||||
return "\(windowLabel): \(monthCost)"
|
||||
}()
|
||||
let err = (error?.isEmpty ?? true) ? nil : error
|
||||
return TokenUsageSection(
|
||||
sessionLine: sessionLine,
|
||||
monthLine: monthLine,
|
||||
comparisonLines: comparisonPeriodsEnabled
|
||||
? snapshot.comparisonSummaries().map {
|
||||
Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode)
|
||||
}
|
||||
: [],
|
||||
hintLine: Self.tokenUsageHint(provider: provider),
|
||||
errorLine: err,
|
||||
errorCopyText: (error?.isEmpty ?? true) ? nil : error)
|
||||
}
|
||||
|
||||
static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String {
|
||||
let label = Self.costHistoryWindowLabel(days: summary.days)
|
||||
let cost = summary.totalCostUSD.map {
|
||||
UsageFormatter.currencyString($0, currencyCode: currencyCode)
|
||||
} ?? "—"
|
||||
guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" }
|
||||
return String(
|
||||
format: L("%@: %@ · %@ tokens"),
|
||||
label,
|
||||
cost,
|
||||
UsageFormatter.tokenCountString(totalTokens))
|
||||
}
|
||||
|
||||
static func tokenUsageHint(provider: UsageProvider) -> String? {
|
||||
switch provider {
|
||||
case .codex:
|
||||
L("Estimated from local Codex logs for the selected account.")
|
||||
case .claude:
|
||||
UsageFormatter.costEstimateHint(provider: provider)
|
||||
case .vertexai:
|
||||
L("cost_estimate_hint")
|
||||
case .bedrock:
|
||||
L("AWS Cost Explorer billing can lag.")
|
||||
case .openai:
|
||||
L("Reported by OpenAI Admin API organization usage.")
|
||||
case .mistral:
|
||||
L("Reported by Mistral billing usage.")
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
static func costHistoryWindowLabel(days: Int) -> String {
|
||||
days == 1 ? L("Today") : String(format: L("Last %d days"), days)
|
||||
}
|
||||
|
||||
private static func latestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String {
|
||||
guard let entry = bedrockLatestBillingDay(from: snapshot.daily),
|
||||
let displayDate = bedrockDisplayDate(from: entry.date)
|
||||
else { return L("Latest billing day") }
|
||||
return String(format: L("Latest billing day (%@)"), displayDate)
|
||||
}
|
||||
|
||||
private static func bedrockLatestBillingDay(from entries: [CostUsageDailyReport.Entry])
|
||||
-> CostUsageDailyReport.Entry?
|
||||
{
|
||||
entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, dayKey: String)? in
|
||||
guard let dayKey = bedrockBillingDayKey(from: entry.date) else { return nil }
|
||||
return (entry, dayKey)
|
||||
}
|
||||
.max { lhs, rhs in
|
||||
if lhs.dayKey != rhs.dayKey { return lhs.dayKey < rhs.dayKey }
|
||||
let lCost = lhs.entry.costUSD ?? -1
|
||||
let rCost = rhs.entry.costUSD ?? -1
|
||||
if lCost != rCost { return lCost < rCost }
|
||||
let lTokens = lhs.entry.totalTokens ?? -1
|
||||
let rTokens = rhs.entry.totalTokens ?? -1
|
||||
if lTokens != rTokens { return lTokens < rTokens }
|
||||
return lhs.entry.date < rhs.entry.date
|
||||
}?.entry
|
||||
}
|
||||
|
||||
private static func bedrockDisplayDate(from text: String) -> String? {
|
||||
guard let dayKey = bedrockBillingDayKey(from: text) else { return nil }
|
||||
let monthStart = dayKey.index(dayKey.startIndex, offsetBy: 5)
|
||||
let monthEnd = dayKey.index(monthStart, offsetBy: 2)
|
||||
let dayStart = dayKey.index(dayKey.startIndex, offsetBy: 8)
|
||||
guard
|
||||
let month = Int(dayKey[monthStart..<monthEnd]),
|
||||
let day = Int(dayKey[dayStart...]),
|
||||
(1...Self.bedrockMonthAbbreviations.count).contains(month),
|
||||
(1...31).contains(day)
|
||||
else { return nil }
|
||||
return "\(Self.bedrockMonthAbbreviations[month - 1]) \(day)"
|
||||
}
|
||||
|
||||
private static let bedrockMonthAbbreviations = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
]
|
||||
|
||||
private static func bedrockBillingDayKey(from text: String) -> String? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count == 10 else { return nil }
|
||||
for (offset, character) in trimmed.enumerated() {
|
||||
switch offset {
|
||||
case 4, 7:
|
||||
guard character == "-" else { return nil }
|
||||
default:
|
||||
guard character.isNumber else { return nil }
|
||||
}
|
||||
}
|
||||
let monthStart = trimmed.index(trimmed.startIndex, offsetBy: 5)
|
||||
let monthEnd = trimmed.index(monthStart, offsetBy: 2)
|
||||
let dayStart = trimmed.index(trimmed.startIndex, offsetBy: 8)
|
||||
let yearEnd = trimmed.index(trimmed.startIndex, offsetBy: 4)
|
||||
guard
|
||||
let year = Int(trimmed[..<yearEnd]),
|
||||
let month = Int(trimmed[monthStart..<monthEnd]),
|
||||
let day = Int(trimmed[dayStart...]),
|
||||
(1...Self.bedrockMonthAbbreviations.count).contains(month),
|
||||
(1...Self.daysInBedrockBillingMonth(month, year: year)).contains(day)
|
||||
else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func daysInBedrockBillingMonth(_ month: Int, year: Int) -> Int {
|
||||
switch month {
|
||||
case 2:
|
||||
if year.isMultiple(of: 400) { return 29 }
|
||||
if year.isMultiple(of: 100) { return 28 }
|
||||
return year.isMultiple(of: 4) ? 29 : 28
|
||||
case 4, 6, 9, 11:
|
||||
return 30
|
||||
default:
|
||||
return 31
|
||||
}
|
||||
}
|
||||
|
||||
static func providerCostSection(
|
||||
provider: UsageProvider,
|
||||
cost: ProviderCostSnapshot?) -> ProviderCostSection?
|
||||
{
|
||||
if provider == .manus {
|
||||
return nil
|
||||
}
|
||||
guard let cost else { return nil }
|
||||
guard provider != .synthetic else { return nil }
|
||||
|
||||
if provider == .factory || provider == .devin, cost.period == "Extra usage balance" {
|
||||
let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
||||
return ProviderCostSection(
|
||||
title: L("Extra usage"),
|
||||
percentUsed: nil,
|
||||
spendLine: "\(L("Balance")): \(balance)",
|
||||
percentLine: nil)
|
||||
}
|
||||
|
||||
if provider == .opencodego, cost.period == "Zen balance" {
|
||||
let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
||||
return ProviderCostSection(
|
||||
title: L("Zen balance"),
|
||||
percentUsed: nil,
|
||||
spendLine: "\(L("Balance")): \(balance)",
|
||||
percentLine: nil)
|
||||
}
|
||||
|
||||
if provider == .minimax, cost.period == "MiniMax points balance" {
|
||||
let balance = String(format: "%.0f", cost.used)
|
||||
return ProviderCostSection(
|
||||
title: L("Credits"),
|
||||
percentUsed: nil,
|
||||
spendLine: "\(L("Balance")): \(balance)",
|
||||
percentLine: nil)
|
||||
}
|
||||
|
||||
if provider == .openai || provider == .claude || provider == .litellm, cost.limit <= 0 {
|
||||
let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
||||
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days")
|
||||
return ProviderCostSection(
|
||||
title: L("API spend"),
|
||||
percentUsed: nil,
|
||||
spendLine: "\(periodLabel): \(spend)",
|
||||
percentLine: nil)
|
||||
}
|
||||
|
||||
if provider == .litellm {
|
||||
return nil
|
||||
}
|
||||
|
||||
if provider == .clawrouter, cost.limit <= 0 {
|
||||
let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
||||
return ProviderCostSection(
|
||||
title: "ClawRouter spend",
|
||||
percentUsed: nil,
|
||||
spendLine: "\(L("This month")): \(spend)",
|
||||
percentLine: nil)
|
||||
}
|
||||
|
||||
guard cost.limit > 0 else { return nil }
|
||||
|
||||
let used: String
|
||||
let limit: String
|
||||
let title: String
|
||||
|
||||
if provider == .clawrouter {
|
||||
title = "Monthly budget"
|
||||
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
||||
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
|
||||
} else if cost.currencyCode == "Quota" {
|
||||
title = L("Quota usage")
|
||||
used = String(format: "%.0f", cost.used)
|
||||
limit = String(format: "%.0f", cost.limit)
|
||||
} else {
|
||||
title = L("Extra usage")
|
||||
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
||||
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
|
||||
}
|
||||
|
||||
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
|
||||
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month")
|
||||
|
||||
// When the headline budget is a shared pool (e.g. Cursor team on-demand), show the
|
||||
// account's own contribution underneath it.
|
||||
let personalSpendLine: String? = cost.personalUsed.flatMap { personal in
|
||||
personal > 0
|
||||
? "\(L("Your spend")): \(UsageFormatter.currencyString(personal, currencyCode: cost.currencyCode))"
|
||||
: nil
|
||||
}
|
||||
|
||||
return ProviderCostSection(
|
||||
title: title,
|
||||
percentUsed: percentUsed,
|
||||
spendLine: "\(periodLabel): \(used) / \(limit)",
|
||||
percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))),
|
||||
personalSpendLine: personalSpendLine)
|
||||
}
|
||||
|
||||
private static func localizedPeriodLabel(_ label: String) -> String {
|
||||
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch trimmed.lowercased() {
|
||||
case "last 30 days":
|
||||
return L("Last 30 days")
|
||||
case "this month":
|
||||
return L("This month")
|
||||
case "today":
|
||||
return L("Today")
|
||||
default:
|
||||
return L(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
static func clamped(_ value: Double) -> Double {
|
||||
min(100, max(0, value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
static func kiroUsageNotes(input: Input) -> [String] {
|
||||
var notes: [String] = []
|
||||
if let authMethod = input.snapshot?.loginMethod(for: .kiro)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!authMethod.isEmpty
|
||||
{
|
||||
notes.append("\(L("Auth")): \(authMethod)")
|
||||
}
|
||||
if let overages = input.snapshot?.kiroUsage?.overagesStatus?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!overages.isEmpty
|
||||
{
|
||||
notes.append("\(L("Overages")): \(overages)")
|
||||
}
|
||||
let overagesEnabled = input.snapshot?.kiroUsage?.overagesStatus?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.hasPrefix("enabled") == true
|
||||
if overagesEnabled,
|
||||
let overageCreditsUsed = input.snapshot?.kiroUsage?.overageCreditsUsed
|
||||
{
|
||||
notes.append(
|
||||
"\(L("Overage usage")): \(UsageFormatter.kiroCreditNumber(overageCreditsUsed)) \(L("credits"))")
|
||||
}
|
||||
if overagesEnabled,
|
||||
let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD
|
||||
{
|
||||
notes.append("\(L("Overage cost")): \(UsageFormatter.usdString(estimatedOverageCostUSD))")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
static func kiroPlan(snapshot: UsageSnapshot?) -> String? {
|
||||
guard let plan = snapshot?.kiroUsage?.displayPlanName,
|
||||
!plan.isEmpty
|
||||
else { return nil }
|
||||
return plan
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] {
|
||||
let percentStyle: PercentStyle = .used
|
||||
let displayNameCounts = Dictionary(grouping: services.map(\.displayName), by: { $0 }).mapValues(\.count)
|
||||
|
||||
return services.enumerated().map { index, service in
|
||||
let used = service.usage
|
||||
let displayPercent = min(100, max(0, service.percent))
|
||||
let usageLabel = if service.isUnlimited {
|
||||
nil as String?
|
||||
} else {
|
||||
String(
|
||||
format: L("minimax_usage_amount_format"),
|
||||
used.formatted(),
|
||||
service.limit.formatted())
|
||||
}
|
||||
let localizedName = Self.localizedMiniMaxServiceName(service.displayName)
|
||||
let title = if (displayNameCounts[service.displayName] ?? 0) > 1 {
|
||||
"\(localizedName) · \(Self.displayWindowBadge(for: service.windowType))"
|
||||
} else {
|
||||
localizedName
|
||||
}
|
||||
|
||||
return Metric(
|
||||
id: "minimax-service-\(index)",
|
||||
title: title,
|
||||
percent: displayPercent,
|
||||
percentStyle: percentStyle,
|
||||
statusText: service.isUnlimited ? "∞ Unlimited" : nil,
|
||||
resetText: Self.localizedMiniMaxResetDescription(service.resetDescription),
|
||||
detailText: nil,
|
||||
detailLeftText: usageLabel,
|
||||
detailRightText: nil,
|
||||
pacePercent: nil,
|
||||
paceOnTop: true,
|
||||
warningMarkerPercents: service.isUnlimited
|
||||
? []
|
||||
: Self.miniMaxWarningMarkerPercents(service: service, input: input),
|
||||
workdayMarkerPercents: service.isUnlimited
|
||||
? []
|
||||
: Self.miniMaxWorkdayMarkerPercents(service: service, input: input),
|
||||
cardStyle: false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func miniMaxWarningMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] {
|
||||
switch self.miniMaxQuotaWarningWindow(for: service) {
|
||||
case .session:
|
||||
warningMarkerPercents(
|
||||
thresholds: input.quotaWarningThresholds[.session],
|
||||
showUsed: true)
|
||||
case .weekly:
|
||||
warningMarkerPercents(
|
||||
thresholds: input.quotaWarningThresholds[.weekly],
|
||||
showUsed: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func miniMaxWorkdayMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] {
|
||||
guard self.miniMaxQuotaWarningWindow(for: service) == .weekly else { return [] }
|
||||
return workDayMarkerPercents(
|
||||
workDays: input.workDaysPerWeek,
|
||||
windowMinutes: self.miniMaxWindowMinutes(for: service.windowType))
|
||||
}
|
||||
|
||||
private static func miniMaxQuotaWarningWindow(for service: MiniMaxServiceUsage) -> QuotaWarningWindow {
|
||||
service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" ? .weekly : .session
|
||||
}
|
||||
|
||||
private static func miniMaxWindowMinutes(for windowType: String) -> Int? {
|
||||
let normalized = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized == "weekly" {
|
||||
return 7 * 24 * 60
|
||||
}
|
||||
if normalized == "today" || normalized == "daily" {
|
||||
return 24 * 60
|
||||
}
|
||||
if normalized == "5h" {
|
||||
return 5 * 60
|
||||
}
|
||||
let pieces = normalized.split(separator: " ")
|
||||
guard pieces.count >= 2, let value = Int(pieces[0]) else { return nil }
|
||||
switch pieces[1] {
|
||||
case "hour", "hours", "hr", "hrs":
|
||||
return value * 60
|
||||
case "minute", "minutes", "min", "mins":
|
||||
return value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func displayWindowBadge(for windowType: String) -> String {
|
||||
let trimmed = windowType.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.lowercased()
|
||||
|
||||
if normalized == "weekly" {
|
||||
return L("Weekly")
|
||||
}
|
||||
if normalized == "5 hours" || normalized == "5 hour" || normalized == "5h" {
|
||||
return "5h"
|
||||
}
|
||||
if normalized == "today" {
|
||||
return L("Today")
|
||||
}
|
||||
if normalized == "daily" {
|
||||
return L("Daily")
|
||||
}
|
||||
return trimmed.isEmpty ? windowType : trimmed
|
||||
}
|
||||
|
||||
private static func localizedMiniMaxResetDescription(_ text: String) -> String {
|
||||
let prefix = "Resets in "
|
||||
guard text.hasPrefix(prefix) else { return text }
|
||||
let rest = String(text.dropFirst(prefix.count))
|
||||
return L("Resets in %@", rest)
|
||||
}
|
||||
|
||||
private static func localizedMiniMaxServiceName(_ raw: String) -> String {
|
||||
switch raw {
|
||||
case "Text Generation", "text_generation":
|
||||
L("minimax_service_text_generation")
|
||||
case "Text to Speech", "text_to_speech":
|
||||
L("minimax_service_text_to_speech")
|
||||
case "Music Generation", "music_generation":
|
||||
L("minimax_service_music_generation")
|
||||
case "Image Generation", "image_generation":
|
||||
L("minimax_service_image_generation")
|
||||
case "lyrics_generation":
|
||||
L("minimax_service_lyrics_generation")
|
||||
case "coding-plan-vlm":
|
||||
L("minimax_service_coding_plan_vlm")
|
||||
case "coding-plan-search":
|
||||
L("minimax_service_coding_plan_search")
|
||||
default:
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
struct PaceDetail {
|
||||
let leftLabel: String
|
||||
let rightLabel: String?
|
||||
let pacePercent: Double?
|
||||
let paceOnTop: Bool
|
||||
}
|
||||
|
||||
static func redactedMetricDetail(_ detail: String?, provider: UsageProvider, metricID: String) -> String? {
|
||||
guard let detail else { return nil }
|
||||
guard provider == .litellm,
|
||||
metricID == "secondary",
|
||||
detail.hasPrefix("Team "),
|
||||
let separator = detail.range(of: ": ", options: .backwards)
|
||||
else {
|
||||
return PersonalInfoRedactor.redactEmails(in: detail, isEnabled: true)
|
||||
}
|
||||
return PersonalInfoRedactor.redactEmails(in: "Team\(detail[separator.lowerBound...])", isEnabled: true)
|
||||
}
|
||||
|
||||
static func redactedMetrics(
|
||||
_ metrics: [Metric],
|
||||
provider: UsageProvider,
|
||||
hidePersonalInfo: Bool) -> [Metric]
|
||||
{
|
||||
guard hidePersonalInfo else { return metrics }
|
||||
return metrics.map { metric in
|
||||
Metric(
|
||||
id: metric.id,
|
||||
title: PersonalInfoRedactor.redactEmails(in: metric.title, isEnabled: true) ?? metric.title,
|
||||
percent: metric.percent,
|
||||
percentStyle: metric.percentStyle,
|
||||
statusText: PersonalInfoRedactor.redactEmails(in: metric.statusText, isEnabled: true),
|
||||
resetText: PersonalInfoRedactor.redactEmails(in: metric.resetText, isEnabled: true),
|
||||
detailText: Self.redactedMetricDetail(
|
||||
metric.detailText,
|
||||
provider: provider,
|
||||
metricID: metric.id),
|
||||
detailLeftText: PersonalInfoRedactor.redactEmails(in: metric.detailLeftText, isEnabled: true),
|
||||
detailRightText: PersonalInfoRedactor.redactEmails(in: metric.detailRightText, isEnabled: true),
|
||||
pacePercent: metric.pacePercent,
|
||||
paceOnTop: metric.paceOnTop,
|
||||
warningMarkerPercents: metric.warningMarkerPercents,
|
||||
workdayMarkerPercents: metric.workdayMarkerPercents,
|
||||
cardStyle: metric.cardStyle)
|
||||
}
|
||||
}
|
||||
|
||||
static func usageNotes(input: Input) -> [String] {
|
||||
let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider)
|
||||
|
||||
if input.provider == .sub2api {
|
||||
return self.sub2APIUsageNotes(input.snapshot?.sub2APIUsage) + subscriptionNotes
|
||||
}
|
||||
|
||||
if input.provider == .kiro {
|
||||
return self.kiroUsageNotes(input: input) + subscriptionNotes
|
||||
}
|
||||
|
||||
if input.provider == .kilo {
|
||||
var notes = Self.kiloLoginDetails(snapshot: input.snapshot)
|
||||
let resolvedSource = input.sourceLabel?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if input.kiloAutoMode,
|
||||
resolvedSource == "cli",
|
||||
!notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame })
|
||||
{
|
||||
notes.append(L("Using CLI fallback"))
|
||||
}
|
||||
return notes + subscriptionNotes
|
||||
}
|
||||
|
||||
if input.provider == .mimo, input.snapshot != nil {
|
||||
return Self.mimoUsageNotes(input: input, subscriptionNotes: subscriptionNotes)
|
||||
}
|
||||
|
||||
if let notes = self.apiProviderUsageNotes(input: input) {
|
||||
return notes + subscriptionNotes
|
||||
}
|
||||
|
||||
if input.provider == .crossmodel, let crossModel = input.snapshot?.crossModelUsage {
|
||||
return Self.crossModelSpendNotes(crossModel) + subscriptionNotes
|
||||
}
|
||||
|
||||
guard input.provider == .openrouter,
|
||||
let openRouter = input.snapshot?.openRouterUsage
|
||||
else {
|
||||
return subscriptionNotes
|
||||
}
|
||||
|
||||
var notes = Self.openRouterSpendNotes(openRouter)
|
||||
switch openRouter.keyQuotaStatus {
|
||||
case .available:
|
||||
break
|
||||
case .noLimitConfigured:
|
||||
notes.append(L("No limit set for the API key"))
|
||||
case .unavailable:
|
||||
notes.append(L("API key limit unavailable right now"))
|
||||
}
|
||||
return notes + subscriptionNotes
|
||||
}
|
||||
|
||||
var isOverviewErrorOnly: Bool {
|
||||
self.subtitleStyle == .error &&
|
||||
self.metrics.isEmpty &&
|
||||
self.usageNotes.isEmpty &&
|
||||
self.openAIAPIUsage == nil &&
|
||||
self.inlineUsageDashboard == nil &&
|
||||
self.creditsRemaining == nil &&
|
||||
self.providerCost == nil &&
|
||||
self.tokenUsage == nil &&
|
||||
self.placeholder == nil
|
||||
}
|
||||
|
||||
var hasUsageContent: Bool {
|
||||
!self.metrics.isEmpty ||
|
||||
!self.usageNotes.isEmpty ||
|
||||
self.openAIAPIUsage != nil ||
|
||||
self.inlineUsageDashboard != nil ||
|
||||
self.codexResetCredits != nil ||
|
||||
self.placeholder != nil
|
||||
}
|
||||
|
||||
var usesStackedDetailLayout: Bool {
|
||||
!self.metrics.isEmpty ||
|
||||
self.creditsText != nil ||
|
||||
self.codexResetCredits != nil ||
|
||||
self.providerCost != nil ||
|
||||
self.tokenUsage != nil
|
||||
}
|
||||
|
||||
func hasCompatibleTrackedLayout(with candidate: Self) -> Bool {
|
||||
self.hasCompatibleTrackedLayout(with: candidate, includeMetrics: true)
|
||||
}
|
||||
|
||||
func hasCompatibleTrackedLayoutIgnoringMetrics(with candidate: Self) -> Bool {
|
||||
self.hasCompatibleTrackedLayout(with: candidate, includeMetrics: false)
|
||||
}
|
||||
|
||||
func hasCompatibleTrackedMetricSubset(of candidate: Self) -> Bool {
|
||||
guard self.metrics.count < candidate.metrics.count,
|
||||
self.hasCompatibleTrackedLayoutIgnoringMetrics(with: candidate)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return self.metrics.allSatisfy { metric in
|
||||
candidate.metrics.contains { Self.hasCompatibleMetricLayout(metric, $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func hasCompatibleTrackedLayout(with candidate: Self, includeMetrics: Bool) -> Bool {
|
||||
guard self.provider == candidate.provider,
|
||||
!includeMetrics || self.metrics.count == candidate.metrics.count,
|
||||
self.usageNotes == candidate.usageNotes,
|
||||
(self.openAIAPIUsage == nil) == (candidate.openAIAPIUsage == nil),
|
||||
Self.hasCompatibleCreditsLayout(
|
||||
currentText: self.creditsText,
|
||||
currentRemaining: self.creditsRemaining,
|
||||
candidateText: candidate.creditsText,
|
||||
candidateRemaining: candidate.creditsRemaining),
|
||||
self.creditsHintText == candidate.creditsHintText,
|
||||
self.codexResetCredits == candidate.codexResetCredits,
|
||||
self.placeholder == candidate.placeholder,
|
||||
Self.hasCompatibleDashboardLayout(self.inlineUsageDashboard, candidate.inlineUsageDashboard),
|
||||
Self.hasCompatibleProviderCostLayout(self.providerCost, candidate.providerCost),
|
||||
Self.hasCompatibleTokenUsageLayout(self.tokenUsage, candidate.tokenUsage)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard includeMetrics else { return true }
|
||||
return zip(self.metrics, candidate.metrics).allSatisfy(Self.hasCompatibleMetricLayout)
|
||||
}
|
||||
|
||||
private static func hasCompatibleMetricLayout(_ current: Metric, _ candidate: Metric) -> Bool {
|
||||
current.id == candidate.id &&
|
||||
current.title == candidate.title &&
|
||||
current.percentStyle == candidate.percentStyle &&
|
||||
(current.statusText == nil) == (candidate.statusText == nil) &&
|
||||
(current.resetText == nil) == (candidate.resetText == nil) &&
|
||||
(current.detailText == nil) == (candidate.detailText == nil) &&
|
||||
(current.detailLeftText == nil) == (candidate.detailLeftText == nil) &&
|
||||
(current.detailRightText == nil) == (candidate.detailRightText == nil) &&
|
||||
current.cardStyle == candidate.cardStyle
|
||||
}
|
||||
|
||||
private static func hasCompatibleCreditsLayout(
|
||||
currentText: String?,
|
||||
currentRemaining: Double?,
|
||||
candidateText: String?,
|
||||
candidateRemaining: Double?) -> Bool
|
||||
{
|
||||
switch (currentText, candidateText) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
case let (currentText?, candidateText?):
|
||||
guard (currentRemaining == nil) == (candidateRemaining == nil) else { return false }
|
||||
// Numeric balances render as a fixed single line beside the full-scale label.
|
||||
// Multiline workspace balances retain their measured text until the menu reopens.
|
||||
return currentRemaining != nil || currentText == candidateText
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasCompatibleDashboardLayout(
|
||||
_ current: InlineUsageDashboardModel?,
|
||||
_ candidate: InlineUsageDashboardModel?) -> Bool
|
||||
{
|
||||
switch (current, candidate) {
|
||||
case (nil, nil):
|
||||
true
|
||||
case let (current?, candidate?):
|
||||
current.valueStyle == candidate.valueStyle &&
|
||||
current.kpis.count == candidate.kpis.count &&
|
||||
current.points.count == candidate.points.count &&
|
||||
current.detailLines.count == candidate.detailLines.count &&
|
||||
zip(current.kpis, candidate.kpis).allSatisfy {
|
||||
$0.title == $1.title && $0.emphasis == $1.emphasis
|
||||
} &&
|
||||
zip(current.points, candidate.points).allSatisfy {
|
||||
$0.id == $1.id && $0.label == $1.label
|
||||
}
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasCompatibleProviderCostLayout(
|
||||
_ current: ProviderCostSection?,
|
||||
_ candidate: ProviderCostSection?) -> Bool
|
||||
{
|
||||
switch (current, candidate) {
|
||||
case (nil, nil):
|
||||
true
|
||||
case let (current?, candidate?):
|
||||
current.title == candidate.title &&
|
||||
(current.percentUsed == nil) == (candidate.percentUsed == nil) &&
|
||||
(current.percentLine == nil) == (candidate.percentLine == nil) &&
|
||||
(current.personalSpendLine == nil) == (candidate.personalSpendLine == nil)
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasCompatibleTokenUsageLayout(
|
||||
_ current: TokenUsageSection?,
|
||||
_ candidate: TokenUsageSection?) -> Bool
|
||||
{
|
||||
switch (current, candidate) {
|
||||
case (nil, nil):
|
||||
true
|
||||
case let (current?, candidate?):
|
||||
current.hintLine == candidate.hintLine &&
|
||||
current.errorLine == candidate.errorLine &&
|
||||
current.comparisonLines.count == candidate.comparisonLines.count
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static func progressColor(for provider: UsageProvider) -> Color {
|
||||
if provider == .elevenlabs {
|
||||
return Color(nsColor: .labelColor)
|
||||
}
|
||||
|
||||
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
|
||||
return Color(red: color.red, green: color.green, blue: color.blue)
|
||||
}
|
||||
|
||||
static func rateWindowLabels(
|
||||
input: Input,
|
||||
snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool)
|
||||
{
|
||||
if input.provider == .factory, snapshot.tertiary != nil {
|
||||
return ("5-hour", L("Weekly"), L("Monthly"), true)
|
||||
}
|
||||
// Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool.
|
||||
let primaryLabel = if input.provider == .cursor, snapshot.cursorRequests != nil {
|
||||
"Requests"
|
||||
} else if input.provider == .grok {
|
||||
GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel
|
||||
} else if input.provider == .doubao {
|
||||
DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel
|
||||
} else if input.provider == .sub2api {
|
||||
Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? input.metadata.sessionLabel
|
||||
} else {
|
||||
input.metadata.sessionLabel
|
||||
}
|
||||
return (
|
||||
L(primaryLabel),
|
||||
L(input.metadata.weeklyLabel),
|
||||
input.metadata.opusLabel.map(L) ?? L("Sonnet"),
|
||||
input.metadata.supportsOpus)
|
||||
}
|
||||
|
||||
static func sub2APIUsageNotes(_ usage: Sub2APIUsageDetails?) -> [String] {
|
||||
guard let usage else { return [] }
|
||||
var notes: [String] = []
|
||||
if let balance = usage.balance {
|
||||
notes.append("\(L("Balance")): \(UsageFormatter.currencyString(balance, currencyCode: usage.unit))")
|
||||
}
|
||||
if let today = usage.today {
|
||||
notes.append("\(L("Today")): \(self.sub2APITotalsText(today, unit: usage.unit))")
|
||||
}
|
||||
if let total = usage.total {
|
||||
notes.append("\(L("Total")): \(self.sub2APITotalsText(total, unit: usage.unit))")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
private static func sub2APITotalsText(_ totals: Sub2APIUsageDetails.Totals, unit: String) -> String {
|
||||
"\(UsageFormatter.tokenCountString(totals.requests)) \(L("requests")) · " +
|
||||
"\(UsageFormatter.tokenCountString(totals.totalTokens)) \(L("tokens")) · " +
|
||||
UsageFormatter.currencyString(totals.actualCostUSD, currencyCode: unit)
|
||||
}
|
||||
|
||||
static func resetText(
|
||||
for window: RateWindow,
|
||||
style: ResetTimeDisplayStyle,
|
||||
now: Date) -> String?
|
||||
{
|
||||
UsageFormatter.resetLine(for: window, style: style, now: now)
|
||||
}
|
||||
|
||||
static func placeholder(input: Input) -> String? {
|
||||
if self.shouldShowRateLimitsUnavailablePlaceholder(input: input) {
|
||||
return L("Limits not available")
|
||||
}
|
||||
|
||||
if input.snapshot == nil, !input.isRefreshing, input.lastError == nil {
|
||||
return self.hasLocalCodexTokenUsage(input) ? nil : L("No usage yet")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func lastError(input: Input) -> String? {
|
||||
guard let lastError = input.lastError?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!lastError.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) {
|
||||
return nil
|
||||
}
|
||||
return lastError
|
||||
}
|
||||
|
||||
static func dashboardHint(error: String?) -> String? {
|
||||
guard let error, !error.isEmpty else { return nil }
|
||||
return error
|
||||
}
|
||||
|
||||
static func mimoUsageNotes(input: Input, subscriptionNotes: [String]) -> [String] {
|
||||
let source = input.sourceLabel?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard source != "local" else { return [] }
|
||||
return [
|
||||
L("Balance updates in near-real time (up to 5 min lag)"),
|
||||
L("Daily billing data finalizes at 07:00 UTC"),
|
||||
] + subscriptionNotes
|
||||
}
|
||||
|
||||
static func subscriptionMetadataNotes(snapshot: UsageSnapshot?, provider: UsageProvider) -> [String] {
|
||||
guard let snapshot else { return [] }
|
||||
if let renewsAt = snapshot.subscriptionRenewsAt {
|
||||
return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt, provider: provider))]
|
||||
}
|
||||
if let expiresAt = snapshot.subscriptionExpiresAt {
|
||||
return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt, provider: provider))]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func subscriptionDateString(_ date: Date, provider: UsageProvider) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = self.subscriptionDateTimeZone(provider: provider)
|
||||
formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func subscriptionDateTimeZone(provider: UsageProvider) -> TimeZone {
|
||||
switch provider {
|
||||
case .minimax:
|
||||
TimeZone(identifier: "Asia/Shanghai") ?? .current
|
||||
default:
|
||||
.current
|
||||
}
|
||||
}
|
||||
|
||||
static func poeBalanceDetailText(input: Input) -> String? {
|
||||
guard input.provider == .poe else { return nil }
|
||||
return StatusItemController.poeBalanceDisplayText(snapshot: input.snapshot)
|
||||
}
|
||||
|
||||
private static func hasLocalCodexTokenUsage(_ input: Input) -> Bool {
|
||||
input.provider == .codex &&
|
||||
input.tokenCostUsageEnabled &&
|
||||
self.tokenUsageSnapshot(input: input) != nil
|
||||
}
|
||||
|
||||
private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool {
|
||||
let currentError = lastError ?? input.lastError
|
||||
if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!currentError.isEmpty,
|
||||
!UsageError.isNoRateLimitsFoundDescription(currentError),
|
||||
!ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(currentError)
|
||||
{
|
||||
return false
|
||||
}
|
||||
if input.limitsAvailability?.isUnavailable == true {
|
||||
return true
|
||||
}
|
||||
return self.rateLimitsUnavailable(input: input, lastError: currentError)
|
||||
}
|
||||
|
||||
private static func rateLimitsUnavailable(input: Input, lastError: String? = nil) -> Bool {
|
||||
UsageLimitsAvailability.resolve(
|
||||
provider: input.provider,
|
||||
snapshot: input.snapshot,
|
||||
account: input.account,
|
||||
lastErrorDescription: lastError ?? input.lastError)
|
||||
.isUnavailable
|
||||
}
|
||||
|
||||
static func sessionPaceDetail(
|
||||
provider: UsageProvider,
|
||||
window: RateWindow,
|
||||
now: Date,
|
||||
showUsed: Bool) -> PaceDetail?
|
||||
{
|
||||
guard let detail = UsagePaceText.sessionDetail(provider: provider, window: window, now: now) else { return nil }
|
||||
let expectedUsed = detail.expectedUsedPercent
|
||||
let actualUsed = window.usedPercent
|
||||
let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed)
|
||||
let actualPercent = showUsed ? actualUsed : (100 - actualUsed)
|
||||
if expectedPercent.isFinite == false || actualPercent.isFinite == false {
|
||||
return nil
|
||||
}
|
||||
let paceOnTop = actualUsed <= expectedUsed
|
||||
let pacePercent: Double? = if detail.stage == .onTrack {
|
||||
nil
|
||||
} else {
|
||||
expectedPercent
|
||||
}
|
||||
return PaceDetail(
|
||||
leftLabel: detail.leftLabel,
|
||||
rightLabel: detail.rightLabel,
|
||||
pacePercent: pacePercent,
|
||||
paceOnTop: paceOnTop)
|
||||
}
|
||||
|
||||
static func weeklyPaceDetail(
|
||||
provider: UsageProvider,
|
||||
window: RateWindow,
|
||||
now: Date,
|
||||
pace: UsagePace?,
|
||||
showUsed: Bool) -> PaceDetail?
|
||||
{
|
||||
guard let pace else { return nil }
|
||||
let detail = UsagePaceText.weeklyDetail(provider: provider, pace: pace, now: now)
|
||||
let expectedUsed = detail.expectedUsedPercent
|
||||
let actualUsed = window.usedPercent
|
||||
let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed)
|
||||
let actualPercent = showUsed ? actualUsed : (100 - actualUsed)
|
||||
if expectedPercent.isFinite == false || actualPercent.isFinite == false {
|
||||
return nil
|
||||
}
|
||||
let paceOnTop = actualUsed <= expectedUsed
|
||||
let pacePercent: Double? = if detail.stage == .onTrack {
|
||||
nil
|
||||
} else {
|
||||
expectedPercent
|
||||
}
|
||||
return PaceDetail(
|
||||
leftLabel: detail.leftLabel,
|
||||
rightLabel: detail.rightLabel,
|
||||
pacePercent: pacePercent,
|
||||
paceOnTop: paceOnTop)
|
||||
}
|
||||
|
||||
static func standardWeeklyPace(input: Input, window: RateWindow) -> UsagePace? {
|
||||
if let weeklyPace = input.weeklyPace {
|
||||
return weeklyPace
|
||||
}
|
||||
return Self.displayableWeeklyPace(UsagePace.weekly(
|
||||
window: window,
|
||||
now: input.now,
|
||||
defaultWindowMinutes: 10080,
|
||||
workDays: input.workDaysPerWeek))
|
||||
}
|
||||
|
||||
private static func displayableWeeklyPace(_ pace: UsagePace?) -> UsagePace? {
|
||||
guard let pace else { return nil }
|
||||
return pace.expectedUsedPercent >= 3 || pace.etaSeconds == 0 ? pace : nil
|
||||
}
|
||||
|
||||
static func resetWindowPaceDetail(
|
||||
window: RateWindow,
|
||||
input: Input,
|
||||
pace: UsagePace? = nil) -> PaceDetail?
|
||||
{
|
||||
guard self.supportsResetWindowPace(provider: input.provider, window: window),
|
||||
window.remainingPercent > 0
|
||||
else { return nil }
|
||||
let paceWindow = Self.resetWindowForPace(provider: input.provider, window: window)
|
||||
let resolved = pace ?? UsagePace.weekly(
|
||||
window: paceWindow,
|
||||
now: input.now,
|
||||
defaultWindowMinutes: 10080,
|
||||
workDays: input.workDaysPerWeek)
|
||||
guard let resolved = Self.displayableWeeklyPace(resolved) else { return nil }
|
||||
return Self.weeklyPaceDetail(
|
||||
provider: input.provider,
|
||||
window: paceWindow,
|
||||
now: input.now,
|
||||
pace: resolved,
|
||||
showUsed: input.usageBarsShowUsed)
|
||||
}
|
||||
|
||||
private static let monthlyWindowSentinelMinutes = 30 * 24 * 60
|
||||
|
||||
private static func supportsResetWindowPace(provider: UsageProvider, window: RateWindow) -> Bool {
|
||||
switch provider {
|
||||
case .cursor:
|
||||
window.windowMinutes != nil
|
||||
case .alibaba, .alibabatokenplan, .doubao, .opencodego:
|
||||
window.windowMinutes == self.monthlyWindowSentinelMinutes
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resetWindowForPace(provider: UsageProvider, window: RateWindow) -> RateWindow {
|
||||
// Provider snapshots use 30 days as a monthly sentinel; use the reset date for the real calendar-cycle length.
|
||||
guard self.usesInferredMonthlyDuration(provider: provider, window: window),
|
||||
let resetsAt = window.resetsAt,
|
||||
let minutes = self.inferredMonthlyWindowMinutes(endingAt: resetsAt)
|
||||
else { return window }
|
||||
return RateWindow(
|
||||
usedPercent: window.usedPercent,
|
||||
windowMinutes: minutes,
|
||||
resetsAt: window.resetsAt,
|
||||
resetDescription: window.resetDescription,
|
||||
nextRegenPercent: window.nextRegenPercent,
|
||||
isSyntheticPlaceholder: window.isSyntheticPlaceholder)
|
||||
}
|
||||
|
||||
private static func usesInferredMonthlyDuration(provider: UsageProvider, window: RateWindow) -> Bool {
|
||||
guard window.windowMinutes == self.monthlyWindowSentinelMinutes else { return false }
|
||||
switch provider {
|
||||
case .alibaba, .alibabatokenplan, .doubao, .opencodego:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func inferredMonthlyWindowMinutes(endingAt resetsAt: Date) -> Int? {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone
|
||||
guard let startsAt = calendar.date(byAdding: .month, value: -1, to: resetsAt) else { return nil }
|
||||
let minutes = resetsAt.timeIntervalSince(startsAt) / 60
|
||||
guard minutes.isFinite, minutes > 0 else { return nil }
|
||||
return Int(minutes.rounded())
|
||||
}
|
||||
|
||||
static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] {
|
||||
let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
|
||||
if Self.hasAntigravityQuotaSummaryWindows(snapshot) {
|
||||
return Self.extraRateWindowMetrics(
|
||||
snapshot: snapshot,
|
||||
input: input,
|
||||
percentStyle: percentStyle)
|
||||
}
|
||||
|
||||
var metrics: [Metric] = []
|
||||
if let primary = snapshot.primary {
|
||||
metrics.append(Self.antigravityMetric(
|
||||
id: "primary",
|
||||
title: L(input.metadata.sessionLabel),
|
||||
window: primary,
|
||||
input: input,
|
||||
percentStyle: percentStyle))
|
||||
}
|
||||
if let secondary = snapshot.secondary {
|
||||
metrics.append(Self.antigravityMetric(
|
||||
id: "secondary",
|
||||
title: L(input.metadata.weeklyLabel),
|
||||
window: secondary,
|
||||
input: input,
|
||||
percentStyle: percentStyle))
|
||||
}
|
||||
if input.metadata.supportsOpus, let tertiary = snapshot.tertiary {
|
||||
metrics.append(Self.antigravityMetric(
|
||||
id: "tertiary",
|
||||
title: input.metadata.opusLabel.map(L) ?? L("Gemini Flash"),
|
||||
window: tertiary,
|
||||
input: input,
|
||||
percentStyle: percentStyle))
|
||||
}
|
||||
metrics.append(contentsOf: Self.extraRateWindowMetrics(
|
||||
snapshot: snapshot,
|
||||
input: input,
|
||||
percentStyle: percentStyle))
|
||||
return metrics
|
||||
}
|
||||
|
||||
static func extraRateWindowMetrics(
|
||||
snapshot: UsageSnapshot,
|
||||
input: Input,
|
||||
percentStyle: PercentStyle) -> [Metric]
|
||||
{
|
||||
guard let extraRateWindows = snapshot.extraRateWindows else { return [] }
|
||||
// Codex additional limits (e.g. Codex Spark) are optional extra usage and follow the
|
||||
// "optional credits and extra usage" setting. Other providers' extra windows (Antigravity
|
||||
// per-model quotas, Factory core windows, etc.) are core data and must always render.
|
||||
if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {
|
||||
return []
|
||||
}
|
||||
if input.provider == .copilot, !input.copilotBudgetExtrasEnabled {
|
||||
return []
|
||||
}
|
||||
let visibleRateWindows = if input.provider == .codex, !input.codexSparkUsageVisible {
|
||||
extraRateWindows.filter { !Self.isCodexSparkRateWindow($0) }
|
||||
} else {
|
||||
extraRateWindows
|
||||
}
|
||||
return visibleRateWindows.map { namedWindow in
|
||||
let paceDetail = Self.extraRateWindowPaceDetail(
|
||||
provider: input.provider,
|
||||
window: namedWindow.window,
|
||||
input: input)
|
||||
let usageKnown = namedWindow.usageKnown
|
||||
let resolvedResetText = Self.extraRateWindowResetText(
|
||||
namedWindow: namedWindow,
|
||||
input: input)
|
||||
let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil
|
||||
? nil
|
||||
: resolvedResetText
|
||||
let detailText = input.provider == .sub2api
|
||||
? namedWindow.window.resetDescription
|
||||
: nil
|
||||
let statusText: String? = if usageKnown {
|
||||
nil
|
||||
} else if let resetText {
|
||||
"\(L("Unavailable")) - \(resetText)"
|
||||
} else {
|
||||
L("Unavailable")
|
||||
}
|
||||
return Metric(
|
||||
id: namedWindow.id,
|
||||
title: namedWindow.title,
|
||||
percent: Self.clamped(
|
||||
input.usageBarsShowUsed
|
||||
? namedWindow.window.usedPercent
|
||||
: namedWindow.window.remainingPercent),
|
||||
percentStyle: percentStyle,
|
||||
statusText: statusText,
|
||||
resetText: usageKnown ? resetText : nil,
|
||||
detailText: usageKnown ? detailText : nil,
|
||||
detailLeftText: usageKnown ? paceDetail?.leftLabel : nil,
|
||||
detailRightText: usageKnown ? paceDetail?.rightLabel : nil,
|
||||
pacePercent: usageKnown ? paceDetail?.pacePercent : nil,
|
||||
paceOnTop: paceDetail?.paceOnTop ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isCodexSparkRateWindow(_ namedWindow: NamedRateWindow) -> Bool {
|
||||
namedWindow.id == CodexAdditionalRateLimitMapper.sparkWindowID ||
|
||||
namedWindow.id == CodexAdditionalRateLimitMapper.sparkWeeklyWindowID
|
||||
}
|
||||
|
||||
private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-"
|
||||
|
||||
private static func hasAntigravityQuotaSummaryWindows(_ snapshot: UsageSnapshot) -> Bool {
|
||||
snapshot.extraRateWindows?.contains(where: self.isAntigravityQuotaSummaryWindow) == true
|
||||
}
|
||||
|
||||
private static func isAntigravityQuotaSummaryWindow(_ namedWindow: NamedRateWindow) -> Bool {
|
||||
namedWindow.id.hasPrefix(self.antigravityQuotaSummaryWindowIDPrefix)
|
||||
}
|
||||
|
||||
private static func extraRateWindowResetText(
|
||||
namedWindow: NamedRateWindow,
|
||||
input: Input) -> String?
|
||||
{
|
||||
if namedWindow.window.resetsAt != nil {
|
||||
return self.resetText(
|
||||
for: namedWindow.window,
|
||||
style: input.resetTimeDisplayStyle,
|
||||
now: input.now)
|
||||
}
|
||||
if input.provider == .antigravity,
|
||||
self.isAntigravityQuotaSummaryWindow(namedWindow)
|
||||
{
|
||||
return self.antigravityQuotaSummaryResetText(namedWindow.window.resetDescription)
|
||||
}
|
||||
return self.resetText(
|
||||
for: namedWindow.window,
|
||||
style: input.resetTimeDisplayStyle,
|
||||
now: input.now)
|
||||
}
|
||||
|
||||
private static func antigravityQuotaSummaryResetText(_ description: String?) -> String? {
|
||||
guard let description = description?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!description.isEmpty
|
||||
else { return nil }
|
||||
|
||||
if let range = description.range(of: "fully refresh in ", options: .caseInsensitive) {
|
||||
var suffix = String(description[range.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while suffix.last == "." {
|
||||
suffix.removeLast()
|
||||
}
|
||||
guard !suffix.isEmpty else { return description }
|
||||
return String(format: L("Resets in %@"), suffix)
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
private static func extraRateWindowPaceDetail(
|
||||
provider: UsageProvider,
|
||||
window: RateWindow,
|
||||
input: Input) -> PaceDetail?
|
||||
{
|
||||
guard provider == .codex || provider == .antigravity else { return nil }
|
||||
switch window.windowMinutes {
|
||||
case 300:
|
||||
return self.sessionPaceDetail(
|
||||
provider: provider,
|
||||
window: window,
|
||||
now: input.now,
|
||||
showUsed: input.usageBarsShowUsed)
|
||||
case 10080:
|
||||
let pace = Self.displayableWeeklyPace(UsagePace.weekly(
|
||||
window: window,
|
||||
now: input.now,
|
||||
defaultWindowMinutes: 10080,
|
||||
workDays: input.workDaysPerWeek))
|
||||
return Self.weeklyPaceDetail(
|
||||
provider: provider,
|
||||
window: window,
|
||||
now: input.now,
|
||||
pace: pace,
|
||||
showUsed: input.usageBarsShowUsed)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func antigravityMetricPaceDetail(
|
||||
window: RateWindow,
|
||||
input: Input) -> PaceDetail?
|
||||
{
|
||||
guard input.provider == .antigravity else { return nil }
|
||||
switch window.windowMinutes {
|
||||
case nil, 300:
|
||||
return self.sessionPaceDetail(
|
||||
provider: input.provider,
|
||||
window: window,
|
||||
now: input.now,
|
||||
showUsed: input.usageBarsShowUsed)
|
||||
case 10080:
|
||||
let pace = Self.displayableWeeklyPace(UsagePace.weekly(
|
||||
window: window,
|
||||
now: input.now,
|
||||
defaultWindowMinutes: 10080,
|
||||
workDays: input.workDaysPerWeek))
|
||||
return Self.weeklyPaceDetail(
|
||||
provider: input.provider,
|
||||
window: window,
|
||||
now: input.now,
|
||||
pace: pace,
|
||||
showUsed: input.usageBarsShowUsed)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func antigravityMetric(
|
||||
id: String,
|
||||
title: String,
|
||||
window: RateWindow?,
|
||||
input: Input,
|
||||
percentStyle: PercentStyle) -> Metric
|
||||
{
|
||||
guard let window else {
|
||||
let placeholderPercent = input.usageBarsShowUsed ? 100.0 : 0.0
|
||||
return Metric(
|
||||
id: id,
|
||||
title: title,
|
||||
percent: placeholderPercent,
|
||||
percentStyle: percentStyle,
|
||||
statusText: nil,
|
||||
resetText: nil,
|
||||
detailText: nil,
|
||||
detailLeftText: nil,
|
||||
detailRightText: nil,
|
||||
pacePercent: nil,
|
||||
paceOnTop: true)
|
||||
}
|
||||
let percent = input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent
|
||||
let paceDetail = Self.antigravityMetricPaceDetail(window: window, input: input)
|
||||
return Metric(
|
||||
id: id,
|
||||
title: title,
|
||||
percent: Self.clamped(percent),
|
||||
percentStyle: percentStyle,
|
||||
resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now),
|
||||
detailText: nil,
|
||||
detailLeftText: paceDetail?.leftLabel,
|
||||
detailRightText: paceDetail?.rightLabel,
|
||||
pacePercent: paceDetail?.pacePercent,
|
||||
paceOnTop: paceDetail?.paceOnTop ?? true)
|
||||
}
|
||||
|
||||
static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? {
|
||||
guard let limit else { return nil }
|
||||
|
||||
if let currentValue = limit.currentValue,
|
||||
let usage = limit.usage,
|
||||
let remaining = limit.remaining
|
||||
{
|
||||
let currentStr = UsageFormatter.tokenCountString(currentValue)
|
||||
let usageStr = UsageFormatter.tokenCountString(usage)
|
||||
let remainingStr = UsageFormatter.tokenCountString(remaining)
|
||||
return String(format: L("%@ / %@ (%@ remaining)"), currentStr, usageStr, remainingStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func crossModelSpendNotes(_ usage: CrossModelUsageSnapshot) -> [String] {
|
||||
let candidates: [(String, Double?)] = [
|
||||
(L("Today"), usage.daily?.cost),
|
||||
(L("This week"), usage.weekly?.cost),
|
||||
(L("This month"), usage.monthly?.cost),
|
||||
]
|
||||
let rendered = candidates.compactMap { candidate -> String? in
|
||||
guard let value = candidate.1 else { return nil }
|
||||
return "\(candidate.0): \(usage.currencyString(value))"
|
||||
}
|
||||
guard !rendered.isEmpty else { return [] }
|
||||
return [rendered.joined(separator: " · ")]
|
||||
}
|
||||
|
||||
static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? {
|
||||
guard provider == .openrouter,
|
||||
let usage = snapshot.openRouterUsage,
|
||||
usage.hasValidKeyQuota,
|
||||
let keyRemaining = usage.keyRemaining,
|
||||
let keyLimit = usage.keyLimit
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let remaining = UsageFormatter.usdString(keyRemaining)
|
||||
let limit = UsageFormatter.usdString(keyLimit)
|
||||
return String(format: L("%@/%@ left"), remaining, limit)
|
||||
}
|
||||
|
||||
static func syntheticRegenDetail(
|
||||
weekly: RateWindow,
|
||||
cost: ProviderCostSnapshot?,
|
||||
now: Date,
|
||||
showUsed: Bool) -> (resetText: String, pace: PaceDetail)?
|
||||
{
|
||||
guard let cost,
|
||||
cost.limit > 0,
|
||||
let nextRegenAmount = cost.nextRegenAmount,
|
||||
nextRegenAmount > 0,
|
||||
let resetsAt = weekly.resetsAt
|
||||
else { return nil }
|
||||
|
||||
let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
|
||||
let resetText = String(format: L("Regenerates %@"), countdown)
|
||||
|
||||
let nextRegenPercent = (nextRegenAmount / cost.limit) * 100
|
||||
let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent)
|
||||
let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining
|
||||
let suffix = showUsed ? L("used after next regen") : L("after next regen")
|
||||
let ticksToFull = max(0, cost.used) / nextRegenAmount
|
||||
let left = String(format: "%.0f%% %@", afterNextRegen, suffix)
|
||||
let right = if ticksToFull <= 0.1 {
|
||||
L("Near full")
|
||||
} else if ticksToFull < 1.5 {
|
||||
L("Full in ~1 regen")
|
||||
} else {
|
||||
String(format: L("Full in ~%.0f regens"), ceil(ticksToFull))
|
||||
}
|
||||
return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true))
|
||||
}
|
||||
|
||||
static func syntheticRollingRegenDetail(
|
||||
window: RateWindow,
|
||||
now: Date,
|
||||
showUsed: Bool) -> (resetText: String, pace: PaceDetail)?
|
||||
{
|
||||
guard let resetsAt = window.resetsAt,
|
||||
let nextRegenPercent = window.nextRegenPercent,
|
||||
nextRegenPercent > 0
|
||||
else { return nil }
|
||||
|
||||
let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
|
||||
let resetText = String(format: L("Regenerates %@"), countdown)
|
||||
|
||||
let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent)
|
||||
let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining
|
||||
let suffix = showUsed ? L("used after next regen") : L("after next regen")
|
||||
let left = String(format: "%.0f%% %@", afterNextRegen, suffix)
|
||||
|
||||
let missingPercent = max(0, window.usedPercent)
|
||||
let ticksToFull = missingPercent / nextRegenPercent
|
||||
let right = if ticksToFull <= 0.1 {
|
||||
L("Near full")
|
||||
} else if ticksToFull < 1.5 {
|
||||
L("Full in ~1 regen")
|
||||
} else {
|
||||
String(format: L("Full in ~%.0f regens"), ceil(ticksToFull))
|
||||
}
|
||||
|
||||
return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension UsageMenuCardView.Model {
|
||||
struct Input {
|
||||
let provider: UsageProvider
|
||||
let metadata: ProviderMetadata
|
||||
let snapshot: UsageSnapshot?
|
||||
let codexProjection: CodexConsumerProjection?
|
||||
let credits: CreditsSnapshot?
|
||||
let creditsError: String?
|
||||
let dashboard: OpenAIDashboardSnapshot?
|
||||
let dashboardError: String?
|
||||
let tokenSnapshot: CostUsageTokenSnapshot?
|
||||
let tokenError: String?
|
||||
let account: AccountInfo
|
||||
let accountIsAuthoritative: Bool
|
||||
let planOverride: String?
|
||||
let isRefreshing: Bool
|
||||
let lastError: String?
|
||||
let limitsAvailability: UsageLimitsAvailability?
|
||||
let usageBarsShowUsed: Bool
|
||||
let resetTimeDisplayStyle: ResetTimeDisplayStyle
|
||||
let tokenCostUsageEnabled: Bool
|
||||
let tokenCostInlineDashboardEnabled: Bool
|
||||
let tokenCostMenuSectionEnabled: Bool
|
||||
let costComparisonPeriodsEnabled: Bool
|
||||
let showOptionalCreditsAndExtraUsage: Bool
|
||||
let codexSparkUsageVisible: Bool
|
||||
let copilotBudgetExtrasEnabled: Bool
|
||||
let sourceLabel: String?
|
||||
let kiloAutoMode: Bool
|
||||
let hidePersonalInfo: Bool
|
||||
let weeklyPace: UsagePace?
|
||||
let quotaWarningThresholds: [QuotaWarningWindow: [Int]]
|
||||
let workDaysPerWeek: Int?
|
||||
let usesLiveSubtitle: Bool
|
||||
let now: Date
|
||||
|
||||
init(
|
||||
provider: UsageProvider,
|
||||
metadata: ProviderMetadata,
|
||||
snapshot: UsageSnapshot?,
|
||||
codexProjection: CodexConsumerProjection? = nil,
|
||||
credits: CreditsSnapshot?,
|
||||
creditsError: String?,
|
||||
dashboard: OpenAIDashboardSnapshot?,
|
||||
dashboardError: String?,
|
||||
tokenSnapshot: CostUsageTokenSnapshot?,
|
||||
tokenError: String?,
|
||||
account: AccountInfo,
|
||||
accountIsAuthoritative: Bool = false,
|
||||
planOverride: String? = nil,
|
||||
isRefreshing: Bool,
|
||||
lastError: String?,
|
||||
limitsAvailability: UsageLimitsAvailability? = nil,
|
||||
usageBarsShowUsed: Bool,
|
||||
resetTimeDisplayStyle: ResetTimeDisplayStyle,
|
||||
tokenCostUsageEnabled: Bool,
|
||||
tokenCostInlineDashboardEnabled: Bool? = nil,
|
||||
tokenCostMenuSectionEnabled: Bool? = nil,
|
||||
costComparisonPeriodsEnabled: Bool = false,
|
||||
showOptionalCreditsAndExtraUsage: Bool,
|
||||
codexSparkUsageVisible: Bool = true,
|
||||
copilotBudgetExtrasEnabled: Bool = false,
|
||||
sourceLabel: String? = nil,
|
||||
kiloAutoMode: Bool = false,
|
||||
hidePersonalInfo: Bool,
|
||||
weeklyPace: UsagePace? = nil,
|
||||
quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:],
|
||||
workDaysPerWeek: Int? = nil,
|
||||
usesLiveSubtitle: Bool = false,
|
||||
now: Date)
|
||||
{
|
||||
self.provider = provider
|
||||
self.metadata = metadata
|
||||
self.snapshot = snapshot
|
||||
self.codexProjection = codexProjection
|
||||
self.credits = credits
|
||||
self.creditsError = creditsError
|
||||
self.dashboard = dashboard
|
||||
self.dashboardError = dashboardError
|
||||
self.tokenSnapshot = tokenSnapshot
|
||||
self.tokenError = tokenError
|
||||
self.account = account
|
||||
self.accountIsAuthoritative = accountIsAuthoritative
|
||||
self.planOverride = planOverride
|
||||
self.isRefreshing = isRefreshing
|
||||
self.lastError = lastError
|
||||
self.limitsAvailability = limitsAvailability
|
||||
self.usageBarsShowUsed = usageBarsShowUsed
|
||||
self.resetTimeDisplayStyle = resetTimeDisplayStyle
|
||||
self.tokenCostUsageEnabled = tokenCostUsageEnabled
|
||||
self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled
|
||||
self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled
|
||||
self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled
|
||||
self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage
|
||||
self.codexSparkUsageVisible = codexSparkUsageVisible
|
||||
self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled
|
||||
self.sourceLabel = sourceLabel
|
||||
self.kiloAutoMode = kiloAutoMode
|
||||
self.hidePersonalInfo = hidePersonalInfo
|
||||
self.weeklyPace = weeklyPace
|
||||
self.quotaWarningThresholds = quotaWarningThresholds
|
||||
self.workDaysPerWeek = workDaysPerWeek
|
||||
self.usesLiveSubtitle = usesLiveSubtitle
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct MenuContent: View {
|
||||
@Bindable var store: UsageStore
|
||||
@Bindable var settings: SettingsStore
|
||||
let account: AccountInfo
|
||||
let updater: UpdaterProviding
|
||||
let provider: UsageProvider?
|
||||
let actions: MenuActions
|
||||
|
||||
var body: some View {
|
||||
let descriptor = MenuDescriptor.build(
|
||||
provider: self.provider,
|
||||
store: self.store,
|
||||
settings: self.settings,
|
||||
account: self.account,
|
||||
updateReady: self.updater.updateStatus.isUpdateReady)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(descriptor.sections.enumerated()), id: \.offset) { index, section in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(Array(section.entries.enumerated()), id: \.offset) { _, entry in
|
||||
self.row(for: entry)
|
||||
}
|
||||
}
|
||||
if index < descriptor.sections.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.frame(minWidth: 260, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(for entry: MenuDescriptor.Entry) -> some View {
|
||||
switch entry {
|
||||
case let .text(text, style):
|
||||
switch style {
|
||||
case .headline:
|
||||
Text(text).font(.headline)
|
||||
.accessibilityLabel(text)
|
||||
case .primary:
|
||||
Text(text)
|
||||
.accessibilityLabel(text)
|
||||
case .secondary:
|
||||
Text(text).foregroundStyle(.secondary).font(.footnote)
|
||||
.accessibilityLabel(text)
|
||||
}
|
||||
case let .action(title, action):
|
||||
Button {
|
||||
self.perform(action)
|
||||
} label: {
|
||||
if let icon = self.iconName(for: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.imageScale(.medium)
|
||||
.frame(width: 18, alignment: .center)
|
||||
Text(title)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(title)
|
||||
} else {
|
||||
Text(title)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
case let .unavailable(title, tooltip):
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
.help(tooltip ?? "")
|
||||
case let .submenu(title, systemImageName, submenuItems):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
if let systemImageName {
|
||||
Image(systemName: systemImageName)
|
||||
}
|
||||
Text(title).font(.headline)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(title)
|
||||
ForEach(Array(submenuItems.enumerated()), id: \.offset) { _, submenuItem in
|
||||
HStack(spacing: 8) {
|
||||
if submenuItem.isChecked {
|
||||
Image(systemName: "checkmark")
|
||||
.imageScale(.small)
|
||||
.frame(width: 18, alignment: .center)
|
||||
} else {
|
||||
Spacer().frame(width: 18)
|
||||
}
|
||||
Text(submenuItem.title)
|
||||
.foregroundStyle(submenuItem.isEnabled ? .primary : .secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(submenuItem.title)
|
||||
}
|
||||
}
|
||||
case .divider:
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
private func iconName(for action: MenuDescriptor.MenuAction) -> String? {
|
||||
action.systemImageName
|
||||
}
|
||||
|
||||
private func perform(_ action: MenuDescriptor.MenuAction) {
|
||||
switch action {
|
||||
case .refresh:
|
||||
self.actions.refresh()
|
||||
case .refreshAugmentSession:
|
||||
self.actions.refreshAugmentSession()
|
||||
case .installUpdate:
|
||||
self.actions.installUpdate()
|
||||
case .dashboard:
|
||||
self.actions.openDashboard()
|
||||
case .statusPage:
|
||||
self.actions.openStatusPage()
|
||||
case .changelog:
|
||||
self.actions.openChangelog()
|
||||
case .addCodexAccount:
|
||||
self.actions.addCodexAccount()
|
||||
case .requestCodexSystemPromotion:
|
||||
return
|
||||
case let .addProviderAccount(provider):
|
||||
self.actions.switchAccount(provider)
|
||||
case let .switchAccount(provider):
|
||||
self.actions.switchAccount(provider)
|
||||
case let .openTerminal(command):
|
||||
self.actions.openTerminal(command)
|
||||
case let .loginToProvider(url):
|
||||
if let urlObj = URL(string: url) {
|
||||
NSWorkspace.shared.open(urlObj)
|
||||
}
|
||||
case .settings:
|
||||
self.actions.openSettings()
|
||||
case .about:
|
||||
self.actions.openAbout()
|
||||
case .quit:
|
||||
self.actions.quit()
|
||||
case let .copyError(message):
|
||||
self.actions.copyError(message)
|
||||
case .focusAgentSession:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuActions {
|
||||
let installUpdate: () -> Void
|
||||
let refresh: () -> Void
|
||||
let refreshAugmentSession: () -> Void
|
||||
let openDashboard: () -> Void
|
||||
let openStatusPage: () -> Void
|
||||
let openChangelog: () -> Void
|
||||
let addCodexAccount: () -> Void
|
||||
let switchAccount: (UsageProvider) -> Void
|
||||
let openTerminal: (String) -> Void
|
||||
let openSettings: () -> Void
|
||||
let openAbout: () -> Void
|
||||
let quit: () -> Void
|
||||
let copyError: (String) -> Void
|
||||
}
|
||||
|
||||
struct PersistentRefreshRowMetrics: Equatable {
|
||||
static let defaults = Self(
|
||||
rowHeight: 24,
|
||||
selectionHorizontalInset: 5,
|
||||
selectionVerticalInset: 0,
|
||||
selectionCornerRadius: 7,
|
||||
// Align the custom row's image/title frames with native NSMenuItem columns.
|
||||
leadingPadding: 15,
|
||||
trailingPadding: 8,
|
||||
iconWidth: 16,
|
||||
iconSymbolPointSize: 16,
|
||||
iconSymbolWeight: .regular,
|
||||
iconTitleSpacing: 4.5,
|
||||
shortcutFontSize: 13,
|
||||
shortcutXOffset: -9.5,
|
||||
shortcutYOffset: 0)
|
||||
|
||||
let rowHeight: CGFloat
|
||||
let selectionHorizontalInset: CGFloat
|
||||
let selectionVerticalInset: CGFloat
|
||||
let selectionCornerRadius: CGFloat
|
||||
let leadingPadding: CGFloat
|
||||
let trailingPadding: CGFloat
|
||||
let iconWidth: CGFloat
|
||||
let iconSymbolPointSize: CGFloat
|
||||
let iconSymbolWeight: NSFont.Weight
|
||||
let iconTitleSpacing: CGFloat
|
||||
let shortcutFontSize: CGFloat
|
||||
let shortcutXOffset: CGFloat
|
||||
let shortcutYOffset: CGFloat
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct StatusIconView: View {
|
||||
@Bindable var store: UsageStore
|
||||
let provider: UsageProvider
|
||||
|
||||
var body: some View {
|
||||
Image(nsImage: self.icon)
|
||||
.renderingMode(.template)
|
||||
.interpolation(.none)
|
||||
.accessibilityLabel(self.accessibilityLabel)
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: self.provider)
|
||||
return descriptor.metadata.displayName
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
let snapshot = self.store.snapshot(for: self.provider)
|
||||
guard let snap = snapshot else {
|
||||
return L("No data")
|
||||
}
|
||||
let remaining = IconRemainingResolver.resolvedRemaining(
|
||||
snapshot: snap,
|
||||
style: self.store.style(for: self.provider))
|
||||
let primary = remaining.primary
|
||||
let percent = primary.map(Self.accessibilityPercentRemaining) ?? L("Unknown")
|
||||
let stale = self.store.isStale(provider: self.provider)
|
||||
return stale ? "\(percent), \(L("stale data"))" : percent
|
||||
}
|
||||
|
||||
static func accessibilityPercentRemaining(_ remaining: Double) -> String {
|
||||
String(format: L("%d percent remaining"), Int(remaining.rounded()))
|
||||
}
|
||||
|
||||
private var icon: NSImage {
|
||||
let now = Date()
|
||||
let snapshot = self.store.snapshot(for: self.provider)
|
||||
let remaining = snapshot.map {
|
||||
IconRemainingResolver.resolvedRemaining(
|
||||
snapshot: $0,
|
||||
style: self.store.style(for: self.provider),
|
||||
now: now)
|
||||
}
|
||||
let creditsProjection = self.store.codexConsumerProjectionIfNeeded(
|
||||
for: self.provider,
|
||||
surface: .menuBar,
|
||||
snapshotOverride: snapshot,
|
||||
now: now)
|
||||
let creditsRemaining = creditsProjection?.menuBarFallback == .creditsBalance
|
||||
? self.store.codexMenuBarCreditsRemaining(
|
||||
snapshotOverride: snapshot,
|
||||
now: now)
|
||||
: nil
|
||||
return IconRenderer.makeIcon(
|
||||
primaryRemaining: remaining?.primary,
|
||||
weeklyRemaining: remaining?.secondary,
|
||||
creditsRemaining: creditsRemaining,
|
||||
stale: self.store.isStale(provider: self.provider),
|
||||
style: self.store.style(for: self.provider),
|
||||
statusIndicator: self.store.statusIndicator(for: self.provider),
|
||||
hideCritters: self.store.settings.menuBarHidesCritters)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension MenuDescriptor {
|
||||
static func appendOpenAIAPIUsageSummary(
|
||||
entries: inout [Entry],
|
||||
usage: OpenAIAPIUsageSnapshot)
|
||||
{
|
||||
let today = usage.currentDay
|
||||
let last7 = usage.last7Days
|
||||
let last30 = usage.last30Days
|
||||
let historyLabel = usage.historyWindowLabel
|
||||
|
||||
entries.append(.text(
|
||||
"\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
entries.append(.text(
|
||||
"7d: \(UsageFormatter.usdString(last7.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))",
|
||||
.secondary))
|
||||
entries.append(.text(
|
||||
"\(historyLabel): \(UsageFormatter.usdString(last30.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))",
|
||||
.secondary))
|
||||
if let topModel = usage.topModels.first?.name {
|
||||
entries.append(.text("\(L("Top model")): \(topModel)", .secondary))
|
||||
}
|
||||
}
|
||||
|
||||
static func appendClaudeAdminAPIUsageSummary(
|
||||
entries: inout [Entry],
|
||||
usage: ClaudeAdminAPIUsageSnapshot)
|
||||
{
|
||||
let today = usage.currentDay
|
||||
let last7 = usage.last7Days
|
||||
let last30 = usage.last30Days
|
||||
|
||||
entries.append(.text(
|
||||
"\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
entries.append(.text(
|
||||
"7d: \(UsageFormatter.usdString(last7.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
entries.append(.text(
|
||||
"30d: \(UsageFormatter.usdString(last30.costUSD)) · " +
|
||||
"\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
if let topModel = usage.topModels.first?.name {
|
||||
entries.append(.text("\(L("Top model")): \(topModel)", .secondary))
|
||||
}
|
||||
}
|
||||
|
||||
static func appendOpenRouterUsageSummary(
|
||||
entries: inout [Entry],
|
||||
usage: OpenRouterUsageSnapshot)
|
||||
{
|
||||
if let daily = usage.keyUsageDaily {
|
||||
entries.append(.text("\(L("Today")): \(UsageFormatter.usdString(daily))", .secondary))
|
||||
}
|
||||
if let weekly = usage.keyUsageWeekly {
|
||||
entries.append(.text("\(L("Week")): \(UsageFormatter.usdString(weekly))", .secondary))
|
||||
}
|
||||
if let monthly = usage.keyUsageMonthly {
|
||||
entries.append(.text("\(L("Month")): \(UsageFormatter.usdString(monthly))", .secondary))
|
||||
}
|
||||
}
|
||||
|
||||
static func appendMistralUsageSummary(
|
||||
entries: inout [Entry],
|
||||
usage: MistralUsageSnapshot)
|
||||
{
|
||||
let latest = usage.daily.last
|
||||
if let latest {
|
||||
entries.append(.text(
|
||||
"\(L("Latest")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, latest.cost))) · " +
|
||||
"\(UsageFormatter.tokenCountString(latest.totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
}
|
||||
let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens
|
||||
entries.append(.text(
|
||||
"\(L("Month")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, usage.totalCost))) · " +
|
||||
"\(UsageFormatter.tokenCountString(totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
if let top = Self.topMistralModel(from: usage.daily) {
|
||||
entries.append(.text("\(L("Top model")): \(top)", .secondary))
|
||||
}
|
||||
}
|
||||
|
||||
static func appendPoeUsageSummary(
|
||||
entries: inout [Entry],
|
||||
usage: PoeUsageHistorySnapshot)
|
||||
{
|
||||
let today = usage.currentDay()
|
||||
let week = usage.last7Days
|
||||
let month = usage.last30Days
|
||||
let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
|
||||
let weekCostSuffix = week.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
|
||||
let monthCostSuffix = month.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
|
||||
entries.append(.text(
|
||||
"\(L("Today")): \(Self.pointsString(today.points)) · " +
|
||||
"\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayCostSuffix)",
|
||||
.secondary))
|
||||
entries.append(.text(
|
||||
"7d: \(Self.pointsString(week.points)) · " +
|
||||
"\(UsageFormatter.tokenCountString(week.requests)) \(L("requests"))\(weekCostSuffix)",
|
||||
.secondary))
|
||||
entries.append(.text(
|
||||
"30d: \(Self.pointsString(month.points)) · " +
|
||||
"\(UsageFormatter.tokenCountString(month.requests)) \(L("requests"))\(monthCostSuffix)",
|
||||
.secondary))
|
||||
if let topModel = usage.topModels.first {
|
||||
entries.append(
|
||||
.text(
|
||||
"\(L("Top model")): \(topModel.name) (\(Self.pointsString(topModel.points)))",
|
||||
.secondary))
|
||||
}
|
||||
if !usage.topUsageTypes.isEmpty {
|
||||
let summary = usage.topUsageTypes
|
||||
.prefix(2)
|
||||
.map { "\($0.name): \(Self.pointsString($0.points))" }
|
||||
.joined(separator: " · ")
|
||||
entries.append(.text("Usage mix: \(summary)", .secondary))
|
||||
}
|
||||
let recent = usage.recentEntries(limit: 3)
|
||||
if !recent.isEmpty {
|
||||
entries.append(.text("Recent activity:", .secondary))
|
||||
for entry in recent {
|
||||
let stamp = Self.poeTimeString(entry.createdAt)
|
||||
entries.append(.text(
|
||||
"\(stamp) · \(entry.model) · \(Self.pointsString(entry.points))",
|
||||
.secondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? {
|
||||
var tokens: [String: Int] = [:]
|
||||
for entry in entries {
|
||||
for model in entry.models {
|
||||
tokens[model.name, default: 0] += model.totalTokens
|
||||
}
|
||||
}
|
||||
return tokens.max {
|
||||
if $0.value == $1.value {
|
||||
return $0.key > $1.key
|
||||
}
|
||||
return $0.value < $1.value
|
||||
}?.key
|
||||
}
|
||||
|
||||
private static func pointsString(_ points: Double) -> String {
|
||||
let value = max(0, points)
|
||||
if value.rounded() == value {
|
||||
return "\(UsageFormatter.tokenCountString(Int(value))) points"
|
||||
}
|
||||
return "\(String(format: "%.1f", value)) points"
|
||||
}
|
||||
|
||||
private static func poeTimeString(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import CodexBarCore
|
||||
|
||||
extension MenuDescriptor {
|
||||
static func appendWayfinderUsageSummary(
|
||||
entries: inout [Entry],
|
||||
usage: WayfinderUsageSnapshot)
|
||||
{
|
||||
for line in usage.displayLines {
|
||||
entries.append(.text(line, .secondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct MenuDescriptor {
|
||||
struct SubmenuItem: Equatable {
|
||||
let title: String
|
||||
let action: MenuAction?
|
||||
let isEnabled: Bool
|
||||
let isChecked: Bool
|
||||
|
||||
init(title: String, action: MenuAction?, isEnabled: Bool = true, isChecked: Bool = false) {
|
||||
self.title = title
|
||||
self.action = action
|
||||
self.isEnabled = isEnabled
|
||||
self.isChecked = isChecked
|
||||
}
|
||||
}
|
||||
|
||||
struct Section {
|
||||
var entries: [Entry]
|
||||
}
|
||||
|
||||
enum Entry {
|
||||
case text(String, TextStyle)
|
||||
case action(String, MenuAction)
|
||||
case unavailable(String, String?)
|
||||
case submenu(String, String?, [SubmenuItem])
|
||||
case divider
|
||||
|
||||
var isActionable: Bool {
|
||||
switch self {
|
||||
case .action, .submenu, .unavailable: true
|
||||
case .text, .divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MenuActionSystemImage: String {
|
||||
case installUpdate = "arrow.down.circle"
|
||||
case refresh = "arrow.clockwise"
|
||||
case dashboard = "chart.xyaxis.line"
|
||||
case statusPage = "waveform.path.ecg"
|
||||
case changelog = "list.bullet.rectangle"
|
||||
case addAccount = "plus"
|
||||
case systemAccount = "person.crop.circle"
|
||||
case switchAccount = "key"
|
||||
case openTerminal = "terminal"
|
||||
case loginToProvider = "arrow.right.square"
|
||||
case settings = "gearshape"
|
||||
case about = "info.circle"
|
||||
case quit = "xmark.rectangle"
|
||||
case copyError = "doc.on.doc"
|
||||
}
|
||||
|
||||
enum TextStyle {
|
||||
case headline
|
||||
case primary
|
||||
case secondary
|
||||
}
|
||||
|
||||
enum MenuAction: Equatable {
|
||||
case installUpdate
|
||||
case refresh
|
||||
case refreshAugmentSession
|
||||
case dashboard
|
||||
case statusPage
|
||||
case changelog
|
||||
case addCodexAccount
|
||||
case requestCodexSystemPromotion(UUID)
|
||||
case addProviderAccount(UsageProvider)
|
||||
case switchAccount(UsageProvider)
|
||||
case openTerminal(command: String)
|
||||
case loginToProvider(url: String)
|
||||
case settings
|
||||
case about
|
||||
case quit
|
||||
case copyError(String)
|
||||
case focusAgentSession(AgentSession, remoteHost: String?)
|
||||
}
|
||||
|
||||
var sections: [Section]
|
||||
|
||||
static func build(
|
||||
provider: UsageProvider?,
|
||||
store: UsageStore,
|
||||
settings: SettingsStore,
|
||||
account: AccountInfo,
|
||||
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? = nil,
|
||||
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil,
|
||||
updateReady: Bool,
|
||||
includeContextualActions: Bool = true,
|
||||
agentSessionsEnabled: Bool = false,
|
||||
localAgentSessions: [AgentSession] = [],
|
||||
remoteAgentHosts: [RemoteSessionHostResult] = [],
|
||||
now: Date = Date()) -> MenuDescriptor
|
||||
{
|
||||
var sections: [Section] = []
|
||||
|
||||
if let provider {
|
||||
let fallbackAccount = store.accountInfo(for: provider)
|
||||
sections.append(Self.usageSection(for: provider, store: store, settings: settings))
|
||||
if let accountSection = Self.accountSection(
|
||||
for: provider,
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fallbackAccount)
|
||||
{
|
||||
sections.append(accountSection)
|
||||
}
|
||||
} else {
|
||||
var addedUsage = false
|
||||
|
||||
for enabledProvider in store.enabledProviders() {
|
||||
sections.append(Self.usageSection(for: enabledProvider, store: store, settings: settings))
|
||||
addedUsage = true
|
||||
}
|
||||
if addedUsage {
|
||||
if let accountProvider = Self.accountProviderForCombined(store: store),
|
||||
let fallbackAccount = Optional(store.accountInfo(for: accountProvider)),
|
||||
let accountSection = Self.accountSection(
|
||||
for: accountProvider,
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fallbackAccount)
|
||||
{
|
||||
sections.append(accountSection)
|
||||
}
|
||||
} else {
|
||||
sections.append(Section(entries: [.text(L("No usage configured."), .secondary)]))
|
||||
}
|
||||
}
|
||||
|
||||
if includeContextualActions {
|
||||
let actions = Self.actionsSection(
|
||||
for: provider,
|
||||
store: store,
|
||||
account: account,
|
||||
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
|
||||
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)
|
||||
if !actions.entries.isEmpty {
|
||||
sections.append(actions)
|
||||
}
|
||||
}
|
||||
if agentSessionsEnabled {
|
||||
sections.append(Self.agentSessionsSection(
|
||||
localSessions: localAgentSessions,
|
||||
remoteHosts: remoteAgentHosts,
|
||||
now: now))
|
||||
}
|
||||
sections.append(Self.metaSection(updateReady: updateReady))
|
||||
|
||||
return MenuDescriptor(sections: sections)
|
||||
}
|
||||
|
||||
static func agentSessionsSection(
|
||||
localSessions: [AgentSession],
|
||||
remoteHosts: [RemoteSessionHostResult],
|
||||
now: Date = Date()) -> Section
|
||||
{
|
||||
let totalCount = localSessions.count + remoteHosts.reduce(0) { $0 + $1.sessions.count }
|
||||
var entries: [Entry] = [.text("Agent Sessions (\(totalCount))", .headline)]
|
||||
|
||||
for session in localSessions {
|
||||
entries.append(.action(
|
||||
self.agentSessionRowTitle(session, now: now),
|
||||
.focusAgentSession(session, remoteHost: nil)))
|
||||
}
|
||||
for remoteHost in remoteHosts {
|
||||
if let error = remoteHost.error {
|
||||
entries.append(.unavailable("\(remoteHost.host) — unreachable", error))
|
||||
continue
|
||||
}
|
||||
entries.append(.text("\(remoteHost.host) — \(remoteHost.sessions.count)", .secondary))
|
||||
for session in remoteHost.sessions {
|
||||
entries.append(.action(
|
||||
self.agentSessionRowTitle(session, now: now),
|
||||
.focusAgentSession(session, remoteHost: remoteHost.host)))
|
||||
}
|
||||
}
|
||||
if totalCount == 0 {
|
||||
entries.append(.unavailable("No agent sessions found", nil))
|
||||
}
|
||||
return Section(entries: entries)
|
||||
}
|
||||
|
||||
private static func agentSessionRowTitle(_ session: AgentSession, now: Date) -> String {
|
||||
let state = session.state == .active ? "●" : "○"
|
||||
let providerGlyph = session.provider == .codex ? "⌘" : "✦"
|
||||
let project = session.projectName ?? "Unknown project"
|
||||
return "\(state) \(providerGlyph) \(project) — \(session.provider.rawValue) · " +
|
||||
"\(session.source.rawValue) · \(self.agentSessionAge(session, now: now))"
|
||||
}
|
||||
|
||||
private static func agentSessionAge(_ session: AgentSession, now: Date) -> String {
|
||||
guard let activity = session.lastActivityAt ?? session.startedAt else { return "now" }
|
||||
let seconds = max(0, Int(now.timeIntervalSince(activity)))
|
||||
if seconds < 60 {
|
||||
return "\(seconds)s"
|
||||
}
|
||||
if seconds < 3600 {
|
||||
return "\(seconds / 60)m"
|
||||
}
|
||||
if seconds < 86400 {
|
||||
return "\(seconds / 3600)h"
|
||||
}
|
||||
return "\(seconds / 86400)d"
|
||||
}
|
||||
|
||||
private static func usageSection(
|
||||
for provider: UsageProvider,
|
||||
store: UsageStore,
|
||||
settings: SettingsStore) -> Section
|
||||
{
|
||||
let meta = store.metadata(for: provider)
|
||||
var entries: [Entry] = []
|
||||
let headlineText: String = {
|
||||
if let ver = Self.versionNumber(for: provider, store: store) {
|
||||
return "\(meta.displayName) \(ver)"
|
||||
}
|
||||
return meta.displayName
|
||||
}()
|
||||
entries.append(.text(headlineText, .headline))
|
||||
|
||||
if let snap = store.snapshot(for: provider) {
|
||||
let resetStyle = settings.resetTimeDisplayStyle
|
||||
let labels = Self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snap)
|
||||
if let primary = snap.primary {
|
||||
let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus ||
|
||||
provider == .deepseek || provider == .azureopenai || provider == .mimo || provider == .qoder ||
|
||||
provider == .sub2api
|
||||
let primaryWindow = if primaryDescriptionIsDetail {
|
||||
// Some providers use resetDescription for non-reset detail
|
||||
// (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line.
|
||||
RateWindow(
|
||||
usedPercent: primary.usedPercent,
|
||||
windowMinutes: primary.windowMinutes,
|
||||
resetsAt: primary.resetsAt,
|
||||
resetDescription: nil)
|
||||
} else {
|
||||
primary
|
||||
}
|
||||
Self.appendRateWindow(
|
||||
entries: &entries,
|
||||
title: labels.primary,
|
||||
window: primaryWindow,
|
||||
resetStyle: resetStyle,
|
||||
showUsed: settings.usageBarsShowUsed)
|
||||
if primaryDescriptionIsDetail,
|
||||
let primaryDetail,
|
||||
!primaryDetail.isEmpty
|
||||
{
|
||||
entries.append(.text(primaryDetail, .secondary))
|
||||
}
|
||||
if provider == .crof,
|
||||
primary.resetsAt != nil,
|
||||
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!detail.isEmpty
|
||||
{
|
||||
entries.append(.text(detail, .secondary))
|
||||
}
|
||||
if provider == .abacus,
|
||||
let pace = store.weeklyPace(provider: provider, window: primary)
|
||||
{
|
||||
let paceSummary = UsagePaceText.weeklySummary(provider: provider, pace: pace)
|
||||
entries.append(.text(paceSummary, .secondary))
|
||||
}
|
||||
if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) {
|
||||
entries.append(.text(paceSummary, .secondary))
|
||||
}
|
||||
}
|
||||
if let weekly = snap.secondary {
|
||||
let weeklyResetOverride: String? = {
|
||||
guard provider == .warp || provider == .kilo || provider == .perplexity || provider == .crof ||
|
||||
provider == .sub2api
|
||||
else { return nil }
|
||||
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let detail, !detail.isEmpty else { return nil }
|
||||
if provider == .kilo, weekly.resetsAt != nil {
|
||||
return nil
|
||||
}
|
||||
return detail
|
||||
}()
|
||||
Self.appendRateWindow(
|
||||
entries: &entries,
|
||||
title: labels.secondary,
|
||||
window: weekly,
|
||||
resetStyle: resetStyle,
|
||||
showUsed: settings.usageBarsShowUsed,
|
||||
resetOverride: weeklyResetOverride)
|
||||
if provider == .kilo,
|
||||
weekly.resetsAt != nil,
|
||||
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!detail.isEmpty
|
||||
{
|
||||
entries.append(.text(detail, .secondary))
|
||||
}
|
||||
if let pace = store.weeklyPace(provider: provider, window: weekly) {
|
||||
let paceSummary = UsagePaceText.weeklySummary(provider: provider, pace: pace)
|
||||
entries.append(.text(paceSummary, .secondary))
|
||||
}
|
||||
}
|
||||
if labels.showsTertiary, let opus = snap.tertiary {
|
||||
// Perplexity purchased credits don't reset; show the balance as plain text.
|
||||
let opusResetOverride: String? = provider == .perplexity || provider == .sub2api
|
||||
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: nil
|
||||
Self.appendRateWindow(
|
||||
entries: &entries,
|
||||
title: labels.tertiary,
|
||||
window: opus,
|
||||
resetStyle: resetStyle,
|
||||
showUsed: settings.usageBarsShowUsed,
|
||||
resetOverride: opusResetOverride)
|
||||
}
|
||||
|
||||
Self.appendProviderUsageSummaries(
|
||||
entries: &entries,
|
||||
snapshot: snap,
|
||||
showOptionalUsage: settings.showOptionalCreditsAndExtraUsage)
|
||||
if snap.rateLimitsUnavailable(for: provider) {
|
||||
entries.append(.text(L("Limits not available"), .secondary))
|
||||
}
|
||||
} else if !store.isStale(provider: provider),
|
||||
store.knownLimitsAvailability(for: provider)?.isUnavailable == true
|
||||
{
|
||||
entries.append(.text(L("Limits not available"), .secondary))
|
||||
} else {
|
||||
entries.append(.text(L("No usage yet"), .secondary))
|
||||
}
|
||||
|
||||
let usageContext = ProviderMenuUsageContext(
|
||||
provider: provider,
|
||||
store: store,
|
||||
settings: settings,
|
||||
metadata: meta,
|
||||
snapshot: store.snapshot(for: provider))
|
||||
ProviderCatalog.implementation(for: provider)?
|
||||
.appendUsageMenuEntries(context: usageContext, entries: &entries)
|
||||
|
||||
return Section(entries: entries)
|
||||
}
|
||||
|
||||
private static func appendProviderUsageSummaries(
|
||||
entries: inout [Entry],
|
||||
snapshot: UsageSnapshot,
|
||||
showOptionalUsage: Bool)
|
||||
{
|
||||
if let cost = snapshot.providerCost {
|
||||
if cost.currencyCode == "Quota" {
|
||||
let used = String(format: "%.0f", cost.used)
|
||||
let limit = String(format: "%.0f", cost.limit)
|
||||
entries.append(.text("\(L("Quota")): \(used) / \(limit)", .primary))
|
||||
}
|
||||
}
|
||||
if let openAIAPIUsage = snapshot.openAIAPIUsage {
|
||||
Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage)
|
||||
}
|
||||
if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage {
|
||||
Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage)
|
||||
}
|
||||
if let openRouterUsage = snapshot.openRouterUsage {
|
||||
Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage)
|
||||
}
|
||||
if let clawRouterUsage = snapshot.clawRouterUsage {
|
||||
entries.append(.text(
|
||||
"\(UsageFormatter.tokenCountString(clawRouterUsage.requestCount)) \(L("requests")) · " +
|
||||
"\(UsageFormatter.tokenCountString(clawRouterUsage.totalTokens)) \(L("tokens"))",
|
||||
.secondary))
|
||||
if !clawRouterUsage.providers.isEmpty {
|
||||
let mix = clawRouterUsage.providers.prefix(5)
|
||||
.map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" }
|
||||
.joined(separator: " · ")
|
||||
entries.append(.text("Routed providers: \(mix)", .secondary))
|
||||
}
|
||||
}
|
||||
if let wayfinderUsage = snapshot.wayfinderUsage {
|
||||
Self.appendWayfinderUsageSummary(entries: &entries, usage: wayfinderUsage)
|
||||
}
|
||||
if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty {
|
||||
Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage)
|
||||
}
|
||||
if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty {
|
||||
Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage)
|
||||
}
|
||||
if let mimoUsage = snapshot.mimoUsage {
|
||||
entries.append(.text("\(L("Balance")): \(mimoUsage.balanceDetail)", .primary))
|
||||
}
|
||||
// Sakana pay-as-you-go is optional data gated by "Show optional credits and extra usage".
|
||||
// Gate the render on the setting too, not just the fetch: toggling the setting off only
|
||||
// rebuilds the menu, it does not immediately refetch, so a previously-populated
|
||||
// sakanaPayAsYouGo would otherwise linger in the cached snapshot until the next refresh.
|
||||
if showOptionalUsage, let sakanaPayAsYouGo = snapshot.sakanaPayAsYouGo {
|
||||
entries.append(.text("\(L("Balance")): \(sakanaPayAsYouGo.balanceDetail)", .primary))
|
||||
if let periodUsageTotal = sakanaPayAsYouGo.periodUsageTotal {
|
||||
entries.append(.text(
|
||||
"\(L("Usage")): \(UsageFormatter.usdString(periodUsageTotal))",
|
||||
.secondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func accountSection(
|
||||
for provider: UsageProvider,
|
||||
store: UsageStore,
|
||||
settings: SettingsStore,
|
||||
account: AccountInfo) -> Section?
|
||||
{
|
||||
let snapshot = store.snapshot(for: provider)
|
||||
let metadata = store.metadata(for: provider)
|
||||
let entries = Self.accountEntries(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
metadata: metadata,
|
||||
fallback: account,
|
||||
hidePersonalInfo: settings.hidePersonalInfo)
|
||||
guard !entries.isEmpty else { return nil }
|
||||
return Section(entries: entries)
|
||||
}
|
||||
|
||||
private static func accountEntries(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?,
|
||||
metadata: ProviderMetadata,
|
||||
fallback: AccountInfo,
|
||||
hidePersonalInfo: Bool) -> [Entry]
|
||||
{
|
||||
var entries: [Entry] = []
|
||||
let emailText = snapshot?.accountEmail(for: provider)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let loginMethodText = snapshot?.loginMethod(for: provider)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo)
|
||||
|
||||
if let emailText, !emailText.isEmpty, !redactedEmail.isEmpty {
|
||||
entries.append(.text("\(L("Account")): \(redactedEmail)", .secondary))
|
||||
}
|
||||
if provider == .kiro {
|
||||
if let plan = snapshot?.kiroUsage?.displayPlanName,
|
||||
!plan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
entries.append(.text("\(L("Plan")): \(plan)", .secondary))
|
||||
}
|
||||
if let loginMethodText, !loginMethodText.isEmpty {
|
||||
entries.append(.text("\(L("Auth")): \(loginMethodText)", .secondary))
|
||||
}
|
||||
if let overages = snapshot?.kiroUsage?.overagesStatus,
|
||||
!overages.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
entries.append(.text("\(L("Overages")): \(overages)", .secondary))
|
||||
}
|
||||
} else if provider == .kilo {
|
||||
let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText)
|
||||
if let pass = kiloLogin.pass {
|
||||
entries.append(.text("\(L("Plan")): \(AccountFormatter.plan(pass, provider: provider))", .secondary))
|
||||
}
|
||||
for detail in kiloLogin.details {
|
||||
entries.append(.text("\(L("Activity")): \(detail)", .secondary))
|
||||
}
|
||||
} else if provider == .crossmodel {
|
||||
if let loginMethodText, !loginMethodText.isEmpty {
|
||||
entries.append(.text("\(L("Auth")): \(loginMethodText)", .secondary))
|
||||
}
|
||||
} else if let loginMethodText, !loginMethodText.isEmpty {
|
||||
if provider == .openrouter || provider == .mimo || provider == .poe,
|
||||
loginMethodText.localizedCaseInsensitiveContains("balance:")
|
||||
{
|
||||
let balanceValue = loginMethodText
|
||||
.replacingOccurrences(
|
||||
of: #"(?i)^\s*balance:\s*"#,
|
||||
with: "",
|
||||
options: [.regularExpression])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = balanceValue.isEmpty ? loginMethodText : balanceValue
|
||||
entries.append(
|
||||
.text("\(L("Balance")): \(AccountFormatter.plan(value, provider: provider))", .secondary))
|
||||
} else {
|
||||
entries.append(
|
||||
.text(
|
||||
"\(L("Plan")): \(AccountFormatter.plan(loginMethodText, provider: provider))",
|
||||
.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
if metadata.usesAccountFallback {
|
||||
if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty {
|
||||
let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo)
|
||||
if !redacted.isEmpty {
|
||||
entries.append(.text("\(L("Account")): \(redacted)", .secondary))
|
||||
}
|
||||
}
|
||||
if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty {
|
||||
entries.append(
|
||||
.text(
|
||||
"\(L("Plan")): \(AccountFormatter.plan(fallbackPlan, provider: provider))",
|
||||
.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func kiloLoginParts(loginMethod: String?) -> (pass: String?, details: [String]) {
|
||||
guard let loginMethod else {
|
||||
return (nil, [])
|
||||
}
|
||||
let parts = loginMethod
|
||||
.components(separatedBy: "·")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !parts.isEmpty else {
|
||||
return (nil, [])
|
||||
}
|
||||
let first = parts[0]
|
||||
if self.isKiloActivitySegment(first) {
|
||||
return (nil, parts)
|
||||
}
|
||||
return (first, Array(parts.dropFirst()))
|
||||
}
|
||||
|
||||
private static func isKiloActivitySegment(_ text: String) -> Bool {
|
||||
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.hasPrefix("auto top-up:")
|
||||
}
|
||||
|
||||
private static func accountProviderForCombined(store: UsageStore) -> UsageProvider? {
|
||||
for provider in store.enabledProviders() {
|
||||
let metadata = store.metadata(for: provider)
|
||||
if store.snapshot(for: provider)?.identity(for: provider) != nil {
|
||||
return provider
|
||||
}
|
||||
if metadata.usesAccountFallback {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func actionsSection(
|
||||
for provider: UsageProvider?,
|
||||
store: UsageStore,
|
||||
account: AccountInfo,
|
||||
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?,
|
||||
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?) -> Section
|
||||
{
|
||||
var entries: [Entry] = []
|
||||
let targetProvider = provider ?? store.enabledProviders().first
|
||||
let metadata = targetProvider.map { store.metadata(for: $0) }
|
||||
let fallbackAccount = targetProvider.map { store.accountInfo(for: $0) } ?? account
|
||||
let loginContext = targetProvider.map {
|
||||
ProviderMenuLoginContext(
|
||||
provider: $0,
|
||||
store: store,
|
||||
settings: store.settings,
|
||||
account: fallbackAccount)
|
||||
}
|
||||
|
||||
// Show "Add Account" if no account, "Switch Account" if logged in
|
||||
if let targetProvider,
|
||||
let implementation = ProviderCatalog.implementation(for: targetProvider),
|
||||
implementation.supportsLoginFlow
|
||||
{
|
||||
if let loginContext,
|
||||
let override = implementation.loginMenuAction(context: loginContext)
|
||||
{
|
||||
entries.append(.action(override.label, override.action))
|
||||
} else {
|
||||
let loginAction = self.switchAccountTarget(for: provider, store: store)
|
||||
let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount)
|
||||
let accountLabel = hasAccount ? L("Switch Account...") : L("Add Account...")
|
||||
entries.append(.action(accountLabel, loginAction))
|
||||
}
|
||||
}
|
||||
|
||||
if let targetProvider {
|
||||
let actionContext = ProviderMenuActionContext(
|
||||
provider: targetProvider,
|
||||
store: store,
|
||||
settings: store.settings,
|
||||
account: fallbackAccount,
|
||||
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
|
||||
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)
|
||||
ProviderCatalog.implementation(for: targetProvider)?
|
||||
.appendActionMenuEntries(context: actionContext, entries: &entries)
|
||||
}
|
||||
|
||||
if metadata?.dashboardURL != nil {
|
||||
entries.append(.action(L("Usage Dashboard"), .dashboard))
|
||||
}
|
||||
if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil {
|
||||
entries.append(.action(L("Status Page"), .statusPage))
|
||||
}
|
||||
if store.settings.providerChangelogLinksEnabled, metadata?.changelogURL != nil {
|
||||
entries.append(.action(L("Changelog"), .changelog))
|
||||
}
|
||||
|
||||
if let statusLine = self.statusLine(for: provider, store: store) {
|
||||
entries.append(.text(statusLine, .secondary))
|
||||
}
|
||||
|
||||
return Section(entries: entries)
|
||||
}
|
||||
|
||||
private static func metaSection(updateReady: Bool) -> Section {
|
||||
var entries: [Entry] = []
|
||||
if updateReady {
|
||||
entries.append(.action(L("Update ready, restart now?"), .installUpdate))
|
||||
}
|
||||
entries.append(contentsOf: [
|
||||
.action(L("Refresh"), .refresh),
|
||||
.action(L("Settings..."), .settings),
|
||||
.action(L("About CodexBar"), .about),
|
||||
.action(L("Quit"), .quit),
|
||||
])
|
||||
return Section(entries: entries)
|
||||
}
|
||||
|
||||
private static func statusLine(for provider: UsageProvider?, store: UsageStore) -> String? {
|
||||
let target = provider ?? store.enabledProviders().first
|
||||
guard let target,
|
||||
let status = store.status(for: target),
|
||||
status.indicator != .none else { return nil }
|
||||
|
||||
let description = status.description?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let label = description?.isEmpty == false ? description! : status.indicator.label
|
||||
if let updated = status.updatedAt {
|
||||
let freshness = UsageFormatter.updatedString(from: updated)
|
||||
return "\(label) — \(freshness)"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
private static func switchAccountTarget(for provider: UsageProvider?, store: UsageStore) -> MenuAction {
|
||||
if let provider {
|
||||
return .switchAccount(provider)
|
||||
}
|
||||
if let enabled = store.enabledProviders().first {
|
||||
return .switchAccount(enabled)
|
||||
}
|
||||
return .switchAccount(.codex)
|
||||
}
|
||||
|
||||
private static func hasAccount(for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Bool {
|
||||
let target = provider ?? store.enabledProviders().first ?? .codex
|
||||
if let email = store.snapshot(for: target)?.accountEmail(for: target),
|
||||
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
let metadata = store.metadata(for: target)
|
||||
if metadata.usesAccountFallback,
|
||||
let fallback = account.email?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!fallback.isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func rateWindowLabels(
|
||||
provider: UsageProvider,
|
||||
metadata: ProviderMetadata,
|
||||
snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool)
|
||||
{
|
||||
if provider == .factory, snapshot.tertiary != nil {
|
||||
return ("5-hour", L("Weekly"), L("Monthly"), true)
|
||||
}
|
||||
let primaryLabel = if provider == .grok {
|
||||
GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel
|
||||
} else if provider == .doubao {
|
||||
DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel
|
||||
} else if provider == .sub2api {
|
||||
Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel
|
||||
} else {
|
||||
metadata.sessionLabel
|
||||
}
|
||||
return (
|
||||
L(primaryLabel),
|
||||
L(metadata.weeklyLabel),
|
||||
metadata.opusLabel.map(L) ?? L("Sonnet"),
|
||||
metadata.supportsOpus)
|
||||
}
|
||||
|
||||
private static func appendRateWindow(
|
||||
entries: inout [Entry],
|
||||
title: String,
|
||||
window: RateWindow,
|
||||
resetStyle: ResetTimeDisplayStyle,
|
||||
showUsed: Bool,
|
||||
resetOverride: String? = nil)
|
||||
{
|
||||
let line = UsageFormatter
|
||||
.usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed)
|
||||
entries.append(.text("\(title): \(line)", .primary))
|
||||
if let resetOverride {
|
||||
entries.append(.text(resetOverride, .secondary))
|
||||
} else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) {
|
||||
entries.append(.text(reset, .secondary))
|
||||
}
|
||||
}
|
||||
|
||||
private static func versionNumber(for provider: UsageProvider, store: UsageStore) -> String? {
|
||||
guard let raw = store.version(for: provider) else { return nil }
|
||||
let pattern = #"[0-9]+(?:\.[0-9]+)*"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(raw.startIndex..<raw.endIndex, in: raw)
|
||||
guard let match = regex.firstMatch(in: raw, options: [], range: range),
|
||||
let r = Range(match.range, in: raw) else { return nil }
|
||||
return String(raw[r])
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountFormatter {
|
||||
static func plan(_ text: String, provider: UsageProvider) -> String {
|
||||
let cleaned = if provider == .codex {
|
||||
CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text)
|
||||
} else {
|
||||
UsageFormatter.cleanPlanName(text)
|
||||
}
|
||||
return cleaned.isEmpty ? text : cleaned
|
||||
}
|
||||
|
||||
static func email(_ text: String) -> String {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuDescriptor.MenuAction {
|
||||
var systemImageName: String? {
|
||||
switch self {
|
||||
case .installUpdate: MenuDescriptor.MenuActionSystemImage.installUpdate.rawValue
|
||||
case .settings: MenuDescriptor.MenuActionSystemImage.settings.rawValue
|
||||
case .about: MenuDescriptor.MenuActionSystemImage.about.rawValue
|
||||
case .quit: MenuDescriptor.MenuActionSystemImage.quit.rawValue
|
||||
case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
|
||||
case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
|
||||
case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue
|
||||
case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue
|
||||
case .changelog: MenuDescriptor.MenuActionSystemImage.changelog.rawValue
|
||||
case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue
|
||||
case .requestCodexSystemPromotion:
|
||||
nil
|
||||
case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue
|
||||
case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue
|
||||
case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue
|
||||
case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue
|
||||
case .focusAgentSession:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var menuItemHighlighted: Bool = false
|
||||
/// Optional live-refresh monitor injected into menu card views so the provider card
|
||||
/// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu
|
||||
/// stays open, without rebuilding the menu during AppKit tracking.
|
||||
@Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor?
|
||||
}
|
||||
|
||||
enum MenuHighlightStyle {
|
||||
static let selectionText = Color(nsColor: .selectedMenuItemTextColor)
|
||||
static let normalPrimaryText = Color(nsColor: .controlTextColor)
|
||||
static let normalSecondaryText = Color(nsColor: .secondaryLabelColor)
|
||||
|
||||
static func primary(_ highlighted: Bool) -> Color {
|
||||
highlighted ? self.selectionText : self.normalPrimaryText
|
||||
}
|
||||
|
||||
static func secondary(_ highlighted: Bool) -> Color {
|
||||
highlighted ? self.selectionText : self.normalSecondaryText
|
||||
}
|
||||
|
||||
static func error(_ highlighted: Bool) -> Color {
|
||||
highlighted ? self.selectionText : Color(nsColor: .systemRed)
|
||||
}
|
||||
|
||||
static func progressTrack(_ highlighted: Bool) -> Color {
|
||||
highlighted ? self.selectionText.opacity(0.22) : Color(nsColor: .tertiaryLabelColor).opacity(0.22)
|
||||
}
|
||||
|
||||
static func progressTint(_ highlighted: Bool, fallback: Color) -> Color {
|
||||
highlighted ? self.selectionText : fallback
|
||||
}
|
||||
|
||||
static func selectionBackground(_ highlighted: Bool) -> Color {
|
||||
highlighted ? Color(nsColor: .selectedContentBackgroundColor) : .clear
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import CodexBarCore
|
||||
|
||||
struct MenuOpenRefreshPlan: Equatable {
|
||||
struct Inputs {
|
||||
let refreshAllOnOpen: Bool
|
||||
let enabledProviders: [UsageProvider]
|
||||
let visibleProviders: [UsageProvider]
|
||||
let refreshingProviders: Set<UsageProvider>
|
||||
let staleProviders: Set<UsageProvider>
|
||||
let missingProviders: Set<UsageProvider>
|
||||
}
|
||||
|
||||
enum Scheduling: Equatable {
|
||||
case sequential
|
||||
case concurrent
|
||||
}
|
||||
|
||||
let providers: [UsageProvider]
|
||||
let scheduling: Scheduling
|
||||
let refreshCodexDashboard: Bool
|
||||
|
||||
static func resolve(_ inputs: Inputs) -> Self {
|
||||
if inputs.refreshAllOnOpen {
|
||||
return Self(
|
||||
providers: inputs.enabledProviders,
|
||||
scheduling: .concurrent,
|
||||
refreshCodexDashboard: inputs.enabledProviders.contains(.codex))
|
||||
}
|
||||
|
||||
let enabled = Set(inputs.enabledProviders)
|
||||
let providers = inputs.visibleProviders.filter {
|
||||
enabled.contains($0) &&
|
||||
(inputs.refreshingProviders.contains($0) || inputs.staleProviders.contains($0) ||
|
||||
inputs.missingProviders.contains($0))
|
||||
}
|
||||
return Self(
|
||||
providers: providers,
|
||||
scheduling: .sequential,
|
||||
refreshCodexDashboard: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
struct MenuSessionCoordinator<MenuID: Hashable> {
|
||||
enum ClosedPreparationPlan: Equatable {
|
||||
case none
|
||||
case nonDeferred
|
||||
case required(version: Int)
|
||||
}
|
||||
|
||||
private(set) var contentVersion = 0
|
||||
private(set) var latestRequiredRebuildVersion = 0
|
||||
private(set) var latestDataOnlyContentVersion = 0
|
||||
private(set) var latestStructuralContentVersion = 0
|
||||
private(set) var renderedVersions: [MenuID: Int] = [:]
|
||||
private(set) var deferredUntilNextOpen: Set<MenuID> = []
|
||||
private(set) var parentRebuildsDeferredDuringTracking: Set<MenuID> = []
|
||||
private var nextMenuInteractionGeneration = 0
|
||||
private(set) var menuInteractionGenerations: [MenuID: Int] = [:]
|
||||
private var nextViewportRestoreGeneration = 0
|
||||
private(set) var pendingViewportRestores: [MenuID: Int] = [:]
|
||||
|
||||
@discardableResult
|
||||
mutating func invalidate(
|
||||
allowsStaleContent: Bool,
|
||||
requiresRebuild: Bool)
|
||||
-> Int
|
||||
{
|
||||
self.contentVersion &+= 1
|
||||
if allowsStaleContent {
|
||||
self.latestDataOnlyContentVersion = self.contentVersion
|
||||
} else {
|
||||
self.latestStructuralContentVersion = self.contentVersion
|
||||
if requiresRebuild {
|
||||
self.latestRequiredRebuildVersion = self.contentVersion
|
||||
}
|
||||
}
|
||||
return self.contentVersion
|
||||
}
|
||||
|
||||
func needsRefresh(_ menuID: MenuID) -> Bool {
|
||||
self.renderedVersions[menuID] != self.contentVersion
|
||||
}
|
||||
|
||||
mutating func markFresh(_ menuID: MenuID) {
|
||||
self.renderedVersions[menuID] = self.contentVersion
|
||||
}
|
||||
|
||||
func renderedVersion(for menuID: MenuID) -> Int? {
|
||||
self.renderedVersions[menuID]
|
||||
}
|
||||
|
||||
func canPreserveStaleContent(for menuID: MenuID) -> Bool {
|
||||
guard let renderedVersion = self.renderedVersions[menuID] else { return false }
|
||||
return self.contentVersion == self.latestDataOnlyContentVersion &&
|
||||
renderedVersion >= self.latestStructuralContentVersion
|
||||
}
|
||||
|
||||
func hasRequiredClosedPreparation(for menuIDs: some Sequence<MenuID>) -> Bool {
|
||||
guard self.latestRequiredRebuildVersion > 0 else { return false }
|
||||
return menuIDs.contains { self.isRenderedVersion($0, olderThan: self.latestRequiredRebuildVersion) }
|
||||
}
|
||||
|
||||
func closedPreparationPlan(for menuIDs: some Sequence<MenuID>) -> ClosedPreparationPlan {
|
||||
if self.hasRequiredClosedPreparation(for: menuIDs) {
|
||||
return .required(version: self.latestRequiredRebuildVersion)
|
||||
}
|
||||
if self.contentVersion > self.latestRequiredRebuildVersion {
|
||||
return .none
|
||||
}
|
||||
return .nonDeferred
|
||||
}
|
||||
|
||||
func isRenderedVersion(_ menuID: MenuID, olderThan version: Int) -> Bool {
|
||||
(self.renderedVersions[menuID] ?? -1) < version
|
||||
}
|
||||
|
||||
mutating func deferUntilNextOpen(_ menuID: MenuID) {
|
||||
self.deferredUntilNextOpen.insert(menuID)
|
||||
}
|
||||
|
||||
mutating func clearNextOpenDeferral(_ menuID: MenuID) {
|
||||
self.deferredUntilNextOpen.remove(menuID)
|
||||
}
|
||||
|
||||
func isDeferredUntilNextOpen(_ menuID: MenuID) -> Bool {
|
||||
self.deferredUntilNextOpen.contains(menuID)
|
||||
}
|
||||
|
||||
mutating func deferParentRebuild(_ menuID: MenuID) {
|
||||
self.parentRebuildsDeferredDuringTracking.insert(menuID)
|
||||
}
|
||||
|
||||
mutating func clearParentRebuildDeferral(_ menuID: MenuID) {
|
||||
self.parentRebuildsDeferredDuringTracking.remove(menuID)
|
||||
}
|
||||
|
||||
func isParentRebuildDeferred(_ menuID: MenuID) -> Bool {
|
||||
self.parentRebuildsDeferredDuringTracking.contains(menuID)
|
||||
}
|
||||
|
||||
/// Identifies one concrete open/close lifetime even when AppKit reuses the same menu object.
|
||||
@discardableResult
|
||||
mutating func beginTrackingSession(_ menuID: MenuID) -> Int {
|
||||
self.replaceMenuInteractionGeneration(for: menuID)
|
||||
}
|
||||
|
||||
func menuInteractionGeneration(for menuID: MenuID) -> Int? {
|
||||
self.menuInteractionGenerations[menuID]
|
||||
}
|
||||
|
||||
func isCurrentMenuInteraction(_ generation: Int, for menuID: MenuID) -> Bool {
|
||||
self.menuInteractionGenerations[menuID] == generation
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func advanceMenuInteraction(for menuID: MenuID) -> Int? {
|
||||
guard self.menuInteractionGenerations[menuID] != nil else { return nil }
|
||||
return self.replaceMenuInteractionGeneration(for: menuID)
|
||||
}
|
||||
|
||||
private mutating func replaceMenuInteractionGeneration(for menuID: MenuID) -> Int {
|
||||
self.nextMenuInteractionGeneration &+= 1
|
||||
self.menuInteractionGenerations[menuID] = self.nextMenuInteractionGeneration
|
||||
return self.nextMenuInteractionGeneration
|
||||
}
|
||||
|
||||
mutating func endTrackingSession(_ menuID: MenuID) {
|
||||
self.menuInteractionGenerations.removeValue(forKey: menuID)
|
||||
}
|
||||
|
||||
/// One-shot viewport restore tied to the menu-tracking session that started a manual refresh.
|
||||
@discardableResult
|
||||
mutating func armViewportRestore(_ menuID: MenuID) -> Int {
|
||||
self.nextViewportRestoreGeneration &+= 1
|
||||
self.pendingViewportRestores[menuID] = self.nextViewportRestoreGeneration
|
||||
return self.nextViewportRestoreGeneration
|
||||
}
|
||||
|
||||
func isCurrentViewportRestore(_ generation: Int, for menuID: MenuID) -> Bool {
|
||||
self.pendingViewportRestores[menuID] == generation
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func consumeViewportRestore(_ menuID: MenuID, generation: Int) -> Bool {
|
||||
guard self.isCurrentViewportRestore(generation, for: menuID) else { return false }
|
||||
self.pendingViewportRestores.removeValue(forKey: menuID)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func cancelViewportRestore(_ menuID: MenuID) {
|
||||
self.pendingViewportRestores.removeValue(forKey: menuID)
|
||||
}
|
||||
|
||||
mutating func removeMenu(_ menuID: MenuID) {
|
||||
self.renderedVersions.removeValue(forKey: menuID)
|
||||
self.deferredUntilNextOpen.remove(menuID)
|
||||
self.parentRebuildsDeferredDuringTracking.remove(menuID)
|
||||
self.endTrackingSession(menuID)
|
||||
self.cancelViewportRestore(menuID)
|
||||
}
|
||||
|
||||
mutating func clearMenuTracking() {
|
||||
self.renderedVersions.removeAll(keepingCapacity: false)
|
||||
self.deferredUntilNextOpen.removeAll(keepingCapacity: false)
|
||||
self.parentRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false)
|
||||
self.menuInteractionGenerations.removeAll(keepingCapacity: false)
|
||||
self.pendingViewportRestores.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
mutating func replaceContentVersionForTesting(_ version: Int) {
|
||||
self.contentVersion = version
|
||||
}
|
||||
|
||||
mutating func replaceRenderedVersionsForTesting(_ versions: [MenuID: Int]) {
|
||||
self.renderedVersions = versions
|
||||
}
|
||||
|
||||
mutating func replaceDeferredMenusForTesting(_ menuIDs: Set<MenuID>) {
|
||||
self.deferredUntilNextOpen = menuIDs
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct MenuRebuildRequestRegistry<MenuID: Hashable> {
|
||||
private var nextToken = 0
|
||||
private(set) var tokens: [MenuID: Int] = [:]
|
||||
|
||||
mutating func replaceRequest(for menuID: MenuID) -> Int {
|
||||
self.nextToken &+= 1
|
||||
self.tokens[menuID] = self.nextToken
|
||||
return self.nextToken
|
||||
}
|
||||
|
||||
func isCurrent(_ token: Int, for menuID: MenuID) -> Bool {
|
||||
self.tokens[menuID] == token
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func finish(_ token: Int, for menuID: MenuID) -> Bool {
|
||||
guard self.isCurrent(token, for: menuID) else { return false }
|
||||
self.tokens.removeValue(forKey: menuID)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func cancel(for menuID: MenuID) {
|
||||
self.tokens.removeValue(forKey: menuID)
|
||||
}
|
||||
|
||||
mutating func cancelAll() {
|
||||
self.tokens.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol MiniMaxAPITokenStoring: Sendable {
|
||||
func loadToken() throws -> String?
|
||||
func storeToken(_ token: String?) throws
|
||||
}
|
||||
|
||||
enum MiniMaxAPITokenStoreError: LocalizedError {
|
||||
case keychainStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .keychainStatus(status):
|
||||
"Keychain error: \(status)"
|
||||
case .invalidData:
|
||||
"Keychain returned invalid data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring {
|
||||
private static let log = CodexBarLog.logger(LogCategories.minimaxAPITokenStore)
|
||||
|
||||
private let service = "com.steipete.CodexBar"
|
||||
private let account = "minimax-api-token"
|
||||
|
||||
func loadToken() throws -> String? {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token load")
|
||||
return nil
|
||||
}
|
||||
var result: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
if case .interactionRequired = KeychainAccessPreflight
|
||||
.checkGenericPassword(service: self.service, account: self.account)
|
||||
{
|
||||
KeychainPromptHandler.handler?(KeychainPromptContext(
|
||||
kind: .minimaxToken,
|
||||
service: self.service,
|
||||
account: self.account))
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
Self.log.error("Keychain read failed: \(status)")
|
||||
throw MiniMaxAPITokenStoreError.keychainStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw MiniMaxAPITokenStoreError.invalidData
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let token, !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeToken(_ token: String?) throws {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping token store")
|
||||
return
|
||||
}
|
||||
let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned == nil || cleaned?.isEmpty == true {
|
||||
try self.deleteIfPresent()
|
||||
return
|
||||
}
|
||||
|
||||
let data = cleaned!.data(using: .utf8)!
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
if updateStatus != errSecItemNotFound {
|
||||
Self.log.error("Keychain update failed: \(updateStatus)")
|
||||
throw MiniMaxAPITokenStoreError.keychainStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
for (key, value) in attributes {
|
||||
addQuery[key] = value
|
||||
}
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
Self.log.error("Keychain add failed: \(addStatus)")
|
||||
throw MiniMaxAPITokenStoreError.keychainStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteIfPresent() throws {
|
||||
guard !KeychainAccessGate.isDisabled else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let status = KeychainSecurity.delete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
Self.log.error("Keychain delete failed: \(status)")
|
||||
throw MiniMaxAPITokenStoreError.keychainStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol MiniMaxCookieStoring: Sendable {
|
||||
func loadCookieHeader() throws -> String?
|
||||
func storeCookieHeader(_ header: String?) throws
|
||||
}
|
||||
|
||||
enum MiniMaxCookieStoreError: LocalizedError {
|
||||
case keychainStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .keychainStatus(status):
|
||||
"Keychain error: \(status)"
|
||||
case .invalidData:
|
||||
"Keychain returned invalid data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring {
|
||||
private static let log = CodexBarLog.logger(LogCategories.minimaxCookieStore)
|
||||
|
||||
private let service = "com.steipete.CodexBar"
|
||||
private let account = "minimax-cookie"
|
||||
|
||||
func loadCookieHeader() throws -> String? {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping cookie load")
|
||||
return nil
|
||||
}
|
||||
var result: CFTypeRef?
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
if case .interactionRequired = KeychainAccessPreflight
|
||||
.checkGenericPassword(service: self.service, account: self.account)
|
||||
{
|
||||
KeychainPromptHandler.handler?(KeychainPromptContext(
|
||||
kind: .minimaxCookie,
|
||||
service: self.service,
|
||||
account: self.account))
|
||||
}
|
||||
|
||||
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
Self.log.error("Keychain read failed: \(status)")
|
||||
throw MiniMaxCookieStoreError.keychainStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw MiniMaxCookieStoreError.invalidData
|
||||
}
|
||||
let header = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let header, !header.isEmpty {
|
||||
return header
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeCookieHeader(_ header: String?) throws {
|
||||
guard !KeychainAccessGate.isDisabled else {
|
||||
Self.log.debug("Keychain access disabled; skipping cookie store")
|
||||
return
|
||||
}
|
||||
guard let raw = header?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
try self.deleteIfPresent()
|
||||
return
|
||||
}
|
||||
guard MiniMaxCookieHeader.normalized(from: raw) != nil else {
|
||||
try self.deleteIfPresent()
|
||||
return
|
||||
}
|
||||
|
||||
let data = raw.data(using: .utf8)!
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
if updateStatus != errSecItemNotFound {
|
||||
Self.log.error("Keychain update failed: \(updateStatus)")
|
||||
throw MiniMaxCookieStoreError.keychainStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
for (key, value) in attributes {
|
||||
addQuery[key] = value
|
||||
}
|
||||
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
Self.log.error("Keychain add failed: \(addStatus)")
|
||||
throw MiniMaxCookieStoreError.keychainStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteIfPresent() throws {
|
||||
guard !KeychainAccessGate.isDisabled else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: self.account,
|
||||
]
|
||||
let status = KeychainSecurity.delete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
Self.log.error("Keychain delete failed: \(status)")
|
||||
throw MiniMaxCookieStoreError.keychainStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Lightweight NSView-based mouse tracking with local coordinates.
|
||||
///
|
||||
/// Why: SwiftUI's `onHover` doesn't provide location, but we want "hover a bar to see values" on macOS.
|
||||
@MainActor
|
||||
struct MouseLocationReader: NSViewRepresentable {
|
||||
let onMoved: (CGPoint?) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> TrackingView {
|
||||
let view = TrackingView()
|
||||
view.onMoved = self.onMoved
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: TrackingView, context: Context) {
|
||||
nsView.onMoved = self.onMoved
|
||||
}
|
||||
|
||||
final class TrackingView: NSView {
|
||||
var onMoved: ((CGPoint?) -> Void)?
|
||||
private var trackingArea: NSTrackingArea?
|
||||
|
||||
override var isFlipped: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
self.window?.acceptsMouseMovedEvents = true
|
||||
self.updateTrackingAreas()
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let trackingArea {
|
||||
self.removeTrackingArea(trackingArea)
|
||||
}
|
||||
|
||||
let options: NSTrackingArea.Options = [
|
||||
// NSMenu popups aren't "key windows", so `.activeInKeyWindow` would drop events and cause hover
|
||||
// state to flicker. `.activeAlways` keeps tracking stable while the menu is open.
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
.mouseEnteredAndExited,
|
||||
.mouseMoved,
|
||||
]
|
||||
let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.trackingArea = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
super.mouseEntered(with: event)
|
||||
self.onMoved?(self.convert(event.locationInWindow, from: nil))
|
||||
}
|
||||
|
||||
override func mouseMoved(with event: NSEvent) {
|
||||
super.mouseMoved(with: event)
|
||||
self.onMoved?(self.convert(event.locationInWindow, from: nil))
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
super.mouseExited(with: event)
|
||||
self.onMoved?(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let codexbarOpenSettings = Notification.Name("codexbarOpenSettings")
|
||||
static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow")
|
||||
#if DEBUG
|
||||
static let codexbarDebugSimulateMemoryPressure =
|
||||
Notification.Name("com.steipete.codexbar.debug.simulateMemoryPressure")
|
||||
#endif
|
||||
static let codexbarSessionLimitReset = Notification.Name("codexbarSessionLimitReset")
|
||||
static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset")
|
||||
static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange")
|
||||
static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SessionLimitResetEvent: NSObject {
|
||||
let provider: UsageProvider
|
||||
let accountIdentifier: String
|
||||
let accountLabel: String?
|
||||
let usedPercent: Double
|
||||
|
||||
init(provider: UsageProvider, accountIdentifier: String, accountLabel: String?, usedPercent: Double) {
|
||||
self.provider = provider
|
||||
self.accountIdentifier = accountIdentifier
|
||||
self.accountLabel = accountLabel
|
||||
self.usedPercent = usedPercent
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WeeklyLimitResetEvent: NSObject {
|
||||
let provider: UsageProvider
|
||||
let accountIdentifier: String
|
||||
let accountLabel: String?
|
||||
let usedPercent: Double
|
||||
|
||||
init(provider: UsageProvider, accountIdentifier: String, accountLabel: String?, usedPercent: Double) {
|
||||
self.provider = provider
|
||||
self.accountIdentifier = accountIdentifier
|
||||
self.accountLabel = accountLabel
|
||||
self.usedPercent = usedPercent
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class QuotaWarningPostedEvent: NSObject {
|
||||
let provider: UsageProvider
|
||||
let window: QuotaWarningWindow
|
||||
let threshold: Int
|
||||
let postedAt: Date
|
||||
|
||||
init(provider: UsageProvider, window: QuotaWarningWindow, threshold: Int, postedAt: Date) {
|
||||
self.provider = provider
|
||||
self.window = window
|
||||
self.threshold = threshold
|
||||
self.postedAt = postedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigationDelegate, WKScriptMessageHandler {
|
||||
private static let defaultSize = NSSize(width: 980, height: 760)
|
||||
private static let logHandlerName = "codexbarLog"
|
||||
private static let debugLogURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("codexbar-buy-credits.log")
|
||||
private static let autoStartScript = """
|
||||
(() => {
|
||||
if (window.__codexbarAutoBuyCreditsStarted) return 'already';
|
||||
const log = (...args) => {
|
||||
try {
|
||||
window.webkit?.messageHandlers?.codexbarLog?.postMessage(args);
|
||||
} catch {}
|
||||
};
|
||||
const buttonSelector = 'button, a, [role="button"], input[type="button"], input[type="submit"]';
|
||||
const isVisible = (el) => {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 2 || rect.height < 2) return false;
|
||||
const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
|
||||
if (style) {
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
if (parseFloat(style.opacity || '1') === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const textOf = el => {
|
||||
const raw = el && (el.innerText || el.textContent) ? String(el.innerText || el.textContent) : '';
|
||||
return raw.trim();
|
||||
};
|
||||
const matches = text => {
|
||||
const lower = String(text || '').toLowerCase();
|
||||
if (!lower.includes('credit')) return false;
|
||||
return (
|
||||
lower.includes('buy') ||
|
||||
lower.includes('add') ||
|
||||
lower.includes('purchase') ||
|
||||
lower.includes('top up') ||
|
||||
lower.includes('top-up')
|
||||
);
|
||||
};
|
||||
const matchesAddMore = text => {
|
||||
const lower = String(text || '').toLowerCase();
|
||||
return lower.includes('add more');
|
||||
};
|
||||
const labelFor = el => {
|
||||
if (!el) return '';
|
||||
return textOf(el) || el.getAttribute('aria-label') || el.getAttribute('title') || el.value || '';
|
||||
};
|
||||
const summarize = el => {
|
||||
if (!el) return null;
|
||||
return {
|
||||
tag: el.tagName,
|
||||
type: el.getAttribute('type'),
|
||||
role: el.getAttribute('role'),
|
||||
label: labelFor(el),
|
||||
aria: el.getAttribute('aria-label'),
|
||||
disabled: isDisabled(el),
|
||||
href: el.getAttribute('href'),
|
||||
testId: el.getAttribute('data-testid'),
|
||||
className: (el.className && String(el.className).slice(0, 120)) || ''
|
||||
};
|
||||
};
|
||||
const collectButtons = () => {
|
||||
const results = new Set();
|
||||
const addAll = (root) => {
|
||||
if (!root || !root.querySelectorAll) return;
|
||||
root.querySelectorAll(buttonSelector).forEach(el => results.add(el));
|
||||
};
|
||||
addAll(document);
|
||||
document.querySelectorAll('*').forEach(el => {
|
||||
if (el.shadowRoot) addAll(el.shadowRoot);
|
||||
});
|
||||
document.querySelectorAll('iframe').forEach(frame => {
|
||||
try {
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc) return;
|
||||
addAll(doc);
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
if (el.shadowRoot) addAll(el.shadowRoot);
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
return Array.from(results);
|
||||
};
|
||||
const findDialogNextButton = () => {
|
||||
const dialog = document.querySelector('[role=\"dialog\"], dialog, [aria-modal=\"true\"]');
|
||||
if (!dialog) return null;
|
||||
const buttons = Array.from(dialog.querySelectorAll(buttonSelector));
|
||||
const labeled = buttons.filter(btn => labelFor(btn).toLowerCase().startsWith('next'));
|
||||
const visible = labeled.find(isVisible);
|
||||
return visible || labeled[0] || null;
|
||||
};
|
||||
const clickButton = (el) => {
|
||||
if (!el) return false;
|
||||
try {
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
} catch {
|
||||
try {
|
||||
el.click();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const triggerPointerClick = (el) => {
|
||||
if (!el) return false;
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (!rect) return false;
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const events = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
||||
for (const type of events) {
|
||||
try {
|
||||
el.dispatchEvent(new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: x,
|
||||
clientY: y
|
||||
}));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const pickLikelyButton = (buttons) => {
|
||||
if (!buttons || buttons.length === 0) return null;
|
||||
const labeled = buttons.find(btn => {
|
||||
const label = labelFor(btn);
|
||||
if (matches(label) || matchesAddMore(label)) return true;
|
||||
const aria = String(btn.getAttribute('aria-label') || '').toLowerCase();
|
||||
return aria.includes('credit') || aria.includes('buy') || aria.includes('add');
|
||||
});
|
||||
return labeled || buttons[0];
|
||||
};
|
||||
const findAddMoreButton = () => {
|
||||
const buttons = collectButtons();
|
||||
return buttons.find(btn => matchesAddMore(labelFor(btn))) || null;
|
||||
};
|
||||
const findNextButton = () => {
|
||||
const dialogNext = findDialogNextButton();
|
||||
if (dialogNext) return dialogNext;
|
||||
const buttons = collectButtons();
|
||||
const labeled = buttons.filter(btn => {
|
||||
const label = labelFor(btn).toLowerCase();
|
||||
return label === 'next' || label.startsWith('next ');
|
||||
});
|
||||
const visible = labeled.find(isVisible);
|
||||
if (visible) return visible;
|
||||
const submit = buttons.find(btn => btn.type && String(btn.type).toLowerCase() === 'submit' && isVisible(btn));
|
||||
return submit || labeled[0] || null;
|
||||
};
|
||||
const isDisabled = (el) => {
|
||||
if (!el) return true;
|
||||
if (el.disabled) return true;
|
||||
const ariaDisabled = String(el.getAttribute('aria-disabled') || '').toLowerCase();
|
||||
if (ariaDisabled === 'true') return true;
|
||||
if (el.classList && (el.classList.contains('disabled') || el.classList.contains('is-disabled'))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const forceClickElement = (el) => {
|
||||
if (!el) return false;
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (rect) {
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (target) {
|
||||
try {
|
||||
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const requestSubmit = (el) => {
|
||||
if (!el || !el.closest) return false;
|
||||
const form = el.closest('form');
|
||||
if (!form) return false;
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit(el);
|
||||
return true;
|
||||
}
|
||||
if (typeof form.submit === 'function') {
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const clickNextIfReady = (attempts) => {
|
||||
const nextButton = findNextButton();
|
||||
if (!nextButton) {
|
||||
if (attempts && attempts % 5 === 0) log('next_missing', { attempts });
|
||||
return false;
|
||||
}
|
||||
if (isDisabled(nextButton)) {
|
||||
if (attempts && attempts % 5 === 0) log('next_disabled', summarize(nextButton));
|
||||
return false;
|
||||
}
|
||||
if (!isVisible(nextButton)) {
|
||||
if (attempts && attempts % 5 === 0) log('next_hidden', summarize(nextButton));
|
||||
return false;
|
||||
}
|
||||
nextButton.focus?.();
|
||||
if (requestSubmit(nextButton)) {
|
||||
log('next_submit', summarize(nextButton));
|
||||
return true;
|
||||
}
|
||||
if (triggerPointerClick(nextButton)) {
|
||||
log('next_pointer', summarize(nextButton));
|
||||
return true;
|
||||
}
|
||||
if (clickButton(nextButton)) {
|
||||
log('next_click', summarize(nextButton));
|
||||
return true;
|
||||
}
|
||||
return forceClickElement(nextButton);
|
||||
};
|
||||
const startNextPolling = (initialDelay = 500, interval = 500, maxAttempts = 90) => {
|
||||
if (window.__codexbarNextPolling) return;
|
||||
window.__codexbarNextPolling = true;
|
||||
log('start_next_poll', { initialDelay, interval, maxAttempts });
|
||||
setTimeout(() => {
|
||||
let attempts = 0;
|
||||
const nextTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
if (attempts % 5 === 0) {
|
||||
const nextButton = findNextButton();
|
||||
log('next_poll', {
|
||||
attempts,
|
||||
found: Boolean(nextButton),
|
||||
summary: summarize(nextButton)
|
||||
});
|
||||
}
|
||||
if (clickNextIfReady(attempts) || attempts >= maxAttempts) {
|
||||
clearInterval(nextTimer);
|
||||
}
|
||||
}, interval);
|
||||
}, initialDelay);
|
||||
};
|
||||
const observeNextButton = () => {
|
||||
if (window.__codexbarNextObserver || !window.MutationObserver) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (clickNextIfReady(1)) {
|
||||
observer.disconnect();
|
||||
window.__codexbarNextObserver = null;
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { subtree: true, childList: true, attributes: true });
|
||||
window.__codexbarNextObserver = observer;
|
||||
};
|
||||
const findCreditsCardButton = () => {
|
||||
const nodes = Array.from(document.querySelectorAll('h1,h2,h3,div,span,p'));
|
||||
const labelMatch = nodes.find(node => {
|
||||
const lower = textOf(node).toLowerCase();
|
||||
return lower === 'credits remaining' || (lower.includes('credits') && lower.includes('remaining'));
|
||||
});
|
||||
if (!labelMatch) return null;
|
||||
let cur = labelMatch;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
const buttons = Array.from(cur.querySelectorAll(buttonSelector));
|
||||
const picked = pickLikelyButton(buttons);
|
||||
if (picked) return picked;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const findAndClick = () => {
|
||||
const addMoreButton = findAddMoreButton();
|
||||
if (addMoreButton) {
|
||||
log('add_more_click', summarize(addMoreButton));
|
||||
clickButton(addMoreButton);
|
||||
return true;
|
||||
}
|
||||
const cardButton = findCreditsCardButton();
|
||||
if (!cardButton) return false;
|
||||
log('credits_card_click', summarize(cardButton));
|
||||
return clickButton(cardButton);
|
||||
};
|
||||
const logDialogButtons = () => {
|
||||
const dialog = document.querySelector('[role=\"dialog\"], dialog, [aria-modal=\"true\"]');
|
||||
if (dialog) {
|
||||
const buttons = Array.from(dialog.querySelectorAll(buttonSelector)).map(summarize).filter(Boolean);
|
||||
if (buttons.length) {
|
||||
log('dialog_buttons', { count: buttons.length, buttons: buttons.slice(0, 6) });
|
||||
}
|
||||
const nextButton = findDialogNextButton();
|
||||
if (nextButton) {
|
||||
log('dialog_next', summarize(nextButton));
|
||||
setTimeout(() => clickNextIfReady(1), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const candidates = collectButtons()
|
||||
.map(summarize)
|
||||
.filter(Boolean)
|
||||
.filter(entry => {
|
||||
const label = (entry.label || '').toLowerCase();
|
||||
return label.includes('next')
|
||||
|| label.includes('continue')
|
||||
|| label.includes('confirm')
|
||||
|| label.includes('buy');
|
||||
});
|
||||
if (candidates.length) {
|
||||
log('button_candidates', { count: candidates.length, buttons: candidates.slice(0, 8) });
|
||||
}
|
||||
};
|
||||
log('auto_start', { href: location.href, ready: document.readyState });
|
||||
const iframeSources = Array.from(document.querySelectorAll('iframe'))
|
||||
.map(frame => frame.getAttribute('src') || '')
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
if (iframeSources.length) {
|
||||
log('iframes', iframeSources);
|
||||
}
|
||||
const shadowHostCount = Array.from(document.querySelectorAll('*')).filter(el => el.shadowRoot).length;
|
||||
if (shadowHostCount > 0) {
|
||||
log('shadow_roots', { count: shadowHostCount });
|
||||
}
|
||||
if (findAndClick()) {
|
||||
window.__codexbarAutoBuyCreditsStarted = true;
|
||||
startNextPolling();
|
||||
observeNextButton();
|
||||
logDialogButtons();
|
||||
return 'clicked';
|
||||
}
|
||||
startNextPolling(500);
|
||||
observeNextButton();
|
||||
logDialogButtons();
|
||||
let attempts = 0;
|
||||
const maxAttempts = 14;
|
||||
const timer = setInterval(() => {
|
||||
attempts += 1;
|
||||
if (findAndClick()) {
|
||||
logDialogButtons();
|
||||
startNextPolling();
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 500);
|
||||
window.__codexbarAutoBuyCreditsStarted = true;
|
||||
return 'scheduled';
|
||||
})();
|
||||
"""
|
||||
|
||||
private let logger = CodexBarLog.logger(LogCategories.creditsPurchase)
|
||||
private var webView: WKWebView?
|
||||
private var accountEmail: String?
|
||||
private var cacheScope: CookieHeaderCache.Scope?
|
||||
private var pendingAutoStart = false
|
||||
private let logHandler = WeakScriptMessageHandler()
|
||||
|
||||
init() {
|
||||
super.init(window: nil)
|
||||
self.logHandler.delegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func show(
|
||||
purchaseURL: URL,
|
||||
accountEmail: String?,
|
||||
cacheScope: CookieHeaderCache.Scope?,
|
||||
autoStartPurchase: Bool)
|
||||
{
|
||||
guard Self.canOpenPurchaseWindow(accountEmail: accountEmail, cacheScope: cacheScope) else {
|
||||
self.close()
|
||||
self.accountEmail = nil
|
||||
self.cacheScope = nil
|
||||
self.logger.error("Buy credits blocked: scoped account email unavailable")
|
||||
return
|
||||
}
|
||||
let normalizedEmail = Self.normalizeEmail(accountEmail)
|
||||
if self.window == nil || normalizedEmail != self.accountEmail || cacheScope != self.cacheScope {
|
||||
self.accountEmail = normalizedEmail
|
||||
self.cacheScope = cacheScope
|
||||
self.buildWindow()
|
||||
}
|
||||
Self.resetDebugLog()
|
||||
let accountValue = normalizedEmail == nil ? "none" : "set"
|
||||
let sanitizedURL = Self.sanitizedURLString(purchaseURL)
|
||||
Self.appendDebugLog(
|
||||
"show autoStart=\(autoStartPurchase) url=\(sanitizedURL) account=\(accountValue)")
|
||||
self.logger.info("Buy credits window opened")
|
||||
self.logger.debug("Auto-start purchase", metadata: ["enabled": autoStartPurchase ? "1" : "0"])
|
||||
self.logger.debug("Purchase URL", metadata: ["url": sanitizedURL])
|
||||
self.logger.debug("Account email", metadata: ["state": accountValue])
|
||||
self.pendingAutoStart = autoStartPurchase
|
||||
self.load(url: purchaseURL)
|
||||
self.window?.center()
|
||||
self.showWindow(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func buildWindow() {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.userContentController.add(self.logHandler, name: Self.logHandlerName)
|
||||
config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(
|
||||
forAccountEmail: self.accountEmail,
|
||||
scope: self.cacheScope)
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = self
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let container = NSView(frame: .zero)
|
||||
container.addSubview(webView)
|
||||
NSLayoutConstraint.activate([
|
||||
webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
webView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: Self.defaultFrame(),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Buy Credits"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
|
||||
window.contentView = container
|
||||
window.center()
|
||||
window.delegate = self
|
||||
|
||||
self.window = window
|
||||
self.webView = webView
|
||||
}
|
||||
|
||||
private func load(url: URL) {
|
||||
guard let webView else { return }
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard self.pendingAutoStart else { return }
|
||||
self.pendingAutoStart = false
|
||||
let currentURL = webView.url?.absoluteString ?? "unknown"
|
||||
Self.appendDebugLog("didFinish url=\(currentURL)")
|
||||
self.logger.debug("Buy credits navigation finished", metadata: ["url": currentURL])
|
||||
webView.evaluateJavaScript(Self.autoStartScript) { [logger] result, error in
|
||||
if let error {
|
||||
Self.appendDebugLog("autoStart error=\(error.localizedDescription)")
|
||||
logger.error("Auto-start purchase failed", metadata: ["error": error.localizedDescription])
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
Self.appendDebugLog("autoStart result=\(String(describing: result))")
|
||||
logger.debug("Auto-start purchase result", metadata: ["result": String(describing: result)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard message.name == Self.logHandlerName else { return }
|
||||
let payload = String(describing: message.body)
|
||||
Self.appendDebugLog("js \(payload)")
|
||||
self.logger.debug("Auto-buy log", metadata: ["payload": payload])
|
||||
}
|
||||
|
||||
private static func normalizeEmail(_ email: String?) -> String? {
|
||||
guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
||||
return raw.lowercased()
|
||||
}
|
||||
|
||||
static func canOpenPurchaseWindow(accountEmail: String?, cacheScope: CookieHeaderCache.Scope?) -> Bool {
|
||||
cacheScope == nil || self.normalizeEmail(accountEmail) != nil
|
||||
}
|
||||
|
||||
private static func defaultFrame() -> NSRect {
|
||||
let visible = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900)
|
||||
let width = min(Self.defaultSize.width, visible.width * 0.92)
|
||||
let height = min(Self.defaultSize.height, visible.height * 0.88)
|
||||
let origin = NSPoint(x: visible.midX - width / 2, y: visible.midY - height / 2)
|
||||
return NSRect(origin: origin, size: NSSize(width: width, height: height))
|
||||
}
|
||||
|
||||
private static func appendDebugLog(_ message: String) {
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
let line = "[\(timestamp)] \(LogRedactor.redact(message))\n"
|
||||
guard let data = line.data(using: .utf8) else { return }
|
||||
if FileManager.default.fileExists(atPath: Self.debugLogURL.path) {
|
||||
if let handle = try? FileHandle(forWritingTo: Self.debugLogURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
try? handle.close()
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: Self.debugLogURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
private static func resetDebugLog() {
|
||||
try? FileManager.default.removeItem(at: self.debugLogURL)
|
||||
}
|
||||
|
||||
private static func sanitizedURLString(_ url: URL) -> String {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url.absoluteString
|
||||
}
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string ?? url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
weak var delegate: WKScriptMessageHandler?
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
self.delegate?.userContentController(userContentController, didReceive: message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
extension OpenAICreditsPurchaseWindowController: NSWindowDelegate {
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
guard let window = self.window else { return }
|
||||
let webView = self.webView
|
||||
self.pendingAutoStart = false
|
||||
self.webView = nil
|
||||
self.window = nil
|
||||
self.logger.info("Buy credits window closing")
|
||||
WebKitTeardown.scheduleCleanup(owner: window, window: window, webView: webView)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
enum PersonalInfoRedactor {
|
||||
static let emailPlaceholder = ""
|
||||
|
||||
private static let emailRegex: NSRegularExpression? = {
|
||||
let pattern = #"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#
|
||||
return try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
|
||||
}()
|
||||
|
||||
static func redactEmail(_ email: String?, isEnabled: Bool) -> String {
|
||||
guard let email, !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "" }
|
||||
guard isEnabled else { return email }
|
||||
return Self.emailPlaceholder
|
||||
}
|
||||
|
||||
static func redactEmails(in text: String?, isEnabled: Bool) -> String? {
|
||||
guard let text else { return nil }
|
||||
guard isEnabled else { return text }
|
||||
guard let regex = Self.emailRegex else { return text }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard regex.firstMatch(in: text, options: [], range: range) != nil else { return text }
|
||||
let redacted = regex.stringByReplacingMatches(
|
||||
in: text,
|
||||
options: [],
|
||||
range: range,
|
||||
withTemplate: Self.emailPlaceholder)
|
||||
return redacted
|
||||
.replacingOccurrences(of: #"\s+([:.,;])"#, with: "$1", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"\s{2,}"#, with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
import Charts
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct PlanUtilizationHistoryChartMenuView: View {
|
||||
private enum Layout {
|
||||
static let chartHeight: CGFloat = 130
|
||||
static let detailHeight: CGFloat = 16
|
||||
static let emptyStateHeight: CGFloat = chartHeight + detailHeight
|
||||
static let maxPoints = 30
|
||||
static let maxAxisLabels = 4
|
||||
static let barWidth: CGFloat = 6
|
||||
}
|
||||
|
||||
private struct SeriesSelection: Hashable {
|
||||
let name: PlanUtilizationSeriesName
|
||||
let windowMinutes: Int
|
||||
|
||||
var id: String {
|
||||
"\(self.name.rawValue):\(self.windowMinutes)"
|
||||
}
|
||||
}
|
||||
|
||||
private struct VisibleSeries: Identifiable, Equatable {
|
||||
let selection: SeriesSelection
|
||||
let title: String
|
||||
let history: PlanUtilizationSeriesHistory
|
||||
|
||||
var id: String {
|
||||
self.selection.id
|
||||
}
|
||||
}
|
||||
|
||||
private struct EntryPointAccumulator {
|
||||
let effectiveBoundaryDate: Date
|
||||
let displayBoundaryDate: Date
|
||||
let observedAt: Date
|
||||
let usedPercent: Double
|
||||
let hasObservedResetBoundary: Bool
|
||||
}
|
||||
|
||||
private struct ResetBoundaryLattice {
|
||||
let referenceBoundaryDate: Date
|
||||
let windowInterval: TimeInterval
|
||||
}
|
||||
|
||||
private struct Point: Identifiable {
|
||||
let id: Date
|
||||
let index: Int
|
||||
let date: Date
|
||||
let usedPercent: Double
|
||||
let isObserved: Bool
|
||||
}
|
||||
|
||||
private struct Model {
|
||||
let points: [Point]
|
||||
let axisIndexes: [Double]
|
||||
let xDomain: ClosedRange<Double>?
|
||||
let pointsByID: [Date: Point]
|
||||
let pointsByIndex: [Int: Point]
|
||||
let barColor: Color
|
||||
let trackColor: Color
|
||||
}
|
||||
|
||||
private let provider: UsageProvider
|
||||
private let visibleSeries: [VisibleSeries]
|
||||
private let modelsBySeriesID: [String: Model]
|
||||
private let emptyModel: Model
|
||||
private let width: CGFloat
|
||||
|
||||
@State private var selectedSeriesID: String?
|
||||
@State private var selectedPointID: Date?
|
||||
|
||||
init(
|
||||
provider: UsageProvider,
|
||||
histories: [PlanUtilizationSeriesHistory],
|
||||
snapshot: UsageSnapshot? = nil,
|
||||
width: CGFloat)
|
||||
{
|
||||
self.provider = provider
|
||||
let visibleSeries = Self.visibleSeries(
|
||||
histories: histories,
|
||||
provider: provider,
|
||||
snapshot: snapshot)
|
||||
let referenceDate = Date()
|
||||
self.visibleSeries = visibleSeries
|
||||
self.modelsBySeriesID = Dictionary(uniqueKeysWithValues: visibleSeries.map {
|
||||
($0.id, Self.makeModel(history: $0.history, provider: provider, referenceDate: referenceDate))
|
||||
})
|
||||
self.emptyModel = Self.emptyModel(provider: provider)
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let effectiveSelectedSeries = self.visibleSeries.first(where: { $0.id == self.selectedSeriesID })
|
||||
?? self.visibleSeries.first
|
||||
let model = effectiveSelectedSeries.flatMap { self.modelsBySeriesID[$0.id] } ?? self.emptyModel
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.visibleSeries.count > 1 {
|
||||
Picker(selection: Binding(
|
||||
get: { effectiveSelectedSeries?.id ?? "" },
|
||||
set: { newValue in
|
||||
self.selectedSeriesID = newValue
|
||||
self.selectedPointID = nil
|
||||
})) {
|
||||
ForEach(self.visibleSeries) { series in
|
||||
Text(series.title).tag(series.id)
|
||||
}
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if model.points.isEmpty {
|
||||
ZStack {
|
||||
Text(Self.emptyStateText(title: effectiveSelectedSeries?.title))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: Layout.emptyStateHeight)
|
||||
} else {
|
||||
self.utilizationChart(model: model)
|
||||
.chartYAxis(.hidden)
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: model.axisIndexes) { value in
|
||||
AxisGridLine().foregroundStyle(Color.clear)
|
||||
AxisTick().foregroundStyle(Color.clear)
|
||||
AxisValueLabel {
|
||||
if let raw = value.as(Double.self) {
|
||||
let index = Int(raw.rounded())
|
||||
if let point = model.pointsByIndex[index] {
|
||||
let isTrailingFullChartLabel = index == model.points.last?.index
|
||||
&& model.points.count == Layout.maxPoints
|
||||
Self.axisLabel(
|
||||
for: point,
|
||||
windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0,
|
||||
isTrailingFullChartLabel: isTrailingFullChartLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(height: Layout.chartHeight)
|
||||
.accessibilityLabel(L("Plan utilization chart"))
|
||||
.accessibilityValue(
|
||||
model.points.isEmpty
|
||||
? L("No data")
|
||||
: String(format: L("%d utilization samples"), model.points.count))
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geo in
|
||||
MouseLocationReader { location in
|
||||
self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
Text(self.detailLine(model: model, windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(height: Layout.detailHeight, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading)
|
||||
.task(id: self.visibleSeries.map(\.id).joined(separator: ",")) {
|
||||
guard let firstVisibleSeries = self.visibleSeries.first else { return }
|
||||
guard !self.visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return }
|
||||
self.selectedSeriesID = firstVisibleSeries.id
|
||||
self.selectedPointID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func visibleSeries(
|
||||
histories: [PlanUtilizationSeriesHistory],
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?) -> [VisibleSeries]
|
||||
{
|
||||
let metadata = ProviderDescriptorRegistry.metadata[provider]
|
||||
let allowedNames = self.visibleSeriesNames(provider: provider, snapshot: snapshot)
|
||||
var historiesBySelection: [SeriesSelection: PlanUtilizationSeriesHistory] = [:]
|
||||
for history in histories {
|
||||
guard !history.entries.isEmpty else { continue }
|
||||
guard history.windowMinutes > 0 else { continue }
|
||||
guard allowedNames?.contains(history.name) ?? true else { continue }
|
||||
|
||||
let canonicalWindowMinutes = history.name.canonicalWindowMinutes(history.windowMinutes)
|
||||
let selection = SeriesSelection(name: history.name, windowMinutes: canonicalWindowMinutes)
|
||||
if let existingHistory = historiesBySelection[selection] {
|
||||
historiesBySelection[selection] = PlanUtilizationSeriesHistory(
|
||||
name: history.name,
|
||||
windowMinutes: canonicalWindowMinutes,
|
||||
entries: Self.mergedEntries(existingHistory.entries + history.entries))
|
||||
} else {
|
||||
historiesBySelection[selection] = PlanUtilizationSeriesHistory(
|
||||
name: history.name,
|
||||
windowMinutes: canonicalWindowMinutes,
|
||||
entries: history.entries)
|
||||
}
|
||||
}
|
||||
|
||||
return historiesBySelection.values
|
||||
.sorted { lhs, rhs in
|
||||
let lhsOrder = self.seriesSortOrder(lhs.name)
|
||||
let rhsOrder = self.seriesSortOrder(rhs.name)
|
||||
if lhsOrder != rhsOrder {
|
||||
return lhsOrder < rhsOrder
|
||||
}
|
||||
if lhs.windowMinutes != rhs.windowMinutes {
|
||||
return lhs.windowMinutes < rhs.windowMinutes
|
||||
}
|
||||
return lhs.name.rawValue < rhs.name.rawValue
|
||||
}
|
||||
.map { history in
|
||||
VisibleSeries(
|
||||
selection: SeriesSelection(name: history.name, windowMinutes: history.windowMinutes),
|
||||
title: self.seriesTitle(name: history.name, metadata: metadata),
|
||||
history: history)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func mergedEntries(
|
||||
_ entries: [PlanUtilizationHistoryEntry]) -> [PlanUtilizationHistoryEntry]
|
||||
{
|
||||
var seen: Set<PlanUtilizationHistoryEntry> = []
|
||||
return entries.filter { entry in
|
||||
seen.insert(entry).inserted
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func visibleSeriesNames(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?) -> Set<PlanUtilizationSeriesName>?
|
||||
{
|
||||
guard let snapshot else { return nil }
|
||||
|
||||
var names: Set<PlanUtilizationSeriesName> = []
|
||||
switch provider {
|
||||
case .codex:
|
||||
if snapshot.primary != nil { names.insert(.session) }
|
||||
if snapshot.secondary != nil { names.insert(.weekly) }
|
||||
case .claude:
|
||||
if snapshot.primary != nil { names.insert(.session) }
|
||||
if snapshot.secondary != nil { names.insert(.weekly) }
|
||||
if snapshot.tertiary != nil,
|
||||
ProviderDescriptorRegistry.metadata[provider]?.supportsOpus == true
|
||||
{
|
||||
names.insert(.opus)
|
||||
}
|
||||
default:
|
||||
let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self)
|
||||
+ (snapshot.extraRateWindows?.filter(\.usageKnown).map(\.window) ?? [])
|
||||
guard windows.contains(where: { $0.windowMinutes == 7 * 24 * 60 }) else { return nil }
|
||||
names.insert(.weekly)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
private nonisolated static func makeModel(
|
||||
history: PlanUtilizationSeriesHistory?,
|
||||
provider: UsageProvider,
|
||||
referenceDate: Date) -> Model
|
||||
{
|
||||
guard let history else {
|
||||
return self.emptyModel(provider: provider)
|
||||
}
|
||||
|
||||
var points = self.seriesPoints(history: history, referenceDate: referenceDate)
|
||||
if points.count > Layout.maxPoints {
|
||||
points = Array(points.suffix(Layout.maxPoints))
|
||||
}
|
||||
|
||||
points = points.enumerated().map { offset, point in
|
||||
Point(
|
||||
id: point.id,
|
||||
index: offset,
|
||||
date: point.date,
|
||||
usedPercent: point.usedPercent,
|
||||
isObserved: point.isObserved)
|
||||
}
|
||||
|
||||
let pointsByID = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0) })
|
||||
let pointsByIndex = Dictionary(uniqueKeysWithValues: points.map { ($0.index, $0) })
|
||||
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
|
||||
let barColor = Color(red: color.red, green: color.green, blue: color.blue)
|
||||
let trackColor = MenuHighlightStyle.progressTrack(false)
|
||||
|
||||
return Model(
|
||||
points: points,
|
||||
axisIndexes: self.axisIndexes(points: points, windowMinutes: history.windowMinutes),
|
||||
xDomain: self.xDomain(points: points),
|
||||
pointsByID: pointsByID,
|
||||
pointsByIndex: pointsByIndex,
|
||||
barColor: barColor,
|
||||
trackColor: trackColor)
|
||||
}
|
||||
|
||||
private nonisolated static func emptyModel(provider: UsageProvider) -> Model {
|
||||
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
|
||||
let barColor = Color(red: color.red, green: color.green, blue: color.blue)
|
||||
let trackColor = MenuHighlightStyle.progressTrack(false)
|
||||
return Model(
|
||||
points: [],
|
||||
axisIndexes: [],
|
||||
xDomain: nil,
|
||||
pointsByID: [:],
|
||||
pointsByIndex: [:],
|
||||
barColor: barColor,
|
||||
trackColor: trackColor)
|
||||
}
|
||||
|
||||
private nonisolated static func seriesPoints(
|
||||
history: PlanUtilizationSeriesHistory,
|
||||
referenceDate: Date) -> [Point]
|
||||
{
|
||||
guard history.windowMinutes > 0 else { return [] }
|
||||
let windowInterval = Double(history.windowMinutes) * 60
|
||||
let resetBoundaryLattice = self.resetBoundaryLattice(
|
||||
entries: history.entries,
|
||||
windowMinutes: history.windowMinutes)
|
||||
var strongestObservedPointByPeriod: [Date: EntryPointAccumulator] = [:]
|
||||
|
||||
for entry in history.entries {
|
||||
let candidate = self.observedPointCandidate(
|
||||
for: entry,
|
||||
windowMinutes: history.windowMinutes,
|
||||
resetBoundaryLattice: resetBoundaryLattice)
|
||||
|
||||
if let existing = strongestObservedPointByPeriod[candidate.effectiveBoundaryDate],
|
||||
!self.shouldPreferObservedPoint(candidate, over: existing)
|
||||
{
|
||||
continue
|
||||
}
|
||||
strongestObservedPointByPeriod[candidate.effectiveBoundaryDate] = candidate
|
||||
}
|
||||
|
||||
guard !strongestObservedPointByPeriod.isEmpty else { return [] }
|
||||
|
||||
let sortedPeriodBoundaryDates = strongestObservedPointByPeriod.keys.sorted()
|
||||
var points: [Point] = []
|
||||
var previousPeriodBoundaryDate: Date?
|
||||
|
||||
for periodBoundaryDate in sortedPeriodBoundaryDates {
|
||||
if let previousPeriodBoundaryDate {
|
||||
var cursor = previousPeriodBoundaryDate.addingTimeInterval(windowInterval)
|
||||
while cursor < periodBoundaryDate {
|
||||
points.append(Point(
|
||||
id: cursor,
|
||||
index: 0,
|
||||
date: cursor,
|
||||
usedPercent: 0,
|
||||
isObserved: false))
|
||||
cursor = cursor.addingTimeInterval(windowInterval)
|
||||
}
|
||||
}
|
||||
|
||||
if let bucket = strongestObservedPointByPeriod[periodBoundaryDate] {
|
||||
points.append(Point(
|
||||
id: bucket.effectiveBoundaryDate,
|
||||
index: 0,
|
||||
date: bucket.displayBoundaryDate,
|
||||
usedPercent: bucket.usedPercent,
|
||||
isObserved: true))
|
||||
}
|
||||
previousPeriodBoundaryDate = periodBoundaryDate
|
||||
}
|
||||
|
||||
if let lastObservedPeriodBoundaryDate = sortedPeriodBoundaryDates.last {
|
||||
let currentPeriodBoundaryDate = self.currentPeriodBoundaryDate(
|
||||
for: referenceDate,
|
||||
windowMinutes: history.windowMinutes,
|
||||
resetBoundaryLattice: resetBoundaryLattice)
|
||||
|
||||
if currentPeriodBoundaryDate > lastObservedPeriodBoundaryDate {
|
||||
var cursor = lastObservedPeriodBoundaryDate.addingTimeInterval(windowInterval)
|
||||
while cursor <= currentPeriodBoundaryDate {
|
||||
points.append(Point(
|
||||
id: cursor,
|
||||
index: 0,
|
||||
date: cursor,
|
||||
usedPercent: 0,
|
||||
isObserved: false))
|
||||
cursor = cursor.addingTimeInterval(windowInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
private nonisolated static func observedPointCandidate(
|
||||
for entry: PlanUtilizationHistoryEntry,
|
||||
windowMinutes: Int,
|
||||
resetBoundaryLattice: ResetBoundaryLattice?) -> EntryPointAccumulator
|
||||
{
|
||||
let rawResetBoundaryDate = entry.resetsAt.map(self.normalizedBoundaryDate)
|
||||
let effectiveBoundaryDate = self.effectivePeriodBoundaryDate(
|
||||
for: entry,
|
||||
windowMinutes: windowMinutes,
|
||||
rawResetBoundaryDate: rawResetBoundaryDate,
|
||||
resetBoundaryLattice: resetBoundaryLattice)
|
||||
return EntryPointAccumulator(
|
||||
effectiveBoundaryDate: effectiveBoundaryDate,
|
||||
displayBoundaryDate: rawResetBoundaryDate ?? effectiveBoundaryDate,
|
||||
observedAt: entry.capturedAt,
|
||||
usedPercent: max(0, min(100, entry.usedPercent)),
|
||||
hasObservedResetBoundary: rawResetBoundaryDate != nil)
|
||||
}
|
||||
|
||||
private nonisolated static func resetBoundaryLattice(
|
||||
entries: [PlanUtilizationHistoryEntry],
|
||||
windowMinutes: Int) -> ResetBoundaryLattice?
|
||||
{
|
||||
guard let latestObservedResetBoundaryDate = entries
|
||||
.compactMap(\.resetsAt)
|
||||
.map(self.normalizedBoundaryDate)
|
||||
.max()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return ResetBoundaryLattice(
|
||||
referenceBoundaryDate: latestObservedResetBoundaryDate,
|
||||
windowInterval: Double(windowMinutes) * 60)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedBoundaryDate(_ date: Date) -> Date {
|
||||
Date(timeIntervalSince1970: floor(date.timeIntervalSince1970))
|
||||
}
|
||||
|
||||
private nonisolated static func effectivePeriodBoundaryDate(
|
||||
for entry: PlanUtilizationHistoryEntry,
|
||||
windowMinutes: Int,
|
||||
rawResetBoundaryDate: Date?,
|
||||
resetBoundaryLattice: ResetBoundaryLattice?) -> Date
|
||||
{
|
||||
if let rawResetBoundaryDate {
|
||||
if let resetBoundaryLattice {
|
||||
return self.closestPeriodBoundaryDate(
|
||||
to: rawResetBoundaryDate,
|
||||
resetBoundaryLattice: resetBoundaryLattice)
|
||||
}
|
||||
return rawResetBoundaryDate
|
||||
}
|
||||
if let resetBoundaryLattice {
|
||||
return self.periodBoundaryDate(
|
||||
containing: entry.capturedAt,
|
||||
resetBoundaryLattice: resetBoundaryLattice)
|
||||
}
|
||||
return self.syntheticBoundaryDate(for: entry.capturedAt, windowMinutes: windowMinutes)
|
||||
}
|
||||
|
||||
private nonisolated static func shouldPreferObservedPoint(
|
||||
_ candidate: EntryPointAccumulator,
|
||||
over existing: EntryPointAccumulator) -> Bool
|
||||
{
|
||||
if candidate.usedPercent != existing.usedPercent {
|
||||
return candidate.usedPercent > existing.usedPercent
|
||||
}
|
||||
if candidate.hasObservedResetBoundary != existing.hasObservedResetBoundary {
|
||||
return candidate.hasObservedResetBoundary
|
||||
}
|
||||
if candidate.displayBoundaryDate != existing.displayBoundaryDate {
|
||||
return candidate.displayBoundaryDate > existing.displayBoundaryDate
|
||||
}
|
||||
return candidate.observedAt >= existing.observedAt
|
||||
}
|
||||
|
||||
private nonisolated static func currentPeriodBoundaryDate(
|
||||
for referenceDate: Date,
|
||||
windowMinutes: Int,
|
||||
resetBoundaryLattice: ResetBoundaryLattice?) -> Date
|
||||
{
|
||||
if let resetBoundaryLattice {
|
||||
return self.periodBoundaryDate(
|
||||
containing: referenceDate,
|
||||
resetBoundaryLattice: resetBoundaryLattice)
|
||||
}
|
||||
return self.syntheticBoundaryDate(for: referenceDate, windowMinutes: windowMinutes)
|
||||
}
|
||||
|
||||
private nonisolated static func closestPeriodBoundaryDate(
|
||||
to rawBoundaryDate: Date,
|
||||
resetBoundaryLattice: ResetBoundaryLattice) -> Date
|
||||
{
|
||||
let offset = rawBoundaryDate.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate)
|
||||
let periodOffset = (offset / resetBoundaryLattice.windowInterval).rounded()
|
||||
return resetBoundaryLattice.referenceBoundaryDate
|
||||
.addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval)
|
||||
}
|
||||
|
||||
private nonisolated static func periodBoundaryDate(
|
||||
containing capturedAt: Date,
|
||||
resetBoundaryLattice: ResetBoundaryLattice) -> Date
|
||||
{
|
||||
let offset = capturedAt.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate)
|
||||
let periodOffset = ceil(offset / resetBoundaryLattice.windowInterval)
|
||||
return resetBoundaryLattice.referenceBoundaryDate
|
||||
.addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval)
|
||||
}
|
||||
|
||||
private nonisolated static func syntheticBoundaryDate(for date: Date, windowMinutes: Int) -> Date {
|
||||
let bucketSeconds = Double(windowMinutes) * 60
|
||||
let bucketIndex = floor(date.timeIntervalSince1970 / bucketSeconds)
|
||||
return Date(timeIntervalSince1970: (bucketIndex + 1) * bucketSeconds)
|
||||
}
|
||||
|
||||
private nonisolated static func xDomain(points: [Point]) -> ClosedRange<Double>? {
|
||||
guard !points.isEmpty else { return nil }
|
||||
return -0.5...(Double(Layout.maxPoints) - 0.5)
|
||||
}
|
||||
|
||||
private nonisolated static func axisIndexes(points: [Point], windowMinutes: Int) -> [Double] {
|
||||
let candidateIndexes = self.axisCandidateIndexes(points: points, windowMinutes: windowMinutes)
|
||||
return self.proportionalAxisIndexes(points: points, candidateIndexes: candidateIndexes)
|
||||
}
|
||||
|
||||
private nonisolated static func axisCandidateIndexes(points: [Point], windowMinutes: Int) -> [Int] {
|
||||
if windowMinutes <= 300 {
|
||||
return self.sessionAxisCandidateIndexes(points: points)
|
||||
}
|
||||
return points.map(\.index)
|
||||
}
|
||||
|
||||
private nonisolated static func sessionAxisCandidateIndexes(points: [Point]) -> [Int] {
|
||||
guard let firstPoint = points.first else { return [] }
|
||||
let calendar = Calendar.current
|
||||
var previousPoint = firstPoint
|
||||
var rawIndexes: [Int] = [firstPoint.index]
|
||||
|
||||
for point in points.dropFirst() {
|
||||
if !calendar.isDate(point.date, inSameDayAs: previousPoint.date) {
|
||||
rawIndexes.append(point.index)
|
||||
}
|
||||
previousPoint = point
|
||||
}
|
||||
|
||||
return rawIndexes
|
||||
}
|
||||
|
||||
private nonisolated static func proportionalAxisIndexes(points: [Point], candidateIndexes: [Int]) -> [Double] {
|
||||
guard !points.isEmpty, !candidateIndexes.isEmpty else { return [] }
|
||||
|
||||
let occupiedFraction = Double(points.count) / Double(Layout.maxPoints)
|
||||
let proportionalBudget = Int(ceil(Double(Layout.maxAxisLabels) * occupiedFraction))
|
||||
let labelBudget = max(1, min(Layout.maxAxisLabels, proportionalBudget, candidateIndexes.count))
|
||||
|
||||
if labelBudget == 1 {
|
||||
return [Double(candidateIndexes[0])]
|
||||
}
|
||||
|
||||
let step = Double(candidateIndexes.count - 1) / Double(labelBudget - 1)
|
||||
var selectedIndexes = (0..<labelBudget).map { position in
|
||||
let candidateOffset = Int((Double(position) * step).rounded())
|
||||
return candidateIndexes[candidateOffset]
|
||||
}
|
||||
selectedIndexes = Array(NSOrderedSet(array: selectedIndexes)) as? [Int] ?? selectedIndexes
|
||||
|
||||
let trailingLabelCutoff = points.first!.index + Int(floor(Double(points.count) * 0.8))
|
||||
if selectedIndexes.count > 1,
|
||||
let lastSelectedIndex = selectedIndexes.last,
|
||||
lastSelectedIndex >= trailingLabelCutoff
|
||||
{
|
||||
selectedIndexes.removeLast()
|
||||
}
|
||||
|
||||
if points.count == Layout.maxPoints,
|
||||
let lastVisibleIndex = points.last?.index,
|
||||
!selectedIndexes.contains(lastVisibleIndex)
|
||||
{
|
||||
selectedIndexes.append(lastVisibleIndex)
|
||||
}
|
||||
|
||||
let deduplicated = Array(NSOrderedSet(array: selectedIndexes)) as? [Int] ?? selectedIndexes
|
||||
return deduplicated.map(Double.init)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private static func axisLabel(
|
||||
for point: Point,
|
||||
windowMinutes: Int,
|
||||
isTrailingFullChartLabel: Bool) -> some View
|
||||
{
|
||||
let label = Text(point.date.formatted(self.axisFormat(windowMinutes: windowMinutes)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
if isTrailingFullChartLabel {
|
||||
label
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
.offset(x: -24)
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func axisFormat(windowMinutes: Int) -> Date.FormatStyle {
|
||||
if windowMinutes <= 300 {
|
||||
return .dateTime.month(.abbreviated).day()
|
||||
}
|
||||
return .dateTime.month(.abbreviated).day()
|
||||
}
|
||||
|
||||
private nonisolated static func seriesTitle(
|
||||
name: PlanUtilizationSeriesName,
|
||||
metadata: ProviderMetadata?) -> String
|
||||
{
|
||||
switch name {
|
||||
case .session:
|
||||
L(metadata?.sessionLabel ?? "Session")
|
||||
case .weekly:
|
||||
L(metadata?.weeklyLabel ?? "Weekly")
|
||||
case .opus:
|
||||
metadata?.opusLabel ?? "Opus"
|
||||
default:
|
||||
self.fallbackTitle(for: name.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func fallbackTitle(for rawValue: String) -> String {
|
||||
let words = rawValue
|
||||
.replacingOccurrences(of: "([a-z0-9])([A-Z])", with: "$1 $2", options: .regularExpression)
|
||||
.split(separator: " ")
|
||||
return words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(separator: " ")
|
||||
}
|
||||
|
||||
private nonisolated static func seriesSortOrder(_ name: PlanUtilizationSeriesName) -> Int {
|
||||
switch name {
|
||||
case .session:
|
||||
0
|
||||
case .weekly:
|
||||
1
|
||||
case .opus:
|
||||
2
|
||||
default:
|
||||
100
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func emptyStateText(title: String?) -> String {
|
||||
if let title {
|
||||
return String(format: L("No %@ utilization data yet."), title.lowercased())
|
||||
}
|
||||
return L("No utilization data yet.")
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ModelSnapshot: Equatable {
|
||||
let pointCount: Int
|
||||
let axisIndexes: [Double]
|
||||
let xDomain: ClosedRange<Double>?
|
||||
let selectedSeries: String?
|
||||
let visibleSeries: [String]
|
||||
let usedPercents: [Double]
|
||||
let pointDates: [String]
|
||||
}
|
||||
|
||||
nonisolated static func _modelSnapshotForTesting(
|
||||
selectedSeriesRawValue: String? = nil,
|
||||
histories: [PlanUtilizationSeriesHistory],
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot? = nil,
|
||||
referenceDate: Date? = nil) -> ModelSnapshot
|
||||
{
|
||||
let visibleSeries = self.visibleSeries(histories: histories, provider: provider, snapshot: snapshot)
|
||||
let selectedSeries = visibleSeries.first(where: { $0.id == selectedSeriesRawValue }) ?? visibleSeries.first
|
||||
let model = self.makeModel(
|
||||
history: selectedSeries?.history,
|
||||
provider: provider,
|
||||
referenceDate: referenceDate ?? histories.flatMap(\.entries).map(\.capturedAt).max() ?? Date())
|
||||
return ModelSnapshot(
|
||||
pointCount: model.points.count,
|
||||
axisIndexes: model.axisIndexes,
|
||||
xDomain: model.xDomain,
|
||||
selectedSeries: selectedSeries?.id,
|
||||
visibleSeries: visibleSeries.map(\.id),
|
||||
usedPercents: model.points.map(\.usedPercent),
|
||||
pointDates: model.points.map { point in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: point.date)
|
||||
})
|
||||
}
|
||||
|
||||
nonisolated static func _detailLineForTesting(
|
||||
selectedSeriesRawValue: String? = nil,
|
||||
histories: [PlanUtilizationSeriesHistory],
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot? = nil,
|
||||
referenceDate: Date? = nil) -> String
|
||||
{
|
||||
let visibleSeries = self.visibleSeries(histories: histories, provider: provider, snapshot: snapshot)
|
||||
let selectedSeries = visibleSeries.first(where: { $0.id == selectedSeriesRawValue }) ?? visibleSeries.first
|
||||
let model = self.makeModel(
|
||||
history: selectedSeries?.history,
|
||||
provider: provider,
|
||||
referenceDate: referenceDate ?? histories.flatMap(\.entries).map(\.capturedAt).max() ?? Date())
|
||||
return self.detailLine(point: model.points.last, windowMinutes: selectedSeries?.history.windowMinutes ?? 0)
|
||||
}
|
||||
|
||||
nonisolated static func _emptyStateTextForTesting(title: String?) -> String {
|
||||
self.emptyStateText(title: title)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func xValue(for index: Int) -> PlottableValue<Double> {
|
||||
.value(L("Series"), Double(index))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func utilizationChart(model: Model) -> some View {
|
||||
if let xDomain = model.xDomain {
|
||||
Chart {
|
||||
self.utilizationChartContent(model: model)
|
||||
}
|
||||
.chartXScale(domain: xDomain)
|
||||
} else {
|
||||
Chart {
|
||||
self.utilizationChartContent(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ChartContentBuilder
|
||||
private func utilizationChartContent(model: Model) -> some ChartContent {
|
||||
ForEach(model.points) { point in
|
||||
BarMark(
|
||||
x: self.xValue(for: point.index),
|
||||
yStart: .value(L("Capacity Start"), 0),
|
||||
yEnd: .value(L("Capacity End"), 100),
|
||||
width: .fixed(Layout.barWidth))
|
||||
.foregroundStyle(model.trackColor)
|
||||
BarMark(
|
||||
x: self.xValue(for: point.index),
|
||||
yStart: .value(L("Utilization Start"), 0),
|
||||
yEnd: .value(L("Utilization End"), point.usedPercent),
|
||||
width: .fixed(Layout.barWidth))
|
||||
.foregroundStyle(model.barColor)
|
||||
}
|
||||
if let selected = self.selectedPoint(model: model) {
|
||||
RuleMark(x: self.xValue(for: selected.index))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedPoint(model: Model) -> Point? {
|
||||
guard let selectedPointID else { return nil }
|
||||
return model.pointsByID[selectedPointID]
|
||||
}
|
||||
|
||||
private func detailLine(model: Model, windowMinutes: Int) -> String {
|
||||
let activePoint = self.selectedPoint(model: model) ?? model.points.last
|
||||
return Self.detailLine(point: activePoint, windowMinutes: windowMinutes)
|
||||
}
|
||||
|
||||
private func updateSelection(
|
||||
location: CGPoint?,
|
||||
model: Model,
|
||||
proxy: ChartProxy,
|
||||
geo: GeometryProxy)
|
||||
{
|
||||
guard let location else {
|
||||
if self.selectedPointID != nil { self.selectedPointID = nil }
|
||||
return
|
||||
}
|
||||
|
||||
guard let plotAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geo[plotAnchor]
|
||||
guard plotFrame.contains(location) else {
|
||||
if self.selectedPointID != nil { self.selectedPointID = nil }
|
||||
return
|
||||
}
|
||||
|
||||
let xInPlot = location.x - plotFrame.origin.x
|
||||
guard let xValue: Double = proxy.value(atX: xInPlot) else { return }
|
||||
|
||||
var best: (id: Date, distance: Double)?
|
||||
for point in model.points {
|
||||
let distance = abs(Double(point.index) - xValue)
|
||||
if let current = best {
|
||||
if distance < current.distance {
|
||||
best = (point.id, distance)
|
||||
}
|
||||
} else {
|
||||
best = (point.id, distance)
|
||||
}
|
||||
}
|
||||
|
||||
// Stay on the last selected bar when cursor is in the gap between bars; only switch
|
||||
// selection when the cursor is over the bar's own visual body.
|
||||
if let best, let bestPoint = model.pointsByID[best.id],
|
||||
let barX = proxy.position(forX: Double(bestPoint.index))
|
||||
{
|
||||
guard ChartBarHoverSelection.accepts(
|
||||
distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)),
|
||||
barHalfWidth: Layout.barWidth / 2,
|
||||
selectableCount: model.points.count)
|
||||
else { return }
|
||||
}
|
||||
|
||||
if self.selectedPointID != best?.id {
|
||||
self.selectedPointID = best?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlanUtilizationHistoryChartMenuView {
|
||||
private nonisolated static func detailLine(point: Point?, windowMinutes: Int) -> String {
|
||||
guard let point else {
|
||||
return "-"
|
||||
}
|
||||
|
||||
let dateLabel = self.detailDateLabel(for: point.date, windowMinutes: windowMinutes)
|
||||
|
||||
let used = max(0, min(100, point.usedPercent))
|
||||
if !point.isObserved {
|
||||
return "\(dateLabel): -"
|
||||
}
|
||||
let usedText = used.formatted(.number.precision(.fractionLength(0...1)))
|
||||
return L("%@: %@%% used", dateLabel, usedText)
|
||||
}
|
||||
|
||||
private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = codexBarLocalizedLocale()
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.setLocalizedDateFormatFromTemplate("MMM d, h:mm a")
|
||||
var rendered = formatter.string(from: date).replacingOccurrences(of: "\u{202F}", with: " ")
|
||||
let amSymbol = formatter.amSymbol ?? ""
|
||||
let pmSymbol = formatter.pmSymbol ?? ""
|
||||
if !amSymbol.isEmpty {
|
||||
rendered = rendered.replacingOccurrences(of: amSymbol, with: amSymbol.lowercased())
|
||||
}
|
||||
if !pmSymbol.isEmpty {
|
||||
rendered = rendered.replacingOccurrences(of: pmSymbol, with: pmSymbol.lowercased())
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, ExpressibleByStringLiteral, Sendable {
|
||||
let rawValue: String
|
||||
|
||||
init(rawValue: String) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
init(stringLiteral value: StringLiteralType) {
|
||||
self.rawValue = value
|
||||
}
|
||||
|
||||
static let session: Self = "session"
|
||||
static let weekly: Self = "weekly"
|
||||
static let opus: Self = "opus"
|
||||
|
||||
func canonicalWindowMinutes(_ windowMinutes: Int) -> Int {
|
||||
switch self {
|
||||
case .session where (295...305).contains(windowMinutes):
|
||||
300
|
||||
case .weekly where (10070...10090).contains(windowMinutes):
|
||||
10080
|
||||
default:
|
||||
windowMinutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlanUtilizationHistoryEntry: Codable, Equatable, Hashable, Sendable {
|
||||
let capturedAt: Date
|
||||
let usedPercent: Double
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable {
|
||||
let name: PlanUtilizationSeriesName
|
||||
let windowMinutes: Int
|
||||
let entries: [PlanUtilizationHistoryEntry]
|
||||
|
||||
init(name: PlanUtilizationSeriesName, windowMinutes: Int, entries: [PlanUtilizationHistoryEntry]) {
|
||||
self.name = name
|
||||
self.windowMinutes = windowMinutes
|
||||
self.entries = entries.sorted { lhs, rhs in
|
||||
if lhs.capturedAt != rhs.capturedAt {
|
||||
return lhs.capturedAt < rhs.capturedAt
|
||||
}
|
||||
if lhs.usedPercent != rhs.usedPercent {
|
||||
return lhs.usedPercent < rhs.usedPercent
|
||||
}
|
||||
let lhsReset = lhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970
|
||||
let rhsReset = rhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970
|
||||
return lhsReset < rhsReset
|
||||
}
|
||||
}
|
||||
|
||||
var latestCapturedAt: Date? {
|
||||
self.entries.last?.capturedAt
|
||||
}
|
||||
}
|
||||
|
||||
struct PlanUtilizationHistoryBuckets: Equatable, Sendable {
|
||||
var preferredAccountKey: String?
|
||||
var unscoped: [PlanUtilizationSeriesHistory] = []
|
||||
var accounts: [String: [PlanUtilizationSeriesHistory]] = [:]
|
||||
|
||||
func histories(for accountKey: String?) -> [PlanUtilizationSeriesHistory] {
|
||||
guard let accountKey, !accountKey.isEmpty else { return self.unscoped }
|
||||
return self.accounts[accountKey] ?? []
|
||||
}
|
||||
|
||||
mutating func setHistories(_ histories: [PlanUtilizationSeriesHistory], for accountKey: String?) {
|
||||
let sorted = Self.sortedHistories(histories)
|
||||
guard let accountKey, !accountKey.isEmpty else {
|
||||
self.unscoped = sorted
|
||||
return
|
||||
}
|
||||
if sorted.isEmpty {
|
||||
self.accounts.removeValue(forKey: accountKey)
|
||||
} else {
|
||||
self.accounts[accountKey] = sorted
|
||||
}
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
self.unscoped.isEmpty && self.accounts.values.allSatisfy(\.isEmpty)
|
||||
}
|
||||
|
||||
private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] {
|
||||
histories.sorted { lhs, rhs in
|
||||
if lhs.windowMinutes != rhs.windowMinutes {
|
||||
return lhs.windowMinutes < rhs.windowMinutes
|
||||
}
|
||||
return lhs.name.rawValue < rhs.name.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProviderHistoryFile: Codable, Sendable {
|
||||
let preferredAccountKey: String?
|
||||
let unscoped: [PlanUtilizationSeriesHistory]
|
||||
let accounts: [String: [PlanUtilizationSeriesHistory]]
|
||||
}
|
||||
|
||||
private struct ProviderHistoryDocument: Codable, Sendable {
|
||||
let version: Int
|
||||
let preferredAccountKey: String?
|
||||
let unscoped: [PlanUtilizationSeriesHistory]
|
||||
let accounts: [String: [PlanUtilizationSeriesHistory]]
|
||||
}
|
||||
|
||||
struct PlanUtilizationHistoryStore: Sendable {
|
||||
fileprivate static let providerSchemaVersion = 1
|
||||
|
||||
let directoryURL: URL?
|
||||
|
||||
init(directoryURL: URL? = Self.defaultDirectoryURL()) {
|
||||
self.directoryURL = directoryURL
|
||||
}
|
||||
|
||||
static func defaultAppSupport() -> Self {
|
||||
Self()
|
||||
}
|
||||
|
||||
func load() -> [UsageProvider: PlanUtilizationHistoryBuckets] {
|
||||
self.loadProviderFiles()
|
||||
}
|
||||
|
||||
/// Loads the persisted histories on a utility-priority detached task.
|
||||
///
|
||||
/// The on-disk decode is synchronous I/O + JSON parsing that can take
|
||||
/// ~150 ms for mature two-year histories and must not run on the app
|
||||
/// startup main thread. The returned dictionary is safe to apply on the
|
||||
/// main actor once decoding completes.
|
||||
func loadAsync() async -> [UsageProvider: PlanUtilizationHistoryBuckets] {
|
||||
await Task.detached(priority: .utility) { self.load() }.value
|
||||
}
|
||||
|
||||
func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) {
|
||||
guard let directoryURL = self.directoryURL else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
|
||||
for provider in UsageProvider.allCases {
|
||||
let fileURL = self.providerFileURL(for: provider)
|
||||
let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets()
|
||||
let unscoped = Self.sortedHistories(buckets.unscoped)
|
||||
let accounts = Self.sortedAccounts(buckets.accounts)
|
||||
guard !unscoped.isEmpty || !accounts.isEmpty else {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
continue
|
||||
}
|
||||
|
||||
let payload = ProviderHistoryDocument(
|
||||
version: Self.providerSchemaVersion,
|
||||
preferredAccountKey: buckets.preferredAccountKey,
|
||||
unscoped: unscoped,
|
||||
accounts: accounts)
|
||||
let data = try encoder.encode(payload)
|
||||
try data.write(to: fileURL, options: Data.WritingOptions.atomic)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort persistence only.
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProviderFiles() -> [UsageProvider: PlanUtilizationHistoryBuckets] {
|
||||
guard self.directoryURL != nil else { return [:] }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:]
|
||||
|
||||
for provider in UsageProvider.allCases {
|
||||
let fileURL = self.providerFileURL(for: provider)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { continue }
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let decoded = try? decoder.decode(ProviderHistoryDocument.self, from: data)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let history = ProviderHistoryFile(
|
||||
preferredAccountKey: decoded.preferredAccountKey,
|
||||
unscoped: decoded.unscoped,
|
||||
accounts: decoded.accounts)
|
||||
output[provider] = Self.decodeProvider(history)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private static func decodeProviders(
|
||||
_ providers: [String: ProviderHistoryFile]) -> [UsageProvider: PlanUtilizationHistoryBuckets]
|
||||
{
|
||||
var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:]
|
||||
for (rawProvider, providerHistory) in providers {
|
||||
guard let provider = UsageProvider(rawValue: rawProvider) else { continue }
|
||||
output[provider] = Self.decodeProvider(providerHistory)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func decodeProvider(_ providerHistory: ProviderHistoryFile) -> PlanUtilizationHistoryBuckets {
|
||||
PlanUtilizationHistoryBuckets(
|
||||
preferredAccountKey: providerHistory.preferredAccountKey,
|
||||
unscoped: self.sortedHistories(providerHistory.unscoped),
|
||||
accounts: Dictionary(
|
||||
uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, histories in
|
||||
let sorted = Self.sortedHistories(histories)
|
||||
guard !sorted.isEmpty else { return nil }
|
||||
return (accountKey, sorted)
|
||||
}))
|
||||
}
|
||||
|
||||
private static func sortedAccounts(
|
||||
_ accounts: [String: [PlanUtilizationSeriesHistory]]) -> [String: [PlanUtilizationSeriesHistory]]
|
||||
{
|
||||
Dictionary(
|
||||
uniqueKeysWithValues: accounts.compactMap { accountKey, histories in
|
||||
let sorted = Self.sortedHistories(histories)
|
||||
guard !sorted.isEmpty else { return nil }
|
||||
return (accountKey, sorted)
|
||||
})
|
||||
}
|
||||
|
||||
private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] {
|
||||
self.sanitizedHistories(histories).sorted { lhs, rhs in
|
||||
if lhs.windowMinutes != rhs.windowMinutes {
|
||||
return lhs.windowMinutes < rhs.windowMinutes
|
||||
}
|
||||
return lhs.name.rawValue < rhs.name.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitizedHistories(_ histories: [PlanUtilizationSeriesHistory])
|
||||
-> [PlanUtilizationSeriesHistory] {
|
||||
histories.filter { history in
|
||||
history.windowMinutes > 0 && !history.entries.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultDirectoryURL() -> URL? {
|
||||
guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true)
|
||||
return dir.appendingPathComponent("history", isDirectory: true)
|
||||
}
|
||||
|
||||
private func providerFileURL(for provider: UsageProvider) -> URL {
|
||||
let directoryURL = self.directoryURL ?? URL(fileURLWithPath: "/dev/null", isDirectory: true)
|
||||
return directoryURL.appendingPathComponent("\(provider.rawValue).json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProviderHistoryDocument {
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let version = try container.decode(Int.self, forKey: .version)
|
||||
guard version == PlanUtilizationHistoryStore.providerSchemaVersion else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .version,
|
||||
in: container,
|
||||
debugDescription: "Unsupported provider history schema version \(version)")
|
||||
}
|
||||
self.version = version
|
||||
self.preferredAccountKey = try container.decodeIfPresent(String.self, forKey: .preferredAccountKey)
|
||||
self.unscoped = try container.decode([PlanUtilizationSeriesHistory].self, forKey: .unscoped)
|
||||
self.accounts = try container.decode([String: [PlanUtilizationSeriesHistory]].self, forKey: .accounts)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot synchronization primitive used by `UsageStore.init` to defer the
|
||||
/// utility-priority plan-utilization history load until a test chooses to
|
||||
/// release it. The default `nil` gate is open and the load proceeds immediately.
|
||||
///
|
||||
/// Used to verify that `UsageStore.init` returns before disk I/O completes and
|
||||
/// that the history is applied exactly once after the gate opens.
|
||||
final class PlanUtilizationHistoryLoadGate: @unchecked Sendable {
|
||||
private enum State {
|
||||
case closed
|
||||
case open
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var continuations: [CheckedContinuation<Bool, Never>] = []
|
||||
private var state: State = .closed
|
||||
|
||||
init() {}
|
||||
|
||||
var isOpen: Bool {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.state == .open
|
||||
}
|
||||
|
||||
var isCancelled: Bool {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.state == .cancelled
|
||||
}
|
||||
|
||||
func wait() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
switch self.state {
|
||||
case .open:
|
||||
self.lock.unlock()
|
||||
continuation.resume(returning: true)
|
||||
case .cancelled:
|
||||
self.lock.unlock()
|
||||
continuation.resume(returning: false)
|
||||
case .closed:
|
||||
self.continuations.append(continuation)
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func open() {
|
||||
self.lock.lock()
|
||||
guard self.state == .closed else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.state = .open
|
||||
let pending = self.continuations
|
||||
self.continuations.removeAll()
|
||||
self.lock.unlock()
|
||||
for continuation in pending {
|
||||
continuation.resume(returning: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels this one-shot gate and resumes pending or future waiters with
|
||||
/// `false`. Cancellation is sticky so it cannot race ahead of `wait()` and
|
||||
/// lose the wakeup that drains the load task.
|
||||
func cancel() {
|
||||
self.lock.lock()
|
||||
guard self.state == .closed else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.state = .cancelled
|
||||
let pending = self.continuations
|
||||
self.continuations.removeAll()
|
||||
self.lock.unlock()
|
||||
for continuation in pending {
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
struct PredictivePaceWarningStateKey: Hashable {
|
||||
let provider: UsageProvider
|
||||
let accountDiscriminator: String
|
||||
let window: QuotaWarningWindow
|
||||
let resetWindow: PredictivePaceWarningResetWindow
|
||||
}
|
||||
|
||||
struct PredictivePaceWarningResetWindow: Hashable {
|
||||
let windowMinutes: Int?
|
||||
let resetsAt: Date
|
||||
|
||||
func belongsToSameCycle(as other: Self) -> Bool {
|
||||
guard self.windowMinutes == other.windowMinutes else { return false }
|
||||
let tolerance = self.windowMinutes.map { max(TimeInterval($0 * 60) / 2, 300) } ?? 300
|
||||
return abs(self.resetsAt.timeIntervalSince(other.resetsAt)) < tolerance
|
||||
}
|
||||
}
|
||||
|
||||
struct PredictivePaceWarningEvent: Equatable {
|
||||
let window: QuotaWarningWindow
|
||||
let etaSeconds: TimeInterval
|
||||
let accountDisplayName: String?
|
||||
}
|
||||
|
||||
enum PredictivePaceWarningNotificationLogic {
|
||||
static func notificationIDPrefix(provider: UsageProvider, event: PredictivePaceWarningEvent) -> String {
|
||||
"predictive-pace-warning-\(provider.rawValue)-\(event.window.rawValue)"
|
||||
}
|
||||
|
||||
static func notificationCopy(
|
||||
providerName: String,
|
||||
event: PredictivePaceWarningEvent,
|
||||
now: Date = .init()) -> (title: String, body: String)
|
||||
{
|
||||
let windowLabel = event.window.localizedNotificationDisplayName
|
||||
let title = L("predictive_pace_warning_notification_title", providerName, windowLabel)
|
||||
let durationText = Self.durationText(seconds: event.etaSeconds, now: now)
|
||||
let body = if let accountDisplayName = event.accountDisplayName {
|
||||
L("predictive_pace_warning_notification_body_with_account", accountDisplayName, durationText)
|
||||
} else {
|
||||
L("predictive_pace_warning_notification_body", durationText)
|
||||
}
|
||||
return (title, body)
|
||||
}
|
||||
|
||||
static func shouldNotify(pace: UsagePace) -> Bool {
|
||||
guard !pace.willLastToReset else { return false }
|
||||
guard let etaSeconds = pace.etaSeconds, etaSeconds > 0 else { return false }
|
||||
guard (pace.runOutProbability ?? 1) >= 0.5 else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
static func recordObservation(
|
||||
key: PredictivePaceWarningStateKey,
|
||||
pace: UsagePace,
|
||||
notifiedKeys: inout Set<PredictivePaceWarningStateKey>) -> Bool
|
||||
{
|
||||
if pace.willLastToReset {
|
||||
notifiedKeys.remove(key)
|
||||
return false
|
||||
}
|
||||
|
||||
guard self.shouldNotify(pace: pace) else { return false }
|
||||
guard !notifiedKeys.contains(key) else { return false }
|
||||
notifiedKeys.insert(key)
|
||||
return true
|
||||
}
|
||||
|
||||
static func reconcileSiblingWindowKeys(
|
||||
activeKey: PredictivePaceWarningStateKey,
|
||||
notifiedKeys: inout Set<PredictivePaceWarningStateKey>)
|
||||
{
|
||||
let siblingKeys = notifiedKeys.filter { key in
|
||||
key.provider == activeKey.provider &&
|
||||
key.accountDiscriminator == activeKey.accountDiscriminator &&
|
||||
key.window == activeKey.window
|
||||
}
|
||||
guard !siblingKeys.isEmpty else { return }
|
||||
|
||||
let alreadyWarnedThisCycle = siblingKeys.contains { key in
|
||||
key.resetWindow.belongsToSameCycle(as: activeKey.resetWindow)
|
||||
}
|
||||
notifiedKeys.subtract(siblingKeys)
|
||||
if alreadyWarnedThisCycle {
|
||||
// Follow small provider reset-time corrections without re-alerting. Replacing the key
|
||||
// lets successive relative-TTL observations move together instead of accumulating drift.
|
||||
notifiedKeys.insert(activeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func durationText(seconds: TimeInterval, now: Date) -> String {
|
||||
let countdown = UsageFormatter.resetCountdownDescription(from: now.addingTimeInterval(seconds), now: now)
|
||||
if countdown.hasPrefix("in ") {
|
||||
return String(countdown.dropFirst(3))
|
||||
}
|
||||
return countdown
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension UsageStore {
|
||||
func handlePredictivePaceWarningTransitions(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot,
|
||||
accountDiscriminatorOverride: String? = nil)
|
||||
{
|
||||
guard self.settings.predictivePaceWarningNotificationsEnabled else {
|
||||
self.predictivePaceWarningNotifiedKeys = Set(
|
||||
self.predictivePaceWarningNotifiedKeys.filter { $0.provider != provider })
|
||||
return
|
||||
}
|
||||
guard provider == .codex || provider == .claude else { return }
|
||||
guard let accountDiscriminator = self.predictivePaceWarningAccountDiscriminator(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
accountDiscriminatorOverride: accountDiscriminatorOverride)
|
||||
else { return }
|
||||
|
||||
let candidates = self.predictivePaceWarningCandidates(provider: provider, snapshot: snapshot)
|
||||
for candidate in candidates {
|
||||
guard let resetWindow = Self.predictivePaceWarningResetWindow(for: candidate.rateWindow) else {
|
||||
continue
|
||||
}
|
||||
let key = PredictivePaceWarningStateKey(
|
||||
provider: provider,
|
||||
accountDiscriminator: accountDiscriminator,
|
||||
window: candidate.window,
|
||||
resetWindow: resetWindow)
|
||||
PredictivePaceWarningNotificationLogic.reconcileSiblingWindowKeys(
|
||||
activeKey: key,
|
||||
notifiedKeys: &self.predictivePaceWarningNotifiedKeys)
|
||||
|
||||
guard PredictivePaceWarningNotificationLogic.recordObservation(
|
||||
key: key,
|
||||
pace: candidate.pace,
|
||||
notifiedKeys: &self.predictivePaceWarningNotifiedKeys)
|
||||
else { continue }
|
||||
|
||||
self.postPredictivePaceWarning(
|
||||
PredictivePaceWarningEvent(
|
||||
window: candidate.window,
|
||||
etaSeconds: candidate.pace.etaSeconds ?? 0,
|
||||
accountDisplayName: self.predictivePaceWarningAccountDisplayName(
|
||||
provider: provider,
|
||||
snapshot: snapshot)),
|
||||
provider: provider,
|
||||
now: snapshot.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private func predictivePaceWarningCandidates(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot) -> [(window: QuotaWarningWindow, rateWindow: RateWindow, pace: UsagePace)]
|
||||
{
|
||||
var candidates: [(window: QuotaWarningWindow, rateWindow: RateWindow, pace: UsagePace)] = []
|
||||
let now = snapshot.updatedAt
|
||||
|
||||
if let sessionWindow = self.predictivePaceWarningSessionWindow(provider: provider, snapshot: snapshot),
|
||||
!sessionWindow.isSyntheticPlaceholder,
|
||||
let sessionPace = UsagePaceText.sessionPace(provider: provider, window: sessionWindow, now: now)
|
||||
{
|
||||
candidates.append((window: .session, rateWindow: sessionWindow, pace: sessionPace))
|
||||
}
|
||||
|
||||
if let weeklyWindow = self.predictivePaceWarningWeeklyWindow(provider: provider, snapshot: snapshot),
|
||||
let weeklyPace = self.weeklyPace(provider: provider, window: weeklyWindow, now: now)
|
||||
{
|
||||
candidates.append((window: .weekly, rateWindow: weeklyWindow, pace: weeklyPace))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func predictivePaceWarningSessionWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? {
|
||||
if provider == .codex {
|
||||
return self.codexConsumerProjection(
|
||||
surface: .liveCard,
|
||||
snapshotOverride: snapshot,
|
||||
now: snapshot.updatedAt)
|
||||
.sourceRateWindow(for: .session)
|
||||
}
|
||||
return self.sessionQuotaWindow(provider: provider, snapshot: snapshot)?.window
|
||||
}
|
||||
|
||||
private func predictivePaceWarningWeeklyWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? {
|
||||
if provider == .codex {
|
||||
return self.codexConsumerProjection(
|
||||
surface: .liveCard,
|
||||
snapshotOverride: snapshot,
|
||||
now: snapshot.updatedAt)
|
||||
.sourceRateWindow(for: .weekly)
|
||||
}
|
||||
return snapshot.secondary
|
||||
}
|
||||
|
||||
private func predictivePaceWarningAccountDiscriminator(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot,
|
||||
accountDiscriminatorOverride: String? = nil) -> String?
|
||||
{
|
||||
if provider == .codex {
|
||||
return self.codexOwnershipContext(
|
||||
preferredEmail: snapshot.accountEmail(for: .codex),
|
||||
snapshot: snapshot)
|
||||
.canonicalKey
|
||||
}
|
||||
|
||||
if let accountDiscriminatorOverride = accountDiscriminatorOverride?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!accountDiscriminatorOverride.isEmpty
|
||||
{
|
||||
return accountDiscriminatorOverride
|
||||
}
|
||||
|
||||
guard let account = snapshot.accountEmail(for: provider)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased(),
|
||||
!account.isEmpty
|
||||
else { return nil }
|
||||
return "email:\(account)"
|
||||
}
|
||||
|
||||
static func predictivePaceWarningClaudeAccountDiscriminator(
|
||||
strategyKind: ProviderFetchKind,
|
||||
observation: ClaudeOAuthActiveAccountObservation,
|
||||
oauthHistoryOwnerIdentifier: String? = nil) -> String?
|
||||
{
|
||||
switch strategyKind {
|
||||
case .cli:
|
||||
return self.predictivePaceWarningClaudeActiveAccountDiscriminator(observation: observation)
|
||||
case .oauth:
|
||||
if let activeAccount = self.predictivePaceWarningClaudeActiveAccountDiscriminator(
|
||||
observation: observation)
|
||||
{
|
||||
return activeAccount
|
||||
}
|
||||
guard let owner = oauthHistoryOwnerIdentifier?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased(),
|
||||
!owner.isEmpty
|
||||
else { return nil }
|
||||
// OAuth usage has no email. Keep a credential-scoped fallback so predictive warnings still work
|
||||
// when Claude's active-account metadata is unavailable, without merging unrelated accounts.
|
||||
return "claude-oauth-owner:\(owner)"
|
||||
case .apiToken, .localProbe, .web, .webDashboard:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func predictivePaceWarningClaudeActiveAccountDiscriminator(
|
||||
observation: ClaudeOAuthActiveAccountObservation) -> String?
|
||||
{
|
||||
guard case let .stable(identity) = observation,
|
||||
let identity = identity?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!identity.isEmpty
|
||||
else { return nil }
|
||||
return "claude-account:\(identity)"
|
||||
}
|
||||
|
||||
static func predictivePaceWarningTokenAccountDiscriminator(_ account: ProviderTokenAccount?) -> String? {
|
||||
guard let account else { return nil }
|
||||
return "token-account:\(account.id.uuidString.lowercased())"
|
||||
}
|
||||
|
||||
private func predictivePaceWarningAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? {
|
||||
guard !self.settings.hidePersonalInfo else { return nil }
|
||||
let account = snapshot.accountEmail(for: provider)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let account, !account.isEmpty else { return nil }
|
||||
return account
|
||||
}
|
||||
|
||||
private static func predictivePaceWarningResetWindow(for window: RateWindow)
|
||||
-> PredictivePaceWarningResetWindow?
|
||||
{
|
||||
guard let resetsAt = window.resetsAt else { return nil }
|
||||
return PredictivePaceWarningResetWindow(
|
||||
windowMinutes: window.windowMinutes,
|
||||
resetsAt: resetsAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AboutPane: View {
|
||||
let updater: UpdaterProviding
|
||||
@State private var iconHover = false
|
||||
@AppStorage("autoUpdateEnabled") private var autoUpdateEnabled: Bool = true
|
||||
@AppStorage(UpdateChannel.userDefaultsKey)
|
||||
private var updateChannelRaw: String = UpdateChannel.defaultChannel.rawValue
|
||||
@State private var didLoadUpdaterState = false
|
||||
|
||||
private var versionString: String {
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–"
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
return build.map { "\(version) (\($0))" } ?? version
|
||||
}
|
||||
|
||||
private var buildTimestamp: String? {
|
||||
guard let raw = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String else { return nil }
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
guard let date = parser.date(from: raw) else { return raw }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
formatter.locale = .current
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
self.hero
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
if self.updater.isAvailable {
|
||||
Section {
|
||||
Toggle(L("check_updates_auto"), isOn: self.$autoUpdateEnabled)
|
||||
|
||||
Picker(selection: self.updateChannelBinding) {
|
||||
ForEach(UpdateChannel.allCases) { channel in
|
||||
Text(channel.displayName).tag(channel)
|
||||
}
|
||||
} label: {
|
||||
SettingsRowLabel(L("update_channel"), subtitle: self.updateChannel.description)
|
||||
}
|
||||
|
||||
LabeledContent(String(format: L("version_format"), self.versionString)) {
|
||||
Button(L("check_for_updates")) { self.updater.checkForUpdates(nil) }
|
||||
}
|
||||
} header: {
|
||||
Text(L("section_updates"))
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text(self.updater.unavailableReason ?? L("updates_unavailable"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
AboutLinkRow(
|
||||
icon: "chevron.left.slash.chevron.right",
|
||||
title: L("link_github"),
|
||||
url: "https://github.com/steipete/CodexBar")
|
||||
AboutLinkRow(icon: "globe", title: L("link_website"), url: "https://steipete.me")
|
||||
AboutLinkRow(icon: "bird", title: L("link_twitter"), url: "https://twitter.com/steipete")
|
||||
AboutLinkRow(icon: "envelope", title: L("link_email"), url: "mailto:peter@steipete.me")
|
||||
} header: {
|
||||
Text(L("section_links"))
|
||||
} footer: {
|
||||
Text(L("copyright"))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.toggleStyle(.switch)
|
||||
.scrollContentBackground(.hidden)
|
||||
.onAppear {
|
||||
guard !self.didLoadUpdaterState else { return }
|
||||
// Align Sparkle's flag with the persisted preference on first load.
|
||||
self.updater.automaticallyChecksForUpdates = self.autoUpdateEnabled
|
||||
self.updater.automaticallyDownloadsUpdates = self.autoUpdateEnabled
|
||||
self.didLoadUpdaterState = true
|
||||
}
|
||||
.onChange(of: self.autoUpdateEnabled) { _, newValue in
|
||||
self.updater.automaticallyChecksForUpdates = newValue
|
||||
self.updater.automaticallyDownloadsUpdates = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var hero: some View {
|
||||
VStack(spacing: 10) {
|
||||
if let image = NSApplication.shared.applicationIconImage {
|
||||
Button(action: self.openProjectHome) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.frame(width: 92, height: 92)
|
||||
.cornerRadius(16)
|
||||
.scaleEffect(self.iconHover ? 1.05 : 1.0)
|
||||
.shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusEffectDisabled()
|
||||
.onHover { hovering in
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
|
||||
self.iconHover = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("CodexBar")
|
||||
.font(.title3).bold()
|
||||
Text(String(format: L("version_format"), self.versionString))
|
||||
.foregroundStyle(.secondary)
|
||||
if let buildTimestamp {
|
||||
Text(String(format: L("built_format"), buildTimestamp))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(L("about_tagline"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private var updateChannel: UpdateChannel {
|
||||
UpdateChannel(rawValue: self.updateChannelRaw) ?? .stable
|
||||
}
|
||||
|
||||
private var updateChannelBinding: Binding<UpdateChannel> {
|
||||
Binding(
|
||||
get: { self.updateChannel },
|
||||
set: { newValue in
|
||||
self.updateChannelRaw = newValue.rawValue
|
||||
self.updater.checkForUpdates(nil)
|
||||
})
|
||||
}
|
||||
|
||||
private func openProjectHome() {
|
||||
guard let url = URL(string: "https://github.com/steipete/CodexBar") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct AboutLinkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let url: String
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if let url = URL(string: self.url) { NSWorkspace.shared.open(url) }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: self.icon)
|
||||
.frame(width: 18)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.title)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.hovering ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { self.hovering = $0 }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user