Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Scripts/package_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,13 @@ strip_release_binary "$APP/Contents/Helpers/CodexBarCLI"
# Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed.
install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
install_widget_extension
strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
if [[ "${CODEXBAR_SKIP_WIDGET:-0}" == "1" ]]; then
echo "WARN: Skipping CodexBarWidget extension (CODEXBAR_SKIP_WIDGET=1)." >&2
rm -rf "$APP/Contents/PlugIns/CodexBarWidget.appex"
else
install_widget_extension
strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
fi

swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR

Expand Down
21 changes: 19 additions & 2 deletions Sources/CodexBar/MenuHighlightStyle.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import SwiftUI

// Manual EnvironmentKey (instead of @Entry) so the app builds with Command Line Tools
// without SwiftUIMacros / full Xcode.
private struct MenuItemHighlightedKey: EnvironmentKey {
static let defaultValue: Bool = false
}

private struct MenuCardRefreshMonitorKey: EnvironmentKey {
static let defaultValue: MenuCardRefreshMonitor? = nil
}

extension EnvironmentValues {
@Entry var menuItemHighlighted: Bool = false
var menuItemHighlighted: Bool {
get { self[MenuItemHighlightedKey.self] }
set { self[MenuItemHighlightedKey.self] = newValue }
}

/// Optional live-refresh monitor injected into menu card views so the provider card
/// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu
/// stays open, without rebuilding the menu during AppKit tracking.
@Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor?
var menuCardRefreshMonitor: MenuCardRefreshMonitor? {
get { self[MenuCardRefreshMonitorKey.self] }
set { self[MenuCardRefreshMonitorKey.self] = newValue }
}
}

