Skip to content

Commit 6bd9087

Browse files
committed
Use local config storage and add MIT license
1 parent c024092 commit 6bd9087

18 files changed

Lines changed: 177 additions & 278 deletions

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
3535
VERSION="$GITHUB_REF_NAME"
3636
else
37-
VERSION="v0.1.3"
37+
VERSION="v0.1.4"
3838
fi
3939
VERSION="$VERSION" ./scripts/package-release.sh
4040
VERSION="$VERSION" ./scripts/verify-release.sh

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v0.1.4
4+
5+
- Removed macOS Keychain token storage.
6+
- Saved auth and refresh tokens in the local Application Support config file.
7+
- Simplified config loading so login state is managed from one local JSON file.
8+
39
## v0.1.3
410

511
- Added GitHub Releases update checking.
@@ -15,7 +21,7 @@
1521

1622
## v0.1.1
1723

18-
- Moved login tokens to the macOS Keychain with automatic migration from older config files.
24+
- Added a credential-storage iteration that was superseded by v0.1.4 local config storage.
1925
- Added a settings action to disconnect and clear saved credentials.
2026
- Added account identity display and model-usage progress bars.
2127
- Removed remaining admin-mode client surface from the user-focused app.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 GeekyWizKid
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Sub2API Status Bar is a macOS menu bar companion for Sub2API users. It keeps dai
1010
- Seven-day token trend and model distribution
1111
- Optional menu bar text summary, for example `$120.75 · 1219 req · 3 RPM`
1212
- First-run login and optional manual Bearer token setup
13-
- Keychain-backed token storage; no telemetry or third-party analytics
13+
- Local config storage; no telemetry or third-party analytics
1414
- GitHub Releases update checking from Settings
1515

1616
## Requirements
@@ -50,7 +50,7 @@ Non-secret preferences are saved at:
5050
~/Library/Application Support/Sub2APIStatusBar/config.json
5151
```
5252

53-
Login tokens are stored in the macOS Keychain. Existing config files from older builds are migrated automatically on launch.
53+
Login tokens are stored in the same local config file. The app does not use macOS Keychain.
5454

5555
To switch accounts or remove saved credentials, open Settings and choose **Disconnect**.
5656

@@ -66,7 +66,7 @@ swift run Sub2APIStatusBar
6666
## Build A macOS App
6767

6868
```bash
69-
VERSION=v0.1.3 ./scripts/build-app.sh
69+
VERSION=v0.1.4 ./scripts/build-app.sh
7070
```
7171

7272
Output:
@@ -79,21 +79,21 @@ The build script generates the app icon, copies bundle resources, and applies ad
7979

8080
```bash
8181
SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
82-
VERSION=v0.1.3 \
82+
VERSION=v0.1.4 \
8383
./scripts/build-app.sh
8484
```
8585

8686
## Package A Release
8787

8888
```bash
89-
VERSION=v0.1.3 ./scripts/package-release.sh
89+
VERSION=v0.1.4 ./scripts/package-release.sh
9090
```
9191

9292
Output:
9393

9494
```text
95-
dist/Sub2APIStatusBar-0.1.3-macOS.zip
96-
dist/Sub2APIStatusBar-0.1.3-macOS.zip.sha256
95+
dist/Sub2APIStatusBar-0.1.4-macOS.zip
96+
dist/Sub2APIStatusBar-0.1.4-macOS.zip.sha256
9797
```
9898

9999
## Notarize A Release
@@ -105,7 +105,7 @@ APPLE_ID="you@example.com" \
105105
TEAM_ID="TEAMID" \
106106
APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
107107
SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
108-
VERSION=v0.1.3 \
108+
VERSION=v0.1.4 \
109109
./scripts/notarize-release.sh
110110
```
111111

@@ -137,7 +137,11 @@ swift run Sub2APIStatusBar
137137

138138
## Privacy
139139

140-
Sub2API Status Bar stores the server URL, display preferences, and refresh interval in the local Application Support config file. Auth and refresh tokens are stored in the macOS Keychain. It does not send data anywhere except the configured Sub2API server.
140+
Sub2API Status Bar stores the server URL, auth token, refresh token, display preferences, account list, and refresh interval in the local Application Support config file. It does not use macOS Keychain and does not send data anywhere except the configured Sub2API server.
141141

