@sriinnu/runic 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +40 -29
  3. package/Scripts/package_app.sh +2 -2
  4. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +10 -1
  5. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +30 -2
  6. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +2 -5
  7. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +9 -33
  8. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +9 -33
  9. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +9 -33
  10. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +9 -33
  11. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +9 -33
  12. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +9 -33
  13. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +9 -33
  14. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +9 -33
  15. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +9 -33
  16. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +9 -33
  17. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +9 -33
  18. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +9 -33
  19. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +9 -33
  20. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +9 -33
  21. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +9 -33
  22. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +9 -33
  23. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +9 -33
  24. package/Sources/Runic/Core/Stores/SettingsStore.swift +10 -1
  25. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +9 -33
  26. package/Sources/Runic/Core/Stores/UsageStore.swift +50 -9
  27. package/Sources/Runic/Core/Stores/XAITokenStore.swift +9 -33
  28. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +9 -34
  29. package/Sources/Runic/Resources/provider-context-windows.json +20 -0
  30. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +9 -16
  31. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +192 -55
  32. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +12 -65
  33. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +71 -1
  34. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +11 -0
  35. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
  36. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +2 -0
  37. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
  38. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
  39. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
  40. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1 -0
  41. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +1 -0
  42. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +1 -4
  43. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +2 -6
  44. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +1 -3
  45. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +3 -9
  46. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +117 -19
  47. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +49 -3
  48. package/Sources/RunicCore/UsageLedger/LedgerCache.swift +158 -0
  49. package/appcast.xml +39 -0
  50. package/package.json +1 -1
  51. package/version.env +2 -2
@@ -3,7 +3,8 @@ import Charts
3
3
  import RunicCore
4
4
  import SwiftUI
5
5
 
6
- /// Overview showing all enabled providers at a glance — usage bars, today's tokens, and a combined chart.
6
+ /// Overview dashboard showing all enabled providers at a glance.
7
+ /// Inspired by Apple Activity app rings, Awwwards dashboard layouts, and Tokex stat cards.
7
8
  @MainActor