enum MenuHighlightStyle {
Expand Down
29 changes: 22 additions & 7 deletions Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ struct KimiProviderImplementation: ProviderImplementation {
ProviderSettingsPickerDescriptor(
id: "kimi-usage-source",
title: "Usage source",
subtitle: "Auto tries your configured API key, then a signed-in Kimi Code CLI credential, " +
"then browser cookies.",
subtitle: "Tracks the Kimi Code subscription only (api.kimi.com weekly quota). " +
"Auto tries your Code API key, then a signed-in Kimi Code CLI credential, then browser cookies. " +
"China open-platform balance (api.moonshot.cn) is a different product — enable " +
"Moonshot / Kimi Open Platform and set API region to China mainland.",
binding: usageBinding,
options: usageOptions,
isVisible: nil,
Expand All @@ -89,7 +91,7 @@ struct KimiProviderImplementation: ProviderImplementation {
ProviderSettingsPickerDescriptor(
id: "kimi-cookie-source",
title: "Cookie source",
subtitle: "Automatic imports browser cookies.",
subtitle: "Automatic imports browser cookies from kimi.com (Code console).",
dynamicSubtitle: subtitle,
binding: cookieBinding,
options: options,
Expand All @@ -103,22 +105,35 @@ struct KimiProviderImplementation: ProviderImplementation {
[
ProviderSettingsFieldDescriptor(
id: "kimi-api-key",
title: "API key",
subtitle: "Stored in ~/.codexbar/config.json. You can also provide KIMI_CODE_API_KEY.",
title: "Kimi Code API key",
subtitle: "Code subscription key from www.kimi.com/code (not platform.kimi.com open-platform keys). " +
"Stored in ~/.codexbar/config.json. You can also provide KIMI_CODE_API_KEY. " +
"For China open-platform balance, use Moonshot / Kimi Open Platform instead.",
kind: .secure,
placeholder: "Paste Kimi Code API key...",
binding: context.stringBinding(\.kimiAPIKey),
actions: [
ProviderSettingsActionDescriptor(
id: "kimi-open-api-docs",
title: "Open API docs",
title: "Open Code docs",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://www.kimi.com/code/docs/en/") {
NSWorkspace.shared.open(url)
}
}),
ProviderSettingsActionDescriptor(
id: "kimi-open-open-platform-china",
title: "China open platform",
style: .link,
isVisible: nil,
perform: {
// Jump to the product that actually has a China API host.
if let url = URL(string: "https://platform.kimi.com/console/account") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: nil,
onActivate: nil),
Expand All @@ -132,7 +147,7 @@ struct KimiProviderImplementation: ProviderImplementation {
actions: [
ProviderSettingsActionDescriptor(
id: "kimi-open-console",
title: "Open Console",
title: "Open Code console",
style: .link,
isVisible: nil,
perform: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ struct MoonshotProviderImplementation: ProviderImplementation {
ProviderSettingsPickerDescriptor(
id: "moonshot-api-region",
title: "API region",
subtitle: "Choose the Moonshot/Kimi API host for international or China mainland accounts.",
subtitle: "Open-platform pay-as-you-go balance only. " +
"China mainland uses api.moonshot.cn (platform.kimi.com keys). " +
"International uses api.moonshot.ai. " +
"Kimi Code weekly subscription is a different product under Kimi Code.",
binding: binding,
options: options,
isVisible: nil,
Expand All @@ -63,20 +66,19 @@ struct MoonshotProviderImplementation: ProviderImplementation {
ProviderSettingsFieldDescriptor(
id: "moonshot-api-key",
title: "API key",
subtitle: "Stored in ~/.codexbar/config.json.",
subtitle: "Open-platform key for the selected region. Stored in ~/.codexbar/config.json " +
"(or MOONSHOT_API_KEY). Do not paste a Kimi Code subscription key here.",
kind: .secure,
placeholder: "sk-...",
binding: context.stringBinding(\.moonshotAPIToken),
actions: [
ProviderSettingsActionDescriptor(
id: "moonshot-open-dashboard",
title: "Open Moonshot Console",
title: "Open console",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://platform.moonshot.ai/console/account") {
NSWorkspace.shared.open(url)
}
NSWorkspace.shared.open(context.settings.moonshotRegion.consoleURL)
}),
],
isVisible: nil,
Expand Down
41 changes: 38 additions & 3 deletions Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ struct ZaiProviderImplementation: ProviderImplementation {
ProviderSettingsPickerDescriptor(
id: "zai-api-region",
title: "API region",
subtitle: "Use BigModel for the China mainland endpoints (open.bigmodel.cn).",
subtitle: "Global uses api.z.ai. China mainland GLM Coding Plan uses open.bigmodel.cn " +
"(BigModel keys from bigmodel.cn — not interchangeable with global z.ai keys).",
binding: binding,
options: options,
isVisible: nil,
Expand All @@ -54,7 +55,41 @@ struct ZaiProviderImplementation: ProviderImplementation {
}

@MainActor
func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[]
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "zai-api-key",
title: "API key",
subtitle: "Coding Plan key (5-hour token window). China: BigModel open.bigmodel.cn key + " +
"region BigModel CN. Also auto-reads ~/.coding-relay/glm-api-key or " +
"Z_AI_API_KEY / BIGMODEL_API_KEY / ZHIPU_API_KEY / GLM_API_KEY.",
kind: .secure,
placeholder: "Paste Coding Plan API key…",
binding: context.stringBinding(\.zaiAPIToken),
actions: [
ProviderSettingsActionDescriptor(
id: "zai-open-bigmodel-keys",
title: "BigModel keys",
style: .link,
isVisible: { context.settings.zaiAPIRegion == .bigmodelCN },
perform: {
if let url = URL(string: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys") {
NSWorkspace.shared.open(url)
}
}),
ProviderSettingsActionDescriptor(
id: "zai-open-global-console",
title: "z.ai console",
style: .link,
isVisible: { context.settings.zaiAPIRegion == .global },
perform: {
if let url = URL(string: "https://z.ai/manage-apikey/apikey") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: nil,
onActivate: { context.settings.ensureZaiAPITokenLoaded() }),
]
}
}
3 changes: 2 additions & 1 deletion Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,8 @@
"usage_bars_fill_used" = "As used";
"reset_times_title" = "Reset times";
"reset_times_countdown" = "Countdown";
"reset_times_clock" = "Clock time";
"reset_times_clock" = "Date only";
"reset_times_date" = "Date only";
"cost_summary_title" = "Cost summary";
"cost_summary_off" = "Off";
"merge_icons_title" = "Merge icons";
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,8 @@
"usage_bars_fill_used" = "按已用量";
"reset_times_title" = "重置时间";
"reset_times_countdown" = "倒计时";
"reset_times_clock" = "时钟时间";
"reset_times_clock" = "仅日期";
"reset_times_date" = "仅日期";
"cost_summary_title" = "费用摘要";
"cost_summary_off" = "关闭";
"merge_icons_title" = "合并图标";
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/SettingsStore+MenuPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ enum ResetTimesOption: String, CaseIterable {
var label: String {
switch self {
case .countdown: L("reset_times_countdown")
case .clock: L("reset_times_clock")
// Absolute style is date-only (no hour:minute).
case .clock: L("reset_times_date")
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,17 @@ public enum KimiCookieImporter {
return first
}

/// Prefer official Kimi Desktop session when browsers are not signed in.
public static func desktopAuthToken() -> String? {
KimiDesktopAuthToken.load()
}

public static func hasSession(
browserDetection: BrowserDetection = BrowserDetection(),
logger: ((String) -> Void)? = nil) -> Bool
{
if self.desktopAuthToken() != nil { return true }

do {
return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty
} catch {
Expand Down
89 changes: 89 additions & 0 deletions Sources/CodexBarCore/Providers/Kimi/KimiDesktopAuthToken.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation

#if canImport(SQLite3)
import SQLite3
#elseif canImport(CSQLite3)
import CSQLite3
#endif

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Reads the `kimi-auth` web session token from the official Kimi Desktop Electron app.
///
/// Kimi Code CLI tokens can fetch weekly/rate-limit usage from `api.kimi.com`, but the
/// **Monthly** subscription pool still comes from the web membership API and needs a
/// `kimi-auth` cookie. Users who only run the CLI (no browser login) often still have
/// Kimi Desktop signed in — its Cookies DB stores `kimi-auth` in plaintext.
public enum KimiDesktopAuthToken: Sendable {
private static let log = CodexBarLog.logger(LogCategories.kimiCookie)

public static func cookiesDatabaseURL(
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL
{
homeDirectory
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Application Support", isDirectory: true)
.appendingPathComponent("kimi-desktop", isDirectory: true)
.appendingPathComponent("Cookies", isDirectory: false)
}

/// Returns a non-empty `kimi-auth` value, or nil when missing/unreadable.
public static func load(
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String?
{
#if os(macOS)
let dbURL = self.cookiesDatabaseURL(homeDirectory: homeDirectory)
guard FileManager.default.isReadableFile(atPath: dbURL.path) else { return nil }

// Chrome/Electron may hold a write lock; copy to a temp file first.
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("codexbar-kimi-desktop-cookies-\(UUID().uuidString).db")
do {
try FileManager.default.copyItem(at: dbURL, to: tempURL)
Comment on lines +40 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Copy SQLite WAL state before querying Desktop cookies

When Kimi Desktop has its Chromium Cookies database open in WAL mode, recent cookie writes live in Cookies-wal rather than the main Cookies file. Copying only the main database and opening the isolated copy can therefore return a stale token or no kimi-auth row, so Desktop-only users miss the new monthly enrichment. Use SQLite's backup API or copy the WAL/SHM sidecars consistently before opening the temporary database.

Useful? React with 👍 / 👎.

} catch {
Self.log.debug("Kimi Desktop Cookies copy failed: \(error.localizedDescription)")
return nil
}
defer { try? FileManager.default.removeItem(at: tempURL) }

guard let token = self.readKimiAuth(fromSQLitePath: tempURL.path) else { return nil }
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
#else
return nil
#endif
}

private static func readKimiAuth(fromSQLitePath path: String) -> String? {
// Minimal SQLite3 open without a package dependency — Cookies is a standard Chromium DB.
var db: OpaquePointer?
guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else {
return nil
}
defer { sqlite3_close(db) }

let sql = """
SELECT value, length(encrypted_value)
FROM cookies
WHERE name = 'kimi-auth'
AND (host_key = 'www.kimi.com' OR host_key = '.www.kimi.com' OR host_key = '.kimi.com' OR host_key = 'kimi.com')
ORDER BY last_access_utc DESC
LIMIT 1;
"""
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
return nil
}
defer { sqlite3_finalize(statement) }

guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
if let cString = sqlite3_column_text(statement, 0) {
let value = String(cString: cString)
if !value.isEmpty { return value }
}
// Encrypted-only rows need Keychain AES — leave those to BrowserCookieClient.
return nil
}
}
Loading