142142
## Acknowledgements
143143
Thanks to the [LinuxDo](https://linux.do/) community for the discussions, sharing, and feedback.
144+
145+
## License
146+
147+
MIT

Sources/Sub2APIStatusCore/AppConfig.swift

Lines changed: 50 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,23 @@ public struct StoredAccount: Codable, Identifiable, Equatable, Sendable {
5858
public var name: String
5959
public var email: String
6060
public var baseURL: String
61+
public var authToken: String
62+
public var refreshToken: String
6163

6264
public init(
6365
id: String = UUID().uuidString,
6466
name: String = "",
6567
email: String = "",
66-
baseURL: String
68+
baseURL: String,
69+
authToken: String = "",
70+
refreshToken: String = ""
6771
) {
6872
self.id = id
6973
self.name = name
7074
self.email = email
7175
self.baseURL = baseURL
76+
self.authToken = authToken
77+
self.refreshToken = refreshToken
7278
normalize()
7379
}
7480

@@ -81,6 +87,8 @@ public struct StoredAccount: Codable, Identifiable, Equatable, Sendable {
8187
name = name.trimmingCharacters(in: .whitespacesAndNewlines)
8288
email = email.trimmingCharacters(in: .whitespacesAndNewlines)
8389
baseURL = AppConfig.normalizedBaseURL(baseURL)
90+
authToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
91+
refreshToken = refreshToken.trimmingCharacters(in: .whitespacesAndNewlines)
8492
}
8593

8694
public var displayName: String {
@@ -99,6 +107,10 @@ public struct StoredAccount: Codable, Identifiable, Equatable, Sendable {
99107
}
100108
return baseURL
101109
}
110+
111+
public var storedTokens: StoredAuthTokens {
112+
StoredAuthTokens(authToken: authToken, refreshToken: refreshToken)
113+
}
102114
}
103115

104116
public struct AppConfig: Codable, Equatable, Sendable {
@@ -164,6 +176,8 @@ public struct AppConfig: Codable, Equatable, Sendable {
164176
public func encode(to encoder: Encoder) throws {
165177
var container = encoder.container(keyedBy: CodingKeys.self)
166178
try container.encode(baseURL, forKey: .baseURL)
179+
try container.encode(authToken, forKey: .authToken)
180+
try container.encode(refreshToken, forKey: .refreshToken)
167181
try container.encode(refreshIntervalSeconds, forKey: .refreshIntervalSeconds)
168182
try container.encode(language, forKey: .language)
169183
try container.encode(monitorMode, forKey: .monitorMode)
@@ -268,12 +282,18 @@ public struct AppConfig: Codable, Equatable, Sendable {
268282
accounts[index].name = accountName.isEmpty ? accounts[index].name : accountName
269283
accounts[index].email = accountEmail
270284
accounts[index].baseURL = accountBaseURL
285+
if let tokens {
286+
accounts[index].authToken = tokens.authToken
287+
accounts[index].refreshToken = tokens.refreshToken
288+
}
271289
accounts[index].normalize()
272290
} else {
273291
var account = StoredAccount(
274292
name: accountName.isEmpty ? accountEmail : accountName,
275293
email: accountEmail,
276-
baseURL: accountBaseURL
294+
baseURL: accountBaseURL,
295+
authToken: tokens?.authToken ?? "",
296+
refreshToken: tokens?.refreshToken ?? ""
277297
)
278298
account.normalize()
279299
accounts.append(account)
@@ -331,34 +351,10 @@ public struct StoredAuthTokens: Equatable, Sendable {
331351
}
332352
}
333353

334-
public protocol TokenStore: Sendable {
335-
func loadTokens() -> StoredAuthTokens
336-
func saveTokens(_ tokens: StoredAuthTokens) throws
337-
func loadTokens(for accountID: String) -> StoredAuthTokens
338-
func saveTokens(_ tokens: StoredAuthTokens, for accountID: String) throws
339-
func deleteTokens(for accountID: String) throws
340-
}
341-
342-
public extension TokenStore {
343-
func loadTokens(for _: String) -> StoredAuthTokens {
344-
loadTokens()
345-
}
346-
347-
func saveTokens(_ tokens: StoredAuthTokens, for _: String) throws {
348-
try saveTokens(tokens)
349-
}
350-
351-
func deleteTokens(for accountID: String) throws {
352-
try saveTokens(StoredAuthTokens(), for: accountID)
353-
}
354-
}
355-
356354
public final class ConfigStore: Sendable {
357355
private let configURL: URL
358-
private let tokenStore: any TokenStore
359356

360-
public init(configURL: URL? = nil, tokenStore: any TokenStore = KeychainTokenStore()) {
361-
self.tokenStore = tokenStore
357+
public init(configURL: URL? = nil) {
362358
if let configURL {
363359
self.configURL = configURL
364360
return
@@ -372,59 +368,28 @@ public final class ConfigStore: Sendable {
372368
}
373369

374370
public func load() -> AppConfig {
375-
let legacyKeychainTokens = tokenStore.loadTokens()
376371
guard let data = try? Data(contentsOf: configURL),
377372
var decoded = try? JSONDecoder.sub2api.decode(AppConfig.self, from: data) else {
378373
var defaults = AppConfig.defaults()
379374
let envTokens = StoredAuthTokens(authToken: defaults.authToken, refreshToken: defaults.refreshToken)
380-
if !legacyKeychainTokens.isEmpty {
381-
let accountID = defaults.upsertAccount(name: "Default Account", baseURL: defaults.baseURL, tokens: legacyKeychainTokens)
382-
do {
383-
try tokenStore.saveTokens(legacyKeychainTokens, for: accountID)
384-
try tokenStore.saveTokens(StoredAuthTokens())
385-
try writeConfig(defaults)
386-
} catch {
387-
defaults.authToken = legacyKeychainTokens.authToken
388-
defaults.refreshToken = legacyKeychainTokens.refreshToken
389-
}
390-
} else if !envTokens.isEmpty {
375+
if !envTokens.isEmpty {
391376
defaults.upsertAccount(name: "Environment Account", baseURL: defaults.baseURL, tokens: envTokens)
392377
}
393-
defaults.normalize()
394378
return defaults
395379
}
396380

397-
let legacyTokens = StoredAuthTokens(authToken: decoded.authToken, refreshToken: decoded.refreshToken)
381+
let topLevelTokens = StoredAuthTokens(authToken: decoded.authToken, refreshToken: decoded.refreshToken)
398382
decoded.normalize()
399-
let migrationTokens = Self.mergedTokens(primary: legacyKeychainTokens, fallback: legacyTokens)
400-
401-
if decoded.accounts.isEmpty, !migrationTokens.isEmpty {
402-
let accountID = decoded.upsertAccount(name: "Default Account", baseURL: decoded.baseURL, tokens: migrationTokens)
403-
do {
404-
try tokenStore.saveTokens(migrationTokens, for: accountID)
405-
try tokenStore.saveTokens(StoredAuthTokens())
406-
try writeConfig(decoded)
407-
} catch {
408-
decoded.authToken = migrationTokens.authToken
409-
decoded.refreshToken = migrationTokens.refreshToken
410-
}
383+
384+
if decoded.accounts.isEmpty, !topLevelTokens.isEmpty {
385+
decoded.upsertAccount(name: "Default Account", baseURL: decoded.baseURL, tokens: topLevelTokens)
411386
decoded.normalize()
412387
return decoded
413388
}
414389

415390
if let selectedAccountID = decoded.selectedAccountID {
416-
let accountTokens = tokenStore.loadTokens(for: selectedAccountID)
417-
let runtimeTokens = Self.mergedTokens(primary: accountTokens, fallback: migrationTokens)
418-
decoded.selectAccount(id: selectedAccountID, tokens: runtimeTokens)
419-
420-
if accountTokens.isEmpty, !runtimeTokens.isEmpty {
421-
try? tokenStore.saveTokens(runtimeTokens, for: selectedAccountID)
422-
try? tokenStore.saveTokens(StoredAuthTokens())
423-
}
424-
425-
if !legacyTokens.isEmpty {
426-
try? writeConfig(decoded)
427-
}
391+
let tokens = loadTokens(in: decoded, for: selectedAccountID)
392+
decoded.selectAccount(id: selectedAccountID, tokens: tokens)
428393
} else {
429394
decoded.clearAuthTokens()
430395
}
@@ -443,18 +408,23 @@ public final class ConfigStore: Sendable {
443408
}
444409

445410
if let accountID = normalized.selectedAccountID {
446-
try tokenStore.saveTokens(tokens, for: accountID)
411+
saveTokens(tokens, in: &normalized, for: accountID)
447412
}
448-
try tokenStore.saveTokens(StoredAuthTokens())
449413
try writeConfig(normalized)
450414
}
451415

452416
public func loadTokens(for accountID: String) -> StoredAuthTokens {
453-
tokenStore.loadTokens(for: accountID)
417+
let config = load()
418+
return loadTokens(in: config, for: accountID)
454419
}
455420

456421
public func deleteTokens(for accountID: String) throws {
457-
try tokenStore.deleteTokens(for: accountID)
422+
var config = load()
423+
saveTokens(StoredAuthTokens(), in: &config, for: accountID)
424+
if config.selectedAccountID == accountID {
425+
config.clearAuthTokens()
426+
}
427+
try writeConfig(config)
458428
}
459429

460430
private func writeConfig(_ config: AppConfig) throws {
@@ -466,10 +436,16 @@ public final class ConfigStore: Sendable {
466436
try encoder.encode(normalized).write(to: configURL, options: .atomic)
467437
}
468438

469-
private static func mergedTokens(primary: StoredAuthTokens, fallback: StoredAuthTokens) -> StoredAuthTokens {
470-
StoredAuthTokens(
471-
authToken: primary.authToken.isEmpty ? fallback.authToken : primary.authToken,
472-
refreshToken: primary.refreshToken.isEmpty ? fallback.refreshToken : primary.refreshToken
473-
)
439+
private func loadTokens(in config: AppConfig, for accountID: String) -> StoredAuthTokens {
440+
config.accounts.first { $0.id == accountID }?.storedTokens ?? StoredAuthTokens()
441+
}
442+
443+
private func saveTokens(_ tokens: StoredAuthTokens, in config: inout AppConfig, for accountID: String) {
444+
guard let index = config.accounts.firstIndex(where: { $0.id == accountID }) else {
445+
return
446+
}
447+
config.accounts[index].authToken = tokens.authToken
448+
config.accounts[index].refreshToken = tokens.refreshToken
449+
config.accounts[index].normalize()
474450
}
475451
}

0 commit comments

Comments
 (0)