@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.
- package/LICENSE +1 -1
- package/README.md +40 -29
- package/Scripts/package_app.sh +2 -2
- package/Sources/Runic/Controllers/StatusItemController+Actions.swift +10 -1
- package/Sources/Runic/Controllers/StatusItemController+Menu.swift +30 -2
- package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +2 -5
- package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/CohereTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/GroqTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/KimiTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MistralTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/QwenTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/SettingsStore.swift +10 -1
- package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/UsageStore.swift +50 -9
- package/Sources/Runic/Core/Stores/XAITokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +9 -34
- package/Sources/Runic/Resources/provider-context-windows.json +20 -0
- package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +9 -16
- package/Sources/Runic/Views/Menu/OverviewMenuView.swift +192 -55
- package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +12 -65
- package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +71 -1
- package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +11 -0
- package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
- package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +2 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
- package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +1 -4
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +2 -6
- package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +1 -3
- package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +3 -9
- package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +117 -19
- package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +49 -3
- package/Sources/RunicCore/UsageLedger/LedgerCache.swift +158 -0
- package/appcast.xml +39 -0
- package/package.json +1 -1
- 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
|
|
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
|
-
//
|
|
35
|
-
HStack(alignment: .
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
77
|
+
Divider().opacity(0.5)
|
|
78
|
+
|
|
79
|
+
// MARK: - Provider cards
|
|
48
80
|
if self.summaries.isEmpty {
|
|
49
|
-
Text("No providers
|
|
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.
|
|
87
|
+
VStack(spacing: RunicSpacing.compact) {
|
|
54
88
|
ForEach(self.summaries) { summary in
|
|
55
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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:
|
|
125
|
-
AxisValueLabel(format: .dateTime.weekday(.
|
|
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:
|
|
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
|
|
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 .
|
|
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 = .
|
|
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
|
|
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 .
|
|
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
|
-
@
|
|
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: .
|
|
45
|
+
self.logger.debug("\(msg, privacy: .auto)")
|
|
46
46
|
case .debug:
|
|
47
|
-
self.logger.debug("\(msg, privacy: .
|
|
47
|
+
self.logger.debug("\(msg, privacy: .auto)")
|
|
48
48
|
case .info, .notice:
|
|
49
|
-
self.logger.info("\(msg, privacy: .
|
|
49
|
+
self.logger.info("\(msg, privacy: .auto)")
|
|
50
50
|
case .warning:
|
|
51
|
-
self.logger.warning("\(msg, privacy: .
|
|
51
|
+
self.logger.warning("\(msg, privacy: .auto)")
|
|
52
52
|
case .error:
|
|
53
|
-
self.logger.error("\(msg, privacy: .
|
|
53
|
+
self.logger.error("\(msg, privacy: .auto)")
|
|
54
54
|
case .critical:
|
|
55
|
-
self.logger.fault("\(msg, privacy: .
|
|
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
|
-
|
|
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 {
|