8
9
  struct OverviewMenuView: View {
9
10
  struct ProviderSummary: Identifiable {
@@ -15,6 +16,8 @@ struct OverviewMenuView: View {
15
16
  let todayTokens: Int
16
17
  let brandColor: Color
17
18
  let resetDescription: String?
19
+ let windowLabel: String? // e.g. "5h" or "Weekly"
20
+ let topModelContext: String? // e.g. "200K ctx"
18
21
  }
19
22
 
20
23
  struct DailyPoint: Identifiable {
@@ -22,82 +25,95 @@ struct OverviewMenuView: View {
22
25
  let date: Date
23
26
  let tokens: Int
24
27
  let provider: String
28
+ let color: Color
25
29
  }
26
30
 
27
31
  let summaries: [ProviderSummary]
28
32
  let chartPoints: [DailyPoint]
29
33
  let totalTodayTokens: Int
34
+ let totalProviders: Int
30
35
  let width: CGFloat
31
36
 
32
37
  var body: some View {
33
38
  VStack(alignment: .leading, spacing: RunicSpacing.sm) {
34
- // Header
35
- HStack(alignment: .firstTextBaseline) {
36
- Text("Overview")
37
- .font(.system(.title3, design: .rounded))
38
- .fontWeight(.bold)
39
+ // MARK: - Hero header
40
+ HStack(alignment: .center, spacing: RunicSpacing.xs) {
41
+ // Ring indicator showing overall usage
42
+ ZStack {
43
+ Circle()
44
+ .stroke(Color(nsColor: .separatorColor).opacity(0.15), lineWidth: 3)
45
+ Circle()
46
+ .trim(from: 0, to: min(1, self.averageUsedPercent / 100))
47
+ .stroke(
48
+ AngularGradient(
49
+ colors: [RunicColors.chartColor(at: 4), RunicColors.chartColor(at: 0)],
50
+ center: .center),
51
+ style: StrokeStyle(lineWidth: 3, lineCap: .round))
52
+ .rotationEffect(.degrees(-90))
53
+ }
54
+ .frame(width: 32, height: 32)
55
+
56
+ VStack(alignment: .leading, spacing: 0) {
57
+ Text(UsageFormatter.tokenCountString(self.totalTodayTokens))
58
+ .font(.system(size: 22, weight: .bold, design: .rounded))
59
+ Text("\(self.summaries.count) of \(self.totalProviders) active")
60
+ .font(.system(.caption2, design: .rounded))
61
+ .foregroundStyle(.tertiary)
62
+ }
63
+
39
64
  Spacer()
40
- if self.totalTodayTokens > 0 {
41
- Text("\(UsageFormatter.tokenCountString(self.totalTodayTokens)) today")
65
+
66
+ VStack(alignment: .trailing, spacing: 2) {
67
+ Text("today")
68
+ .font(.system(.caption2, design: .rounded))
69
+ .foregroundStyle(.tertiary)
70
+ Text("\(Int(self.averageUsedPercent))% avg")
42
71
  .font(.system(.caption, design: .rounded))
72
+ .fontWeight(.medium)
43
73
  .foregroundStyle(.secondary)
44
74
  }
45
75
  }
46
76
 
47
- // Provider rows
77
+ Divider().opacity(0.5)
78
+
79
+ // MARK: - Provider cards
48
80
  if self.summaries.isEmpty {
49
- Text("No providers enabled.")
81
+ Text("No active providers.")
50
82
  .font(.system(.footnote, design: .rounded))
51
83
  .foregroundStyle(.secondary)
84
+ .frame(maxWidth: .infinity, alignment: .center)
85
+ .padding(.vertical, RunicSpacing.md)
52
86
  } else {
53
- VStack(spacing: RunicSpacing.xs) {
87
+ VStack(spacing: RunicSpacing.compact) {
54
88
  ForEach(self.summaries) { summary in
55
- HStack(spacing: RunicSpacing.xs) {
56
- // Provider icon
57
- if let icon = summary.icon {
58
- Image(nsImage: icon)
59
- .resizable()
60
- .aspectRatio(contentMode: .fit)
61
- .frame(width: 16, height: 16)
62
- }
63
-
64
- // Name
65
- Text(summary.name)
66
- .font(.system(.caption, design: .rounded))
67
- .fontWeight(.medium)
68
- .frame(width: 60, alignment: .leading)
69
- .lineLimit(1)
70
-
71
- // Mini progress bar
72
- GeometryReader { geo in
73
- ZStack(alignment: .leading) {
74
- Capsule()
75
- .fill(Color(nsColor: .separatorColor).opacity(0.2))
76
- Capsule()
77
- .fill(summary.brandColor)
78
- .frame(width: max(0, geo.size.width * min(1, summary.usedPercent / 100)))
79
- }
80
- }
81
- .frame(height: 6)
82
-
83
- // Percentage
84
- Text("\(Int(summary.usedPercent))%")
85
- .font(.system(size: 10, weight: .medium, design: .rounded))
86
- .foregroundStyle(.secondary)
87
- .frame(width: 30, alignment: .trailing)
88
- }
89
+ ProviderRow(summary: summary)
89
90
  }
90
91
  }
91
92
  }
92
93
 
93
- // Combined 7-day chart
94
+ // MARK: - Combined 7-day chart
94
95
  if !self.chartPoints.isEmpty {
95
- Divider()
96
- .padding(.vertical, RunicSpacing.xxxs)
96
+ Divider().opacity(0.5)
97
97
 
98
- Text("Last 7 days")
99
- .font(.system(.caption2, design: .rounded))
100
- .foregroundStyle(.tertiary)
98
+ HStack {
99
+ Text("7-day activity")
100
+ .font(.system(.caption2, design: .rounded))
101
+ .foregroundStyle(.tertiary)
102
+ Spacer()
103
+ // Mini legend dots
104
+ HStack(spacing: RunicSpacing.xxs) {
105
+ ForEach(self.summaries.prefix(4)) { s in
106
+ Circle()
107
+ .fill(s.brandColor)
108
+ .frame(width: 5, height: 5)
109
+ }
110
+ if self.summaries.count > 4 {
111
+ Text("+\(self.summaries.count - 4)")
112
+ .font(.system(size: 8, design: .rounded))
113
+ .foregroundStyle(.quaternary)
114
+ }
115
+ }
116
+ }
101
117
 
102
118
  Chart {
103
119
  ForEach(self.chartPoints) { point in
@@ -105,12 +121,15 @@ struct OverviewMenuView: View {
105
121
  x: .value("Date", point.date, unit: .day),
106
122
  y: .value("Tokens", point.tokens))
107
123
  .foregroundStyle(by: .value("Provider", point.provider))
124
+ .cornerRadius(2)
108
125
  }
109
126
  }
110
127
  .chartForegroundStyleScale(range: self.chartColors)
111
128
  .chartLegend(.hidden)
112
129
  .chartYAxis {
113
130
  AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { value in
131
+ AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3, dash: [3, 3]))
132
+ .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.3))
114
133
  AxisValueLabel {
115
134
  if let tokens = value.as(Int.self) {
116
135
  Text(UsageFormatter.tokenCountString(tokens))
@@ -121,13 +140,13 @@ struct OverviewMenuView: View {
121
140
  }
122
141
  }
123
142
  .chartXAxis {
124
- AxisMarks(values: .automatic(desiredCount: 5)) { _ in
125
- AxisValueLabel(format: .dateTime.weekday(.short))
126
- .font(.system(size: 8, design: .rounded))
143
+ AxisMarks(values: .automatic(desiredCount: 7)) { _ in
144
+ AxisValueLabel(format: .dateTime.weekday(.narrow))
145
+ .font(.system(size: 8, weight: .medium, design: .rounded))
127
146
  .foregroundStyle(Color(nsColor: .tertiaryLabelColor))
128
147
  }
129
148
  }
130
- .frame(height: 70)
149
+ .frame(height: 80)
131
150
  }
132
151
  }
133
152
  .padding(.horizontal, MenuCardMetrics.horizontalPadding)
@@ -135,7 +154,125 @@ struct OverviewMenuView: View {
135
154
  .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading)
136
155
  }
137
156
 
157
+ private var averageUsedPercent: Double {
158
+ let active = self.summaries.filter { $0.usedPercent > 0 }
159
+ guard !active.isEmpty else { return 0 }
160
+ return active.reduce(0) { $0 + $1.usedPercent } / Double(active.count)
161
+ }
162
+
138
163
  private var chartColors: [Color] {
139
164
  self.summaries.map(\.brandColor)
140
165
  }
141
166
  }
167
+
168
+ // MARK: - Provider row
169
+
170
+ private struct ProviderRow: View {
171
+ let summary: OverviewMenuView.ProviderSummary
172
+
173
+ var body: some View {
174
+ HStack(spacing: RunicSpacing.xs) {
175
+ // Icon with brand tint
176
+ if let icon = self.summary.icon {
177
+ Image(nsImage: icon)
178
+ .resizable()
179
+ .aspectRatio(contentMode: .fit)
180
+ .frame(width: 14, height: 14)
181
+ }
182
+
183
+ // Name
184
+ Text(self.summary.name)
185
+ .font(.system(.caption, design: .rounded))
186
+ .fontWeight(.medium)
187
+ .frame(width: 58, alignment: .leading)
188
+ .lineLimit(1)
189
+
190
+ // Progress bar with gradient fill
191
+ GeometryReader { geo in
192
+ let fillWidth = max(0, geo.size.width * min(1, self.summary.usedPercent / 100))
193
+ ZStack(alignment: .leading) {
194
+ // Track
195
+ Capsule()
196
+ .fill(Color(nsColor: .separatorColor).opacity(0.12))
197
+
198
+ // Fill with gradient
199
+ Capsule()
200
+ .fill(
201
+ LinearGradient(
202
+ colors: [
203
+ self.summary.brandColor.opacity(0.9),
204
+ self.summary.brandColor,
205
+ ],
206
+ startPoint: .leading,
207
+ endPoint: .trailing))
208
+ .frame(width: fillWidth)
209
+
210
+ // Gloss overlay on fill
211
+ Capsule()
212
+ .fill(
213
+ LinearGradient(
214
+ stops: [
215
+ .init(color: .white.opacity(0.2), location: 0),
216
+ .init(color: .clear, location: 0.5),
217
+ ],
218
+ startPoint: .top,
219
+ endPoint: .bottom))
220
+ .frame(width: fillWidth)
221
+ }
222
+ }
223
+ .frame(height: 7)
224
+
225
+ // Percentage
226
+ Text("\(Int(self.summary.usedPercent))%")
227
+ .font(.system(size: 9, weight: .semibold, design: .rounded))
228
+ .foregroundStyle(self.summary.usedPercent > 80 ? .primary : .secondary)
229
+ .frame(width: 28, alignment: .trailing)
230
+
231
+ // Today's tokens (if any)
232
+ if self.summary.todayTokens > 0 {
233
+ Text(UsageFormatter.tokenCountString(self.summary.todayTokens))
234
+ .font(.system(size: 8, design: .rounded))
235
+ .foregroundStyle(.tertiary)
236
+ .frame(width: 32, alignment: .trailing)
237
+ }
238
+ }
239
+
240
+ // Second line: window + reset + context
241
+ let hasSecondLine = self.summary.windowLabel != nil ||
242
+ self.summary.resetDescription != nil ||
243
+ self.summary.topModelContext != nil
244
+
245
+ if hasSecondLine {
246
+ HStack(spacing: RunicSpacing.xxs) {
247
+ // Spacer for icon + name width
248
+ Color.clear.frame(width: 14 + 58 + RunicSpacing.xs * 2, height: 0)
249
+
250
+ if let window = self.summary.windowLabel {
251
+ InfoPill(text: window)
252
+ }
253
+ if let reset = self.summary.resetDescription {
254
+ InfoPill(text: reset)
255
+ }
256
+ if let ctx = self.summary.topModelContext {
257
+ InfoPill(text: ctx)
258
+ }
259
+ Spacer()
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ private struct InfoPill: View {
266
+ let text: String
267
+
268
+ var body: some View {
269
+ Text(self.text)
270
+ .font(.system(size: 8, weight: .medium, design: .rounded))
271
+ .foregroundStyle(.tertiary)
272
+ .padding(.horizontal, 4)
273
+ .padding(.vertical, 1)
274
+ .background(
275
+ Capsule(style: .continuous)
276
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(RunicColors.Opacity.subtle)))
277
+ }
278
+ }
@@ -5,28 +5,23 @@ import SwiftUI
5
5
  @MainActor
6
6
  struct UsageTimelineChartMenuView: View {
7
7
  enum TimeRange: String, CaseIterable {
8
- case oneHour = "1h"
9
- case sixHours = "6h"
10
- case oneDay = "1d"
8
+ case threeDays = "3d"
11
9
  case sevenDays = "7d"
12
10
  case thirtyDays = "30d"
11
+ case quarter = "90d"
12
+ case year = "1y"
13
13
 
14
14
  var cutoffInterval: TimeInterval {
15
15
  switch self {
16
- case .oneHour: return -3600
17
- case .sixHours: return -21600
18
- case .oneDay: return -86400
16
+ case .threeDays: return -259200
19
17
  case .sevenDays: return -604800
20
18
  case .thirtyDays: return -2592000
19
+ case .quarter: return -7776000
20
+ case .year: return -31536000
21
21
  }
22
22
  }
23
23
 
24
- var usesHourlyData: Bool {
25
- switch self {
26
- case .oneHour, .sixHours, .oneDay: return true
27
- default: return false
28
- }
29
- }
24
+ var usesHourlyData: Bool { false }
30
25
 
31
26
  var label: String { self.rawValue }
32
27
  }
@@ -48,7 +43,7 @@ struct UsageTimelineChartMenuView: View {
48
43
  private let dailySummaries: [UsageLedgerDailySummary]
49
44
  private let hourlySummaries: [UsageLedgerHourlySummary]
50
45
  private let width: CGFloat
51
- @State private var selectedTimeRange: TimeRange = .oneDay
46
+ @State private var selectedTimeRange: TimeRange = .sevenDays
52
47
  @State private var selectedKey: String?
53
48
 
54
49
  init(
@@ -65,9 +60,7 @@ struct UsageTimelineChartMenuView: View {
65
60
  private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1)
66
61
 
67
62
  var body: some View {
68
- let model = self.selectedTimeRange.usesHourlyData
69
- ? Self.makeHourlyModel(from: self.hourlySummaries, timeRange: self.selectedTimeRange)
70
- : Self.makeDailyModel(from: self.dailySummaries, timeRange: self.selectedTimeRange)
63
+ let model = Self.makeDailyModel(from: self.dailySummaries, timeRange: self.selectedTimeRange)
71
64
 
72
65
  VStack(alignment: .leading, spacing: RunicSpacing.xs) {
73
66
  // Header on its own line
@@ -262,9 +255,11 @@ struct UsageTimelineChartMenuView: View {
262
255
  }
263
256
 
264
257
  let desiredAxisCount: Int = switch timeRange {
265
- case .oneHour, .sixHours, .oneDay: 4
258
+ case .threeDays: 3
266
259
  case .sevenDays: 5
267
260
  case .thirtyDays: 6
261
+ case .quarter: 6
262
+ case .year: 6
268
263
  }
269
264
 
270
265
  return Model(
@@ -281,54 +276,6 @@ struct UsageTimelineChartMenuView: View {
281
276
  isHourly: false)
282
277
  }
283
278
 
284
- // MARK: - Hourly model
285
-
286
- private static func makeHourlyModel(from summaries: [UsageLedgerHourlySummary], timeRange: TimeRange) -> Model {
287
- let now = Date()
288
- let cutoffDate = now.addingTimeInterval(timeRange.cutoffInterval)
289
- let filtered = summaries.filter { $0.hourStart >= cutoffDate }
290
- let sorted = filtered.sorted { $0.hourStart < $1.hourStart }
291
-
292
- var points: [Point] = []
293
- var pointsByKey: [String: Point] = [:]
294
- var dateKeys: [(key: String, date: Date)] = []
295
- var peak: Point?
296
- var totalTokens = 0
297
-
298
- for summary in sorted {
299
- let tokens = summary.totals.totalTokens
300
- totalTokens += tokens
301
- let point = Point(date: summary.hourStart, totalTokens: tokens, key: summary.hourKey)
302
- points.append(point)
303
- pointsByKey[summary.hourKey] = point
304
- dateKeys.append((summary.hourKey, summary.hourStart))
305
-
306
- if peak == nil || tokens > (peak?.totalTokens ?? 0) {
307
- peak = point
308
- }
309
- }
310
-
311
- let desiredAxisCount: Int = switch timeRange {
312
- case .oneHour: 4
313
- case .sixHours: 6
314
- case .oneDay: 8
315
- default: 6
316
- }
317
-
318
- return Model(
319
- points: points,
320
- pointsByKey: pointsByKey,
321
- dateKeys: dateKeys,
322
- peakPoint: peak,
323
- totalTokens: totalTokens,
324
- averagePerPeriod: points.isEmpty ? 0 : totalTokens / max(1, points.count),
325
- peakTokens: peak?.totalTokens ?? 0,
326
- totalCostUSD: nil,
327
- desiredAxisCount: desiredAxisCount,
328
- xAxisFormat: .dateTime.hour(.defaultDigits(amPM: .narrow)),
329
- isHourly: true)
330
- }
331
-
332
279
  // MARK: - Interaction
333
280
 
334
281
  private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? {
@@ -1,5 +1,6 @@
1
1
  import AppKit
2
2
  import RunicCore
3
+ import Security
3
4
  import SwiftUI
4
5
 
5
6
  @MainActor
@@ -9,7 +10,7 @@ struct IntegrationsPane: View {
9
10
 
10
11
  @AppStorage("vaayuAPIServerEnabled") private var vaayuAPIServerEnabled = false
11
12
  @AppStorage("vaayuAPIServerPort") private var vaayuAPIServerPort = 3000
12
- @AppStorage("vaayuAPIKey") private var vaayuAPIKey = ""
13
+ @State private var vaayuAPIKey = ""
13
14
  @AppStorage("defaultWebhookURL") private var defaultWebhookURL = ""
14
15
  @AppStorage("webhookFormat") private var webhookFormat = "slack"
15
16
  @AppStorage("githubIntegrationEnabled") private var githubIntegrationEnabled = false
@@ -319,6 +320,22 @@ struct IntegrationsPane: View {
319
320
  }
320
321
  .onAppear {
321
322
  self.loadMCPServers()
323
+ self.vaayuAPIKey = VaayuKeychainHelper.load() ?? ""
324
+ // Migrate any existing value out of UserDefaults
325
+ let defaults = UserDefaults.standard
326
+ if let legacyKey = defaults.string(forKey: "vaayuAPIKey"), !legacyKey.isEmpty {
327
+ if VaayuKeychainHelper.save(legacyKey) {
328
+ self.vaayuAPIKey = legacyKey
329
+ defaults.removeObject(forKey: "vaayuAPIKey")
330
+ }
331
+ }
332
+ }
333
+ .onChange(of: self.vaayuAPIKey) { _, newValue in
334
+ if newValue.isEmpty {
335
+ VaayuKeychainHelper.delete()
336
+ } else {
337
+ VaayuKeychainHelper.save(newValue)
338
+ }
322
339
  }
323
340
  .sheet(isPresented: self.$showingAddServerSheet) {
324
341
  AddMCPServerSheet(
@@ -495,3 +512,56 @@ private struct AddMCPServerSheet: View {
495
512
  .onAppear { self.isNameFocused = true }
496
513
  }
497
514
  }
515
+
516
+ // MARK: - Vaayu API Key Keychain Helper
517
+
518
+ private enum VaayuKeychainHelper {
519
+ private static let service = "com.sriinnu.athena.Runic"
520
+ private static let account = "vaayu-api-key"
521
+
522
+ static func load() -> String? {
523
+ var result: CFTypeRef?
524
+ let query: [String: Any] = [
525
+ kSecClass as String: kSecClassGenericPassword,
526
+ kSecAttrService as String: service,
527
+ kSecAttrAccount as String: account,
528
+ kSecMatchLimit as String: kSecMatchLimitOne,
529
+ kSecReturnData as String: true,
530
+ kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail,
531
+ ]
532
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
533
+ guard status == errSecSuccess,
534
+ let data = result as? Data,
535
+ let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
536
+ !token.isEmpty
537
+ else {
538
+ return nil
539
+ }
540
+ return token
541
+ }
542
+
543
+ @discardableResult
544
+ static func save(_ value: String) -> Bool {
545
+ delete()
546
+ let data = Data(value.utf8)
547
+ let query: [String: Any] = [
548
+ kSecClass as String: kSecClassGenericPassword,
549
+ kSecAttrService as String: service,
550
+ kSecAttrAccount as String: account,
551
+ kSecValueData as String: data,
552
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
553
+ ]
554
+ return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
555
+ }
556
+
557
+ @discardableResult
558
+ static func delete() -> Bool {
559
+ let query: [String: Any] = [
560
+ kSecClass as String: kSecClassGenericPassword,
561
+ kSecAttrService as String: service,
562
+ kSecAttrAccount as String: account,
563
+ ]
564
+ let status = SecItemDelete(query as CFDictionary)
565
+ return status == errSecSuccess || status == errSecItemNotFound
566
+ }
567
+ }
@@ -144,6 +144,10 @@ public actor GenericProviderFetcher {
144
144
  throw FetchError.invalidURL(urlString)
145
145
  }
146
146
 
147
+ guard url.scheme?.lowercased() == "https" else {
148
+ throw FetchError.insecureURL(urlString)
149
+ }
150
+
147
151
  return url
148
152
  }
149
153
 
@@ -562,6 +566,7 @@ public enum FetchError: LocalizedError, Sendable {
562
566
  case missingToken(String)
563
567
  case invalidDateRange
564
568
  case extractionFailed(String)
569
+ case insecureURL(String)
565
570
 
566
571
  public var errorDescription: String? {
567
572
  switch self {
@@ -583,6 +588,8 @@ public enum FetchError: LocalizedError, Sendable {
583
588
  return "Failed to calculate date range for URL template"
584
589
  case .extractionFailed(let field):
585
590
  return "Failed to extract field '\(field)' from response"
591
+ case .insecureURL(let url):
592
+ return "Only HTTPS URLs are allowed. Got: \(url)"
586
593
  }
587
594
  }
588
595
 
@@ -596,6 +603,8 @@ public enum FetchError: LocalizedError, Sendable {
596
603
  return "The response is not valid JSON or has unexpected structure"
597
604
  case .extractionFailed(let field):
598
605
  return "The field '\(field)' was not found in the response or has wrong type"
606
+ case .insecureURL:
607
+ return "The URL scheme is not HTTPS"
599
608
  default:
600
609
  return nil
601
610
  }
@@ -615,6 +624,8 @@ public enum FetchError: LocalizedError, Sendable {
615
624
  return "You have been rate limited. Try again later."
616
625
  case .invalidJSON:
617
626
  return "Check the API endpoint URL and response mapping configuration"
627
+ case .insecureURL:
628
+ return "Change the provider URL to use HTTPS (e.g., https://api.example.com)"
618
629
  default:
619
630
  return nil
620
631
  }
@@ -42,17 +42,17 @@ struct OSLogLogHandler: LogHandler {
42
42
 
43
43
  switch level {
44
44
  case .trace:
45
- self.logger.debug("\(msg, privacy: .public)")
45
+ self.logger.debug("\(msg, privacy: .auto)")
46
46
  case .debug:
47
- self.logger.debug("\(msg, privacy: .public)")
47
+ self.logger.debug("\(msg, privacy: .auto)")
48
48
  case .info, .notice:
49
- self.logger.info("\(msg, privacy: .public)")
49
+ self.logger.info("\(msg, privacy: .auto)")
50
50
  case .warning:
51
- self.logger.warning("\(msg, privacy: .public)")
51
+ self.logger.warning("\(msg, privacy: .auto)")
52
52
  case .error:
53
- self.logger.error("\(msg, privacy: .public)")
53
+ self.logger.error("\(msg, privacy: .auto)")
54
54
  case .critical:
55
- self.logger.fault("\(msg, privacy: .public)")
55
+ self.logger.fault("\(msg, privacy: .auto)")
56
56
  }
57
57
  }
58
58
 
@@ -94,6 +94,8 @@ struct AzureUsageFetcher {
94
94
  if !normalized.contains("://") {
95
95
  normalized = "https://\(normalized)"
96
96
  }
97
+ // Enforce HTTPS — reject http:// to prevent cleartext token leakage.
98
+ guard normalized.lowercased().hasPrefix("https://") else { return nil }
97
99
  guard let baseURL = URL(string: normalized),
98
100
  var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
99
101
  else {
@@ -133,6 +133,7 @@ public enum CodexOAuthCredentialsStore {
133
133
  let directory = url.deletingLastPathComponent()
134
134
  try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
135
135
  try data.write(to: url, options: .atomic)
136
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
136
137
  }
137
138
 
138
139
  private static func parseLastRefresh(from raw: Any?) -> Date? {
@@ -192,12 +192,23 @@ public enum CodexOAuthUsageFetcher {
192
192
  self.resolveUsageURL(env: ProcessInfo.processInfo.environment, configContents: nil)
193
193
  }
194
194
 
195
+ private static let allowedHostSuffixes = ["openai.com", "chatgpt.com"]
196
+
195
197
  private static func resolveUsageURL(env: [String: String], configContents: String?) -> URL {
196
198
  let baseURL = self.resolveChatGPTBaseURL(env: env, configContents: configContents)
197
199
  let normalized = self.normalizeChatGPTBaseURL(baseURL)
198
200
  let path = normalized.contains("/backend-api") ? Self.chatGPTUsagePath : Self.codexUsagePath
199
201
  let full = normalized + path
200
- return URL(string: full) ?? URL(string: Self.defaultChatGPTBaseURL + Self.chatGPTUsagePath)!
202
+ guard let url = URL(string: full), self.isAllowedHost(url) else {
203
+ return URL(string: Self.defaultChatGPTBaseURL + Self.chatGPTUsagePath)!
204
+ }
205
+ return url
206
+ }
207
+
208
+ private static func isAllowedHost(_ url: URL) -> Bool {
209
+ guard url.scheme?.lowercased() == "https" else { return false }
210
+ guard let host = url.host?.lowercased() else { return false }
211
+ return allowedHostSuffixes.contains { host == $0 || host.hasSuffix("." + $0) }
201
212
  }
202
213
 
203
214
  private static func resolveChatGPTBaseURL(env: [String: String], configContents: String?) -> String {
@@ -375,6 +375,7 @@ public actor CursorSessionStore {
375
375
  return
376
376
  }
377
377
  try? data.write(to: self.fileURL)
378
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: self.fileURL.path)
378
379
  }
379
380
 
380
381
  private func loadFromDisk() {
@@ -464,6 +464,7 @@ public actor FactorySessionStore {
464
464
  return
465
465
  }
466
466
  try? data.write(to: self.fileURL)
467
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: self.fileURL.path)
467
468
  }
468
469
 
469
470
  private func loadFromDisk() {