@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
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -24,73 +24,84 @@ Runic sits in your menubar and shows how much of your AI subscription you've use
|
|
|
24
24
|
|
|
25
25
|
| | | |
|
|
26
26
|
|---|---|---|
|
|
27
|
-
| Claude | Codex | Cursor |
|
|
28
|
-
| Gemini | Copilot | z.ai |
|
|
29
|
-
| OpenRouter | Groq | DeepSeek |
|
|
30
|
-
| Fireworks | Mistral | Perplexity |
|
|
31
|
-
| Kimi | Together | Cohere |
|
|
32
|
-
| xAI | Cerebras | SambaNova |
|
|
27
|
+
| Claude (1M ctx) | Codex (400K ctx) | Cursor (128K ctx) |
|
|
28
|
+
| Gemini (1M ctx) | Copilot (128K ctx) | z.ai (205K ctx) |
|
|
29
|
+
| OpenRouter | Groq (128K ctx) | DeepSeek (64K ctx) |
|
|
30
|
+
| Fireworks (128K ctx) | Mistral (128K ctx) | Perplexity (128K ctx) |
|
|
31
|
+
| Kimi (128K ctx) | Together (128K ctx) | Cohere (128K ctx) |
|
|
32
|
+
| xAI (128K ctx) | Cerebras (128K ctx) | SambaNova (128K ctx) |
|
|
33
33
|
| Azure OpenAI | Bedrock | Vertex AI |
|
|
34
|
-
| Qwen | MiniMax | Auggie |
|
|
34
|
+
| Qwen (128K ctx) | MiniMax | Auggie |
|
|
35
35
|
| Antigravity | Factory (Droid) | |
|
|
36
36
|
|
|
37
|
+
Context windows are configurable via `Resources/provider-context-windows.json`.
|
|
38
|
+
|
|
37
39
|
## Features
|
|
38
40
|
|
|
39
|
-
**
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
|
|
41
|
+
**Overview dashboard**
|
|
42
|
+
- All providers at a glance with brand icons and progress bars
|
|
43
|
+
- Activity ring showing average usage across providers
|
|
44
|
+
- Context window, reset countdown, and usage window per provider
|
|
45
|
+
- Combined 7-day stacked bar chart
|
|
46
|
+
|
|
47
|
+
**Per-provider view**
|
|
48
|
+
- Hero stat with provider icon, today's token count and cost
|
|
49
|
+
- Inline line chart with 1h / 6h / 1d / 7d / 30d range picker
|
|
50
|
+
- Glassmorphism stat cards (Peak Hour, This Week) with sparklines
|
|
51
|
+
- Usage progress bars with sheen animation and glow effects
|
|
45
52
|
- Live "Updated Xs ago" timestamp
|
|
46
53
|
|
|
47
|
-
**Charts**
|
|
48
|
-
- Usage timeline (area + line)
|
|
49
|
-
- Today by hour (24-bar)
|
|
50
|
-
- Last 7 days (weekly
|
|
54
|
+
**Charts** (submenus)
|
|
55
|
+
- Usage timeline (area + line, Catmull-Rom interpolated)
|
|
56
|
+
- Today by hour (24-bar chart with peak highlight)
|
|
57
|
+
- Last 7 days (weekly bar chart)
|
|
51
58
|
- Subscription utilization (Daily / Weekly / Monthly)
|
|
52
|
-
- Usage window comparison (session vs weekly)
|
|
53
|
-
- Model breakdown (donut)
|
|
54
|
-
- Project breakdown (bar)
|
|
59
|
+
- Usage window comparison (dual-line session vs weekly)
|
|
60
|
+
- Model breakdown (donut chart)
|
|
61
|
+
- Project breakdown (horizontal bar chart)
|
|
55
62
|
|
|
56
63
|
**Analytics**
|
|
57
|
-
- Token usage (input, output, cache)
|
|
64
|
+
- Token usage tracking (input, output, cache)
|
|
58
65
|
- Cost estimation with per-model pricing
|
|
59
|
-
- Spend forecasting
|
|
66
|
+
- Spend forecasting with budget breach detection
|
|
60
67
|
- Project and model attribution
|
|
68
|
+
- Anomaly detection
|
|
61
69
|
|
|
62
70
|
**Export & Notifications**
|
|
63
71
|
- Export as CSV or JSON
|
|
64
72
|
- Budget breach alerts via macOS notifications
|
|
65
|
-
- macOS widgets
|
|
73
|
+
- macOS widgets (usage, history, compact, switcher)
|
|
66
74
|
- CLI tool (`RunicCLI`)
|
|
67
75
|
|
|
68
76
|
**Design**
|
|
69
77
|
- Dark / Light / System theme
|
|
70
78
|
- Liquid UI with glass materials and animated progress bars
|
|
71
|
-
-
|
|
79
|
+
- Staggered entrance animations and glass shimmer effects
|
|
80
|
+
- SF Rounded typography with design tokens
|
|
72
81
|
- VoiceOver accessible
|
|
73
82
|
- Sparkle auto-updates
|
|
74
83
|
|
|
75
84
|
## Install
|
|
76
85
|
|
|
86
|
+
**Download** the latest release from [GitHub Releases](https://github.com/sriinnu/Runic/releases/latest), unzip, and drag `Runic.app` to Applications. Signed and notarized — no Gatekeeper warnings.
|
|
87
|
+
|
|
88
|
+
**Or build from source:**
|
|
89
|
+
|
|
77
90
|
```bash
|
|
78
91
|
git clone https://github.com/sriinnu/Runic.git
|
|
79
92
|
cd Runic
|
|
80
93
|
./Scripts/compile_and_run.sh
|
|
81
94
|
```
|
|
82
95
|
|
|
83
|
-
Builds the app, signs it, installs to `/Applications`, and launches. The infinity icon appears in your menubar.
|
|
84
|
-
|
|
85
96
|
## Configure
|
|
86
97
|
|
|
87
98
|
Open **Preferences** from the menubar. Each provider has its own settings:
|
|
88
99
|
|
|
89
|
-
- **API-based** (Groq, Mistral, z.ai, etc.): Paste your API key
|
|
90
|
-
- **CLI-based** (Claude, Codex): Detected automatically
|
|
100
|
+
- **API-based** (Groq, Mistral, z.ai, etc.): Paste your API key — provider auto-enables
|
|
101
|
+
- **CLI-based** (Claude, Codex): Detected automatically from local CLI
|
|
91
102
|
- **Cloud** (Bedrock, Vertex AI): Set environment variables
|
|
92
103
|
|
|
93
|
-
All tokens stored in macOS Keychain
|
|
104
|
+
All tokens stored in macOS Keychain with no password prompts.
|
|
94
105
|
|
|
95
106
|
## Privacy
|
|
96
107
|
|
package/Scripts/package_app.sh
CHANGED
|
@@ -218,9 +218,9 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
|
|
218
218
|
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
|
219
219
|
<key>LSUIElement</key><true/>
|
|
220
220
|
<key>CFBundleIconFile</key><string>Icon</string>
|
|
221
|
-
<key>NSHumanReadableCopyright</key><string>© 2026 Srinivas Pendela
|
|
221
|
+
<key>NSHumanReadableCopyright</key><string>© 2025-2026 Srinivas Pendela. MIT License.</string>
|
|
222
222
|
<key>SUFeedURL</key><string>${FEED_URL}</string>
|
|
223
|
-
<key>SUPublicEDKey</key><string>
|
|
223
|
+
<key>SUPublicEDKey</key><string>1Ay3r9lgXQtyOjzVSCjlT5dvQuRlJghNKa8Goqvhfz4=</string>
|
|
224
224
|
<key>SUEnableAutomaticChecks</key><${AUTO_CHECKS}/>
|
|
225
225
|
<key>CodexBuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
|
|
226
226
|
<key>CodexGitCommit</key><string>${GIT_COMMIT}</string>
|
|
@@ -6,7 +6,16 @@ extension StatusItemController {
|
|
|
6
6
|
// MARK: - Actions reachable from menus
|
|
7
7
|
|
|
8
8
|
@objc func refreshNow() {
|
|
9
|
-
|
|
9
|
+
let provider = self.selectedMenuProvider
|
|
10
|
+
if let provider {
|
|
11
|
+
// Single provider selected — only refresh that one.
|
|
12
|
+
Task {
|
|
13
|
+
await self.store.refreshSingleProvider(provider)
|
|
14
|
+
}
|
|
15
|
+
} else {
|
|
16
|
+
// Overview — refresh all.
|
|
17
|
+
Task { await self.store.refresh(trigger: .manual, forceTokenUsage: true) }
|
|
18
|
+
}
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
@objc func installUpdate() {
|
|
@@ -432,6 +432,13 @@ extension StatusItemController {
|
|
|
432
432
|
totalToday += todayTokens
|
|
433
433
|
|
|
434
434
|
let resetDesc = snapshot?.primary.resetDescription
|
|
435
|
+
let windowLabel = snapshot?.primary.label?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
436
|
+
let topModel = self.store.ledgerTopModel(for: provider)
|
|
437
|
+
let topModelContext: String? = if let model = topModel?.model {
|
|
438
|
+
UsageFormatter.modelContextLabel(for: model)
|
|
439
|
+
} else {
|
|
440
|
+
Self.defaultContextLabel(for: provider)
|
|
441
|
+
}
|
|
435
442
|
|
|
436
443
|
summaries.append(OverviewMenuView.ProviderSummary(
|
|
437
444
|
id: provider.rawValue,
|
|
@@ -441,7 +448,9 @@ extension StatusItemController {
|
|
|
441
448
|
usedPercent: usedPercent,
|
|
442
449
|
todayTokens: todayTokens,
|
|
443
450
|
brandColor: brandColor,
|
|
444
|
-
resetDescription: resetDesc
|
|
451
|
+
resetDescription: resetDesc,
|
|
452
|
+
windowLabel: windowLabel,
|
|
453
|
+
topModelContext: topModelContext))
|
|
445
454
|
|
|
446
455
|
// Chart data — last 7 days
|
|
447
456
|
let dailySummaries = self.store.ledgerAllDailySummary(for: provider)
|
|
@@ -450,7 +459,8 @@ extension StatusItemController {
|
|
|
450
459
|
id: "\(provider.rawValue)-\(summary.dayKey)",
|
|
451
460
|
date: summary.dayStart,
|
|
452
461
|
tokens: summary.totals.totalTokens,
|
|
453
|
-
provider: meta.displayName
|
|
462
|
+
provider: meta.displayName,
|
|
463
|
+
color: brandColor))
|
|
454
464
|
}
|
|
455
465
|
}
|
|
456
466
|
|
|
@@ -461,6 +471,7 @@ extension StatusItemController {
|
|
|
461
471
|
summaries: activeSummaries.isEmpty ? summaries : activeSummaries,
|
|
462
472
|
chartPoints: chartPoints,
|
|
463
473
|
totalTodayTokens: totalToday,
|
|
474
|
+
totalProviders: UsageProvider.allCases.count,
|
|
464
475
|
width: width)
|
|
465
476
|
}
|
|
466
477
|
|
|
@@ -477,6 +488,23 @@ extension StatusItemController {
|
|
|
477
488
|
return abbreviations[name] ?? String(name.prefix(6))
|
|
478
489
|
}
|
|
479
490
|
|
|
491
|
+
/// Default flagship model context window per provider, loaded from
|
|
492
|
+
/// `Resources/provider-context-windows.json`. Update the JSON to add/change
|
|
493
|
+
/// context windows without recompiling.
|
|
494
|
+
private static let defaultContextWindows: [String: Int] = {
|
|
495
|
+
struct Entry: Decodable { let contextK: Int }
|
|
496
|
+
guard let url = Bundle.main.url(forResource: "provider-context-windows", withExtension: "json"),
|
|
497
|
+
let data = try? Data(contentsOf: url),
|
|
498
|
+
let dict = try? JSONDecoder().decode([String: Entry].self, from: data)
|
|
499
|
+
else { return [:] }
|
|
500
|
+
return dict.mapValues(\.contextK)
|
|
501
|
+
}()
|
|
502
|
+
|
|
503
|
+
private static func defaultContextLabel(for provider: UsageProvider) -> String? {
|
|
504
|
+
guard let k = self.defaultContextWindows[provider.rawValue] else { return nil }
|
|
505
|
+
return k >= 1_000 ? "ctx \(k / 1_000)M" : "ctx \(k)K"
|
|
506
|
+
}
|
|
507
|
+
|
|
480
508
|
private func resolvedMenuProvider() -> UsageProvider? {
|
|
481
509
|
let enabled = self.store.enabledProviders()
|
|
482
510
|
if enabled.isEmpty { return .codex }
|
|
@@ -45,11 +45,8 @@ enum GeminiLoginRunner {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Create a temporary shell script that runs gemini (auto-prompts for auth when no creds)
|
|
48
|
-
let
|
|
49
|
-
#!/bin/bash
|
|
50
|
-
cd ~
|
|
51
|
-
"\(binary)"
|
|
52
|
-
"""
|
|
48
|
+
let escaped = binary.replacingOccurrences(of: "'", with: "'\\''")
|
|
49
|
+
let scriptContent = "#!/bin/bash\ncd ~\n'\(escaped)'\n"
|
|
53
50
|
|
|
54
51
|
let tempDir = FileManager.default.temporaryDirectory
|
|
55
52
|
let scriptURL = tempDir.appendingPathComponent("gemini_login_\(UUID().uuidString).command")
|
|
@@ -53,29 +53,17 @@ struct KeychainAuggieTokenStore: AuggieTokenStoring {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let data = cleaned!.data(using: .utf8)!
|
|
56
|
-
let query = self.baseQuery(dataProtection: false)
|
|
57
|
-
let attributes: [String: Any] = [
|
|
58
|
-
kSecValueData as String: data,
|
|
59
|
-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
// Try update first.
|
|
63
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
64
|
-
if updateStatus == errSecSuccess { return }
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// Always delete-then-add to ensure SecAccess ACL is set.
|
|
58
|
+
// SecItemUpdate does NOT update the ACL, so existing items with
|
|
59
|
+
// missing or stale ACLs would continue to prompt for passwords.
|
|
60
|
+
try? self.deleteToken(dataProtection: false)
|
|
61
|
+
try? self.deleteToken(dataProtection: true)
|
|
70
62
|
|
|
71
|
-
var addQuery =
|
|
72
|
-
|
|
63
|
+
var addQuery = self.baseQuery(dataProtection: false)
|
|
64
|
+
addQuery[kSecValueData as String] = data
|
|
65
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
73
66
|
|
|
74
|
-
// Grant the calling app permanent access so macOS never shows a
|
|
75
|
-
// Keychain password dialog for this item.
|
|
76
|
-
if let access = Self.createSelfAccess() {
|
|
77
|
-
addQuery[kSecAttrAccess as String] = access
|
|
78
|
-
}
|
|
79
67
|
|
|
80
68
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
81
69
|
guard addStatus == errSecSuccess else {
|
|
@@ -91,6 +79,7 @@ struct KeychainAuggieTokenStore: AuggieTokenStoring {
|
|
|
91
79
|
var query = self.baseQuery(dataProtection: dataProtection)
|
|
92
80
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
93
81
|
query[kSecReturnData as String] = true
|
|
82
|
+
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
|
|
94
83
|
|
|
95
84
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
96
85
|
if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
|
|
@@ -127,17 +116,4 @@ struct KeychainAuggieTokenStore: AuggieTokenStoring {
|
|
|
127
116
|
return q
|
|
128
117
|
}
|
|
129
118
|
|
|
130
|
-
/// Creates a SecAccess that grants the calling application permanent
|
|
131
|
-
/// read/write access without triggering the macOS Keychain password dialog.
|
|
132
|
-
private static func createSelfAccess() -> SecAccess? {
|
|
133
|
-
var trustedSelf: SecTrustedApplication?
|
|
134
|
-
guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
|
|
135
|
-
let trustedSelf else { return nil }
|
|
136
|
-
var access: SecAccess?
|
|
137
|
-
guard SecAccessCreate(
|
|
138
|
-
"Runic API Token" as CFString,
|
|
139
|
-
[trustedSelf] as CFArray,
|
|
140
|
-
&access) == errSecSuccess else { return nil }
|
|
141
|
-
return access
|
|
142
|
-
}
|
|
143
119
|
}
|
|
@@ -53,29 +53,17 @@ struct KeychainAzureOpenAITokenStore: AzureOpenAITokenStoring {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let data = cleaned!.data(using: .utf8)!
|
|
56
|
-
let query = self.baseQuery(dataProtection: false)
|
|
57
|
-
let attributes: [String: Any] = [
|
|
58
|
-
kSecValueData as String: data,
|
|
59
|
-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
// Try update first.
|
|
63
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
64
|
-
if updateStatus == errSecSuccess { return }
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// Always delete-then-add to ensure SecAccess ACL is set.
|
|
58
|
+
// SecItemUpdate does NOT update the ACL, so existing items with
|
|
59
|
+
// missing or stale ACLs would continue to prompt for passwords.
|
|
60
|
+
try? self.deleteToken(dataProtection: false)
|
|
61
|
+
try? self.deleteToken(dataProtection: true)
|
|
70
62
|
|
|
71
|
-
var addQuery =
|
|
72
|
-
|
|
63
|
+
var addQuery = self.baseQuery(dataProtection: false)
|
|
64
|
+
addQuery[kSecValueData as String] = data
|
|
65
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
73
66
|
|
|
74
|
-
// Grant the calling app permanent access so macOS never shows a
|
|
75
|
-
// Keychain password dialog for this item.
|
|
76
|
-
if let access = Self.createSelfAccess() {
|
|
77
|
-
addQuery[kSecAttrAccess as String] = access
|
|
78
|
-
}
|
|
79
67
|
|
|
80
68
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
81
69
|
guard addStatus == errSecSuccess else {
|
|
@@ -91,6 +79,7 @@ struct KeychainAzureOpenAITokenStore: AzureOpenAITokenStoring {
|
|
|
91
79
|
var query = self.baseQuery(dataProtection: dataProtection)
|
|
92
80
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
93
81
|
query[kSecReturnData as String] = true
|
|
82
|
+
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
|
|
94
83
|
|
|
95
84
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
96
85
|
if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
|
|
@@ -127,17 +116,4 @@ struct KeychainAzureOpenAITokenStore: AzureOpenAITokenStoring {
|
|
|
127
116
|
return q
|
|
128
117
|
}
|
|
129
118
|
|
|
130
|
-
/// Creates a SecAccess that grants the calling application permanent
|
|
131
|
-
/// read/write access without triggering the macOS Keychain password dialog.
|
|
132
|
-
private static func createSelfAccess() -> SecAccess? {
|
|
133
|
-
var trustedSelf: SecTrustedApplication?
|
|
134
|
-
guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
|
|
135
|
-
let trustedSelf else { return nil }
|
|
136
|
-
var access: SecAccess?
|
|
137
|
-
guard SecAccessCreate(
|
|
138
|
-
"Runic API Token" as CFString,
|
|
139
|
-
[trustedSelf] as CFArray,
|
|
140
|
-
&access) == errSecSuccess else { return nil }
|
|
141
|
-
return access
|
|
142
|
-
}
|
|
143
119
|
}
|
|
@@ -53,29 +53,17 @@ struct KeychainCerebrasTokenStore: CerebrasTokenStoring {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let data = cleaned!.data(using: .utf8)!
|
|
56
|
-
let query = self.baseQuery(dataProtection: false)
|
|
57
|
-
let attributes: [String: Any] = [
|
|
58
|
-
kSecValueData as String: data,
|
|
59
|
-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
// Try update first.
|
|
63
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
64
|
-
if updateStatus == errSecSuccess { return }
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// Always delete-then-add to ensure SecAccess ACL is set.
|
|
58
|
+
// SecItemUpdate does NOT update the ACL, so existing items with
|
|
59
|
+
// missing or stale ACLs would continue to prompt for passwords.
|
|
60
|
+
try? self.deleteToken(dataProtection: false)
|
|
61
|
+
try? self.deleteToken(dataProtection: true)
|
|
70
62
|
|
|
71
|
-
var addQuery =
|
|
72
|
-
|
|
63
|
+
var addQuery = self.baseQuery(dataProtection: false)
|
|
64
|
+
addQuery[kSecValueData as String] = data
|
|
65
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
73
66
|
|
|
74
|
-
// Grant the calling app permanent access so macOS never shows a
|
|
75
|
-
// Keychain password dialog for this item.
|
|
76
|
-
if let access = Self.createSelfAccess() {
|
|
77
|
-
addQuery[kSecAttrAccess as String] = access
|
|
78
|
-
}
|
|
79
67
|
|
|
80
68
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
81
69
|
guard addStatus == errSecSuccess else {
|
|
@@ -91,6 +79,7 @@ struct KeychainCerebrasTokenStore: CerebrasTokenStoring {
|
|
|
91
79
|
var query = self.baseQuery(dataProtection: dataProtection)
|
|
92
80
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
93
81
|
query[kSecReturnData as String] = true
|
|
82
|
+
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
|
|
94
83
|
|
|
95
84
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
96
85
|
if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
|
|
@@ -127,17 +116,4 @@ struct KeychainCerebrasTokenStore: CerebrasTokenStoring {
|
|
|
127
116
|
return q
|
|
128
117
|
}
|
|
129
118
|
|
|
130
|
-
/// Creates a SecAccess that grants the calling application permanent
|
|
131
|
-
/// read/write access without triggering the macOS Keychain password dialog.
|
|
132
|
-
private static func createSelfAccess() -> SecAccess? {
|
|
133
|
-
var trustedSelf: SecTrustedApplication?
|
|
134
|
-
guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
|
|
135
|
-
let trustedSelf else { return nil }
|
|
136
|
-
var access: SecAccess?
|
|
137
|
-
guard SecAccessCreate(
|
|
138
|
-
"Runic API Token" as CFString,
|
|
139
|
-
[trustedSelf] as CFArray,
|
|
140
|
-
&access) == errSecSuccess else { return nil }
|
|
141
|
-
return access
|
|
142
|
-
}
|
|
143
119
|
}
|
|
@@ -53,29 +53,17 @@ struct KeychainCohereTokenStore: CohereTokenStoring {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let data = cleaned!.data(using: .utf8)!
|
|
56
|
-
let query = self.baseQuery(dataProtection: false)
|
|
57
|
-
let attributes: [String: Any] = [
|
|
58
|
-
kSecValueData as String: data,
|
|
59
|
-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
// Try update first.
|
|
63
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
64
|
-
if updateStatus == errSecSuccess { return }
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// Always delete-then-add to ensure SecAccess ACL is set.
|
|
58
|
+
// SecItemUpdate does NOT update the ACL, so existing items with
|
|
59
|
+
// missing or stale ACLs would continue to prompt for passwords.
|
|
60
|
+
try? self.deleteToken(dataProtection: false)
|
|
61
|
+
try? self.deleteToken(dataProtection: true)
|
|
70
62
|
|
|
71
|
-
var addQuery =
|
|
72
|
-
|
|
63
|
+
var addQuery = self.baseQuery(dataProtection: false)
|
|
64
|
+
addQuery[kSecValueData as String] = data
|
|
65
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
73
66
|
|
|
74
|
-
// Grant the calling app permanent access so macOS never shows a
|
|
75
|
-
// Keychain password dialog for this item.
|
|
76
|
-
if let access = Self.createSelfAccess() {
|
|
77
|
-
addQuery[kSecAttrAccess as String] = access
|
|
78
|
-
}
|
|
79
67
|
|
|
80
68
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
81
69
|
guard addStatus == errSecSuccess else {
|
|
@@ -91,6 +79,7 @@ struct KeychainCohereTokenStore: CohereTokenStoring {
|
|
|
91
79
|
var query = self.baseQuery(dataProtection: dataProtection)
|
|
92
80
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
93
81
|
query[kSecReturnData as String] = true
|
|
82
|
+
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
|
|
94
83
|
|
|
95
84
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
96
85
|
if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
|
|
@@ -127,17 +116,4 @@ struct KeychainCohereTokenStore: CohereTokenStoring {
|
|
|
127
116
|
return q
|
|
128
117
|
}
|
|
129
118
|
|
|
130
|
-
/// Creates a SecAccess that grants the calling application permanent
|
|
131
|
-
/// read/write access without triggering the macOS Keychain password dialog.
|
|
132
|
-
private static func createSelfAccess() -> SecAccess? {
|
|
133
|
-
var trustedSelf: SecTrustedApplication?
|
|
134
|
-
guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
|
|
135
|
-
let trustedSelf else { return nil }
|
|
136
|
-
var access: SecAccess?
|
|
137
|
-
guard SecAccessCreate(
|
|
138
|
-
"Runic API Token" as CFString,
|
|
139
|
-
[trustedSelf] as CFArray,
|
|
140
|
-
&access) == errSecSuccess else { return nil }
|
|
141
|
-
return access
|
|
142
|
-
}
|
|
143
119
|
}
|
|
@@ -53,29 +53,17 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let data = cleaned!.data(using: .utf8)!
|
|
56
|
-
let query = self.baseQuery(dataProtection: false)
|
|
57
|
-
let attributes: [String: Any] = [
|
|
58
|
-
kSecValueData as String: data,
|
|
59
|
-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
// Try update first.
|
|
63
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
64
|
-
if updateStatus == errSecSuccess { return }
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// Always delete-then-add to ensure SecAccess ACL is set.
|
|
58
|
+
// SecItemUpdate does NOT update the ACL, so existing items with
|
|
59
|
+
// missing or stale ACLs would continue to prompt for passwords.
|
|
60
|
+
try? self.deleteToken(dataProtection: false)
|
|
61
|
+
try? self.deleteToken(dataProtection: true)
|
|
70
62
|
|
|
71
|
-
var addQuery =
|
|
72
|
-
|
|
63
|
+
var addQuery = self.baseQuery(dataProtection: false)
|
|
64
|
+
addQuery[kSecValueData as String] = data
|
|
65
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
73
66
|
|
|
74
|
-
// Grant the calling app permanent access so macOS never shows a
|
|
75
|
-
// Keychain password dialog for this item.
|
|
76
|
-
if let access = Self.createSelfAccess() {
|
|
77
|
-
addQuery[kSecAttrAccess as String] = access
|
|
78
|
-
}
|
|
79
67
|
|
|
80
68
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
81
69
|
guard addStatus == errSecSuccess else {
|
|
@@ -91,6 +79,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
|
|
|
91
79
|
var query = self.baseQuery(dataProtection: dataProtection)
|
|
92
80
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
93
81
|
query[kSecReturnData as String] = true
|
|
82
|
+
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
|
|
94
83
|
|
|
95
84
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
96
85
|
if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
|
|
@@ -127,17 +116,4 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
|
|
|
127
116
|
return q
|
|
128
117
|
}
|
|
129
118
|
|
|
130
|
-
/// Creates a SecAccess that grants the calling application permanent
|
|
131
|
-
/// read/write access without triggering the macOS Keychain password dialog.
|
|
132
|
-
private static func createSelfAccess() -> SecAccess? {
|
|
133
|
-
var trustedSelf: SecTrustedApplication?
|
|
134
|
-
guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
|
|
135
|
-
let trustedSelf else { return nil }
|
|
136
|
-
var access: SecAccess?
|
|
137
|
-
guard SecAccessCreate(
|
|
138
|
-
"Runic API Token" as CFString,
|
|
139
|
-
[trustedSelf] as CFArray,
|
|
140
|
-
&access) == errSecSuccess else { return nil }
|
|
141
|
-
return access
|
|
142
|
-
}
|
|
143
119
|
}
|
|
@@ -53,29 +53,17 @@ struct KeychainDeepSeekTokenStore: DeepSeekTokenStoring {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
let data = cleaned!.data(using: .utf8)!
|
|
56
|
-
let query = self.baseQuery(dataProtection: false)
|
|
57
|
-
let attributes: [String: Any] = [
|
|
58
|
-
kSecValueData as String: data,
|
|
59
|
-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
// Try update first.
|
|
63
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
64
|
-
if updateStatus == errSecSuccess { return }
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// Always delete-then-add to ensure SecAccess ACL is set.
|
|
58
|
+
// SecItemUpdate does NOT update the ACL, so existing items with
|
|
59
|
+
// missing or stale ACLs would continue to prompt for passwords.
|
|
60
|
+
try? self.deleteToken(dataProtection: false)
|
|
61
|
+
try? self.deleteToken(dataProtection: true)
|
|
70
62
|
|
|
71
|
-
var addQuery =
|
|
72
|
-
|
|
63
|
+
var addQuery = self.baseQuery(dataProtection: false)
|
|
64
|
+
addQuery[kSecValueData as String] = data
|
|
65
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
73
66
|
|
|
74
|
-
// Grant the calling app permanent access so macOS never shows a
|
|
75
|
-
// Keychain password dialog for this item.
|
|
76
|
-
if let access = Self.createSelfAccess() {
|
|
77
|
-
addQuery[kSecAttrAccess as String] = access
|
|
78
|
-
}
|
|
79
67
|
|
|
80
68
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
81
69
|
guard addStatus == errSecSuccess else {
|
|
@@ -91,6 +79,7 @@ struct KeychainDeepSeekTokenStore: DeepSeekTokenStoring {
|
|
|
91
79
|
var query = self.baseQuery(dataProtection: dataProtection)
|
|
92
80
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
93
81
|
query[kSecReturnData as String] = true
|
|
82
|
+
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
|
|
94
83
|
|
|
95
84
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
96
85
|
if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
|
|
@@ -127,17 +116,4 @@ struct KeychainDeepSeekTokenStore: DeepSeekTokenStoring {
|
|
|
127
116
|
return q
|
|
128
117
|
}
|
|
129
118
|
|
|
130
|
-
/// Creates a SecAccess that grants the calling application permanent
|
|
131
|
-
/// read/write access without triggering the macOS Keychain password dialog.
|
|
132
|
-
private static func createSelfAccess() -> SecAccess? {
|
|
133
|
-
var trustedSelf: SecTrustedApplication?
|
|
134
|
-
guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
|
|
135
|
-
let trustedSelf else { return nil }
|
|
136
|
-
var access: SecAccess?
|
|
137
|
-
guard SecAccessCreate(
|
|
138
|
-
"Runic API Token" as CFString,
|
|
139
|
-
[trustedSelf] as CFArray,
|
|
140
|
-
&access) == errSecSuccess else { return nil }
|
|
141
|
-
return access
|
|
142
|
-
}
|
|
143
119
|
}
|