From d2a5b58b66b344b73575370ae1d55f1351ebb030 Mon Sep 17 00:00:00 2001 From: nxc8335 Date: Wed, 5 Aug 2026 06:52:17 -0700 Subject: [PATCH 1/6] feat(linux): align normal UI with macOS --- Dockerfile.linux | 10 + Package.swift | 10 +- README.md | 2 +- README.zh-CN.md | 2 +- Sources/CGtk/include/shim.h | 275 ++++++ Sources/TokenClock/Linux/LinuxApp.swift | 856 ++++++++++++++---- Sources/TokenClock/Linux/LinuxAutostart.swift | 55 ++ .../TokenClock/Linux/LinuxClockRenderer.swift | 501 ++++++++++ .../TokenClock/Linux/LinuxClockTheme.swift | 398 ++++++++ .../TokenClock/Linux/LinuxCustomTheme.swift | 198 ++++ .../TokenClock/Linux/LinuxDetailsPanel.swift | 497 ++++++++++ .../Linux/LinuxSettingsWindow.swift | 834 +++++++++++++++++ .../TokenClock/Linux/LinuxThemePicker.swift | 187 ++++ .../TokenClock/Linux/LinuxUsageModel.swift | 127 ++- .../Linux/LinuxWeatherService.swift | 107 +++ scripts/build-appimage.sh | 10 +- 16 files changed, 3817 insertions(+), 252 deletions(-) create mode 100644 Sources/TokenClock/Linux/LinuxAutostart.swift create mode 100644 Sources/TokenClock/Linux/LinuxClockRenderer.swift create mode 100644 Sources/TokenClock/Linux/LinuxClockTheme.swift create mode 100644 Sources/TokenClock/Linux/LinuxCustomTheme.swift create mode 100644 Sources/TokenClock/Linux/LinuxDetailsPanel.swift create mode 100644 Sources/TokenClock/Linux/LinuxSettingsWindow.swift create mode 100644 Sources/TokenClock/Linux/LinuxThemePicker.swift create mode 100644 Sources/TokenClock/Linux/LinuxWeatherService.swift diff --git a/Dockerfile.linux b/Dockerfile.linux index e179616..ad65e00 100644 --- a/Dockerfile.linux +++ b/Dockerfile.linux @@ -18,6 +18,11 @@ FROM builder AS tester RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ curl \ + adwaita-icon-theme \ + fonts-noto-color-emoji \ + fonts-noto-cjk \ + librsvg2-common \ + shared-mime-info \ xvfb \ && rm -rf /var/lib/apt/lists/* @@ -29,9 +34,14 @@ RUN runtime_mirror="$(printf '%s' "${UBUNTU_PORTS_MIRROR}" | sed 's@^https:@http && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates \ + adwaita-icon-theme \ + fonts-noto-color-emoji \ + fonts-noto-cjk \ libcurl4 \ libgtk-3-0 \ + librsvg2-common \ libsqlite3-0 \ + shared-mime-info \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /src/.build/release/TokenClock /usr/local/bin/tokenclock-gui diff --git a/Package.swift b/Package.swift index 23f3834..269c27e 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,14 @@ let linuxSources = [ "Services/UsageServiceProtocol.swift", "Linux/LinuxAPIServer.swift", "Linux/LinuxApp.swift", + "Linux/LinuxAutostart.swift", + "Linux/LinuxClockTheme.swift", + "Linux/LinuxCustomTheme.swift", + "Linux/LinuxClockRenderer.swift", + "Linux/LinuxDetailsPanel.swift", + "Linux/LinuxSettingsWindow.swift", + "Linux/LinuxThemePicker.swift", + "Linux/LinuxWeatherService.swift", "Linux/LinuxMain.swift", "Linux/LinuxUsageModel.swift", ] @@ -59,7 +67,6 @@ let package = Package( "Models/ClockFaceTheme.swift", "Models/ClockSize.swift", "Models/CustomThemeConfig.swift", - "Resources", "Services/LaunchAgentHelper.swift", "Services/UsageAPIServer.swift", "Services/WeatherService.swift", @@ -68,6 +75,7 @@ let package = Package( "main.swift", ], sources: linuxSources, + resources: [.copy("Resources/glass_disc.png")], swiftSettings: [.unsafeFlags(["-parse-as-library"])] ), ] diff --git a/README.md b/README.md index 176de9a..d6bd838 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ curl -fsSL https://raw.githubusercontent.com/Neo-Isshin/TokenClock/main/cli/inst ### Linux normal build -Linux intentionally ships the **normal classic dial only** (GTK3). It keeps the existing 14 local usage parsers, stores history under XDG data directories, registers XDG autostart, and exposes the same loopback API on `127.0.0.1:9988`. Liquid Glass, the macOS theme editor, and weather/location UI are not part of the Linux build. +Linux ships the **normal dial experience** in GTK3/Cairo: a transparent circular widget with the same on-dial information layout, all 8 built-in normal faces (Glass, Classic, Glacier, Midnight, Luxe, Antique, Railgun, and Sky), their hand shapes, numerals, ticks, and decorations. Left-click opens the themed session/model detail panel with percentages and weather forecast; right-click opens the visual face picker plus size, opacity, always-on-top, temperature, city, timezone, language, settings, XDG autostart, API copy, and About controls. The GTK settings window covers auto-detection, all 14 tool switches and data paths, live API configuration, usage thresholds, and saved custom faces. Automatic Linux weather uses wttr.in's IP-based location fallback; manually selected cities behave like macOS normal. **x86_64 — prebuilt AppImage (default):** the universal one-liner downloads a self-contained AppImage (GTK3 bundled, needs only glibc ≥ 2.35) — no Swift, no compilation, no dev headers: diff --git a/README.zh-CN.md b/README.zh-CN.md index 3c64301..4b960d1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -205,7 +205,7 @@ curl -fsSL https://raw.githubusercontent.com/Neo-Isshin/TokenClock/main/cli/inst ### Linux normal 版 -Linux 仅适配 **normal 经典不透明表盘**:使用 GTK3,复用现有 14 个本地用量解析器,历史数据按 XDG 目录保存,支持 XDG 登录自启动,并在 `127.0.0.1:9988` 提供同构 API。Linux 版不包含 Liquid Glass、macOS 主题编辑器和天气/定位界面。 +Linux 使用 GTK3/Cairo 还原 **normal 表盘体验**:主窗口是透明的圆形小组件,盘面信息布局与 macOS normal 一致,并完整提供玻璃、经典、冰川、深夜、暗金、古风、超电磁炮、天空 8 套内置表盘及其指针形状、数字、刻度和装饰。左键展开与主题配套的详情面板,可按会话/模型分组、显示占比、展开来源并查看天气趋势;右键提供图形化表盘选择器、尺寸、透明度、置顶、温标、城市、时区、语言、设置、XDG 开机自启、复制 API 和关于。GTK 设置窗口包含自动探测、14 种工具开关与数据路径、API 即时配置、速率阈值和可保存的自定义表盘。Linux 自动天气使用 wttr.in 的 IP 定位回退,手选城市行为与 macOS normal 一致。 **x86_64 —— 预编译 AppImage(默认):** 通用一键命令会下载一个自带 GTK3 的 AppImage(仅要求 glibc ≥ 2.35),无需 Swift、无需编译、无需开发头文件: diff --git a/Sources/CGtk/include/shim.h b/Sources/CGtk/include/shim.h index 181f35e..0b6d0dd 100644 --- a/Sources/CGtk/include/shim.h +++ b/Sources/CGtk/include/shim.h @@ -8,6 +8,7 @@ typedef void (*TCGtkVoidCallback)(GtkWidget *, gpointer); typedef gboolean (*TCGtkTimerCallback)(gpointer); typedef gboolean (*TCGtkDrawCallback)(GtkWidget *, cairo_t *, gpointer); typedef gboolean (*TCGtkButtonCallback)(GtkWidget *, GdkEventButton *, gpointer); +typedef gboolean (*TCGtkMotionCallback)(GtkWidget *, GdkEventMotion *, gpointer); static inline GtkWindow *tc_gtk_window(GtkWidget *widget) { return GTK_WINDOW(widget); @@ -25,6 +26,38 @@ static inline GtkLabel *tc_gtk_label(GtkWidget *widget) { return GTK_LABEL(widget); } +static inline GtkButton *tc_gtk_button(GtkWidget *widget) { + return GTK_BUTTON(widget); +} + +static inline GtkScrolledWindow *tc_gtk_scrolled_window(GtkWidget *widget) { + return GTK_SCROLLED_WINDOW(widget); +} + +static inline GtkEntry *tc_gtk_entry(GtkWidget *widget) { + return GTK_ENTRY(widget); +} + +static inline GtkToggleButton *tc_gtk_toggle_button(GtkWidget *widget) { + return GTK_TOGGLE_BUTTON(widget); +} + +static inline GtkSpinButton *tc_gtk_spin_button(GtkWidget *widget) { + return GTK_SPIN_BUTTON(widget); +} + +static inline GtkNotebook *tc_gtk_notebook(GtkWidget *widget) { + return GTK_NOTEBOOK(widget); +} + +static inline GtkMenuShell *tc_gtk_menu_shell(GtkWidget *widget) { + return GTK_MENU_SHELL(widget); +} + +static inline GtkMenuItem *tc_gtk_menu_item(GtkWidget *widget) { + return GTK_MENU_ITEM(widget); +} + static inline GtkCssProvider *tc_gtk_css_provider(GtkWidget *widget) { return GTK_CSS_PROVIDER(widget); } @@ -33,6 +66,10 @@ static inline void tc_gtk_add_class(GtkWidget *widget, const char *class_name) { gtk_style_context_add_class(gtk_widget_get_style_context(widget), class_name); } +static inline void tc_gtk_remove_class(GtkWidget *widget, const char *class_name) { + gtk_style_context_remove_class(gtk_widget_get_style_context(widget), class_name); +} + static inline gulong tc_gtk_on_destroy( GtkWidget *widget, TCGtkVoidCallback callback, gpointer data ) { @@ -51,12 +88,34 @@ static inline gulong tc_gtk_on_button_press( return g_signal_connect(widget, "button-press-event", G_CALLBACK(callback), data); } +static inline gulong tc_gtk_on_button_release( + GtkWidget *widget, TCGtkButtonCallback callback, gpointer data +) { + return g_signal_connect(widget, "button-release-event", G_CALLBACK(callback), data); +} + +static inline gulong tc_gtk_on_motion( + GtkWidget *widget, TCGtkMotionCallback callback, gpointer data +) { + return g_signal_connect(widget, "motion-notify-event", G_CALLBACK(callback), data); +} + static inline gulong tc_gtk_on_clicked( GtkWidget *widget, TCGtkVoidCallback callback, gpointer data ) { return g_signal_connect(widget, "clicked", G_CALLBACK(callback), data); } +static inline gulong tc_gtk_on_activate( + GtkWidget *widget, TCGtkVoidCallback callback, gpointer data +) { + return g_signal_connect(widget, "activate", G_CALLBACK(callback), data); +} + +static inline gulong tc_gtk_hide_on_delete(GtkWidget *widget) { + return g_signal_connect(widget, "delete-event", G_CALLBACK(gtk_widget_hide_on_delete), NULL); +} + static inline guint tc_gtk_timeout_add_seconds( guint seconds, TCGtkTimerCallback callback, gpointer data ) { @@ -85,10 +144,189 @@ static inline void tc_gtk_begin_move( ); } +static inline void tc_gtk_begin_move_at( + GtkWidget *widget, guint button, double root_x, double root_y, guint32 time +) { + gtk_window_begin_move_drag( + GTK_WINDOW(widget), (gint)button, (gint)root_x, (gint)root_y, time + ); +} + +static inline double tc_gtk_button_root_x(GdkEventButton *event) { return event->x_root; } +static inline double tc_gtk_button_root_y(GdkEventButton *event) { return event->y_root; } +static inline guint32 tc_gtk_button_time(GdkEventButton *event) { return event->time; } +static inline double tc_gtk_motion_root_x(GdkEventMotion *event) { return event->x_root; } +static inline double tc_gtk_motion_root_y(GdkEventMotion *event) { return event->y_root; } +static inline guint32 tc_gtk_motion_time(GdkEventMotion *event) { return event->time; } +static inline guint tc_gtk_motion_state(GdkEventMotion *event) { return event->state; } + +static inline void tc_gtk_prepare_transparent_window(GtkWidget *widget) { + GdkScreen *screen = gtk_widget_get_screen(widget); + GdkVisual *visual = screen != NULL ? gdk_screen_get_rgba_visual(screen) : NULL; + if (visual != NULL) { + gtk_widget_set_visual(widget, visual); + } + gtk_widget_set_app_paintable(widget, TRUE); +} + +static inline gint tc_gtk_primary_workarea_height(void) { + GdkDisplay *display = gdk_display_get_default(); + if (display == NULL) return 900; + GdkMonitor *monitor = gdk_display_get_primary_monitor(display); + if (monitor == NULL) monitor = gdk_display_get_monitor(display, 0); + if (monitor == NULL) return 900; + GdkRectangle workarea; + gdk_monitor_get_workarea(monitor, &workarea); + return workarea.height; +} + +// Give both the visible X11 window and its input region a true circular shape. +// Alpha compositors still use the RGBA buffer; this is the fallback that keeps +// non-composited/XQuartz desktops from showing or intercepting a square frame. +static inline void tc_gtk_shape_circle(GtkWidget *widget, gint diameter) { + GdkWindow *window = gtk_widget_get_window(widget); + if (window == NULL || diameter <= 0) return; + + cairo_region_t *region = cairo_region_create(); + const double radius = (double)diameter / 2.0; + for (gint y = 0; y < diameter; y++) { + const double dy = ((double)y + 0.5) - radius; + const double half = sqrt(fmax(0.0, radius * radius - dy * dy)); + const gint x = (gint)floor(radius - half); + const gint width = (gint)ceil(half * 2.0); + cairo_rectangle_int_t row = { x, y, width, 1 }; + cairo_region_union_rectangle(region, &row); + } + gdk_window_shape_combine_region(window, region, 0, 0); + gdk_window_input_shape_combine_region(window, region, 0, 0); + cairo_region_destroy(region); +} + +static inline void tc_gtk_position_adjacent_panel( + GtkWidget *parent_widget, + GtkWidget *panel_widget, + gint panel_width, + gint panel_height, + gint margin +) { + GtkWindow *parent = GTK_WINDOW(parent_widget); + GtkWindow *panel = GTK_WINDOW(panel_widget); + gint parent_x = 0, parent_y = 0, parent_width = 0, parent_height = 0; + gtk_window_get_position(parent, &parent_x, &parent_y); + gtk_window_get_size(parent, &parent_width, &parent_height); + + gint x = parent_x + (parent_width - panel_width) / 2; + gint y = parent_y + parent_height + margin; + GdkWindow *gdk_parent = gtk_widget_get_window(parent_widget); + GdkDisplay *display = gdk_display_get_default(); + GdkMonitor *monitor = (display != NULL && gdk_parent != NULL) + ? gdk_display_get_monitor_at_window(display, gdk_parent) : NULL; + if (monitor != NULL) { + GdkRectangle workarea; + gdk_monitor_get_workarea(monitor, &workarea); + if (y + panel_height > workarea.y + workarea.height) { + y = parent_y - panel_height - margin; + } + x = MAX(workarea.x, MIN(x, workarea.x + workarea.width - panel_width)); + y = MAX(workarea.y, MIN(y, workarea.y + workarea.height - panel_height)); + } + gtk_window_move(panel, x, y); +} + +static inline void tc_gtk_remove_all_children(GtkWidget *container_widget) { + GList *children = gtk_container_get_children(GTK_CONTAINER(container_widget)); + for (GList *node = children; node != NULL; node = node->next) { + gtk_widget_destroy(GTK_WIDGET(node->data)); + } + g_list_free(children); +} + +static inline void tc_gtk_clipboard_set_text(const char *text) { + GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard != NULL) { + gtk_clipboard_set_text(clipboard, text, -1); + gtk_clipboard_store(clipboard); + } +} + +static inline void tc_gtk_show_about(GtkWidget *parent_widget, const char *version) { + GtkWidget *dialog = gtk_about_dialog_new(); + gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), "TokenClock"); + gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), version); + gtk_about_dialog_set_comments( + GTK_ABOUT_DIALOG(dialog), + "A beautiful desktop clock for local AI token usage." + ); + gtk_about_dialog_set_website( + GTK_ABOUT_DIALOG(dialog), + "https://github.com/Neo-Isshin/TokenClock" + ); + gtk_window_set_transient_for(GTK_WINDOW(dialog), GTK_WINDOW(parent_widget)); + gtk_window_set_keep_above(GTK_WINDOW(dialog), TRUE); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); +} + +static inline void tc_gtk_show_message( + GtkWidget *parent_widget, + GtkMessageType type, + const char *title, + const char *message +) { + GtkWidget *dialog = gtk_message_dialog_new( + GTK_WINDOW(parent_widget), + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + type, + GTK_BUTTONS_OK, + "%s", + message + ); + gtk_window_set_title(GTK_WINDOW(dialog), title); + gtk_window_set_keep_above(GTK_WINDOW(dialog), TRUE); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); +} + +static inline char *tc_gtk_choose_folder( + GtkWidget *parent_widget, + const char *title, + const char *current_path +) { + GtkWidget *dialog = gtk_file_chooser_dialog_new( + title, + GTK_WINDOW(parent_widget), + GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER, + "_Cancel", GTK_RESPONSE_CANCEL, + "_Select", GTK_RESPONSE_ACCEPT, + NULL + ); + if (current_path != NULL && current_path[0] != '\0') { + gtk_file_chooser_set_filename(GTK_FILE_CHOOSER(dialog), current_path); + } + char *selected = NULL; + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + selected = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + } + gtk_widget_destroy(dialog); + return selected; +} + +static inline void tc_g_free(gpointer pointer) { + g_free(pointer); +} + static inline guint tc_gtk_event_button(GdkEventButton *event) { return event->button; } +static inline const char *tc_gtk_widget_name(GtkWidget *widget) { + return gtk_widget_get_name(widget); +} + +static inline void tc_gtk_popup_menu(GtkWidget *menu, GdkEventButton *event) { + gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event); +} + static inline void tc_gtk_apply_css(const char *css) { GtkCssProvider *provider = gtk_css_provider_new(); gtk_css_provider_load_from_data(provider, css, -1, NULL); @@ -109,4 +347,41 @@ static inline void tc_cairo_set_source_rgba( cairo_set_source_rgba(context, r, g, b, a); } +// Pango provides UTF-8 fallback and correct metrics for CJK/emoji overlay text. +// align: 0 = left, 1 = centered, 2 = right; x/y identify that anchor point. +static inline void tc_cairo_draw_text( + cairo_t *context, + const char *text, + const char *family, + double size, + gint weight, + double x, + double y, + gint align, + double r, + double g, + double b, + double a +) { + PangoLayout *layout = pango_cairo_create_layout(context); + PangoFontDescription *font = pango_font_description_new(); + pango_font_description_set_family(font, family); + pango_font_description_set_absolute_size(font, size * PANGO_SCALE); + pango_font_description_set_weight(font, (PangoWeight)weight); + pango_layout_set_font_description(layout, font); + pango_layout_set_text(layout, text, -1); + + PangoRectangle logical; + pango_layout_get_pixel_extents(layout, NULL, &logical); + double draw_x = x; + if (align == 1) draw_x -= logical.width / 2.0; + if (align == 2) draw_x -= logical.width; + cairo_move_to(context, draw_x - logical.x, y - logical.y - logical.height / 2.0); + cairo_set_source_rgba(context, r, g, b, a); + pango_cairo_show_layout(context, layout); + + pango_font_description_free(font); + g_object_unref(layout); +} + #endif diff --git a/Sources/TokenClock/Linux/LinuxApp.swift b/Sources/TokenClock/Linux/LinuxApp.swift index d3a1f5a..84c5af8 100644 --- a/Sources/TokenClock/Linux/LinuxApp.swift +++ b/Sources/TokenClock/Linux/LinuxApp.swift @@ -3,22 +3,84 @@ import CGtk final class LinuxApp: @unchecked Sendable { private let model = LinuxUsageModel() + private let renderer = LinuxClockRenderer() + private let weatherService = LinuxWeatherService() private var apiServer: LinuxAPIServer? private var window: UnsafeMutablePointer? private var dial: UnsafeMutablePointer? - private var summaryLabel: UnsafeMutablePointer? - private var detailsLabel: UnsafeMutablePointer? - private var detailsVisible = false + private var menu: UnsafeMutablePointer? + private var detailsPanel: LinuxDetailsPanel? + private var themePicker: LinuxThemePicker? + private var settingsWindow: LinuxSettingsWindow? + private var themeItems: [LinuxClockTheme: UnsafeMutablePointer] = [:] + private var sizeItems: [LinuxClockSize: UnsafeMutablePointer] = [:] + private var opacityItems: [Int: UnsafeMutablePointer] = [:] + private var timezoneItems: [String: UnsafeMutablePointer] = [:] + private var languageItems: [AppLanguage: UnsafeMutablePointer] = [:] + private var leftButtonDown = false + private var dragStarted = false + private var pressRootX: Double = 0 + private var pressRootY: Double = 0 + + private var windowOpacity: Double = { + let defaults = UserDefaults.standard + guard defaults.object(forKey: SettingsKey.windowOpacity.rawValue) != nil else { return 1 } + return min(1, max(0.25, defaults.double(forKey: SettingsKey.windowOpacity.rawValue))) + }() + private var alwaysOnTop = UserDefaults.standard.bool(for: .alwaysOnTop) + private var selectedTimezone = UserDefaults.standard.string(for: .selectedTimezone) ?? "auto" + private var selectedCity = UserDefaults.standard.string(for: .selectedCity) ?? "auto" + private var useFahrenheit = UserDefaults.standard.bool(for: .useFahrenheit) + + private static let cityOptions = [ + "auto", "Hong Kong", "Shanghai", "Beijing", "Tokyo", "Singapore", "New York", + ] + + private static let timezoneOptions: [(label: String, identifier: String)] = [ + ("tz.auto", "auto"), + ("tz.hongKong", "Asia/Hong_Kong"), + ("tz.shanghai", "Asia/Shanghai"), + ("tz.tokyo", "Asia/Tokyo"), + ("tz.singapore", "Asia/Singapore"), + ("tz.newYork", "America/New_York"), + ("tz.london", "Europe/London"), + ("tz.losAngeles", "America/Los_Angeles"), + ] + + private var selectedTheme: LinuxClockTheme = { + if let forced = ProcessInfo.processInfo.environment["TOKENCLOCK_THEME"], + let theme = LinuxClockTheme(rawValue: forced) { + return theme + } + if let saved = UserDefaults.standard.string(for: .selectedTheme), + let theme = LinuxClockTheme(rawValue: saved) { + return theme + } + return .classic + }() + + private var selectedSize: LinuxClockSize = { + if let forced = ProcessInfo.processInfo.environment["TOKENCLOCK_SIZE"], + let size = LinuxClockSize(rawValue: forced) { + return size + } + if let saved = UserDefaults.standard.string(for: .clockSize), + let size = LinuxClockSize(rawValue: saved) { + return size + } + return .medium + }() private lazy var opaque = Unmanaged.passUnretained(self).toOpaque() func run() { gtk_init(nil, nil) + autoDetectSizeIfNeeded() buildInterface() - apiServer = LinuxAPIServer(model: model) - apiServer?.start() + startAPIServerIfEnabled() scheduleScan(incremental: false) + refreshWeather() _ = tc_gtk_timeout_add(1_000, linuxClockTick, opaque) _ = tc_gtk_timeout_add_seconds( @@ -26,100 +88,257 @@ final class LinuxApp: @unchecked Sendable { linuxScanTick, opaque ) + _ = tc_gtk_timeout_add_seconds( + guint(max(60, Int(AppConfig.Timers.weather))), + linuxWeatherTick, + opaque + ) gtk_main() } + private func autoDetectSizeIfNeeded() { + guard ProcessInfo.processInfo.environment["TOKENCLOCK_SIZE"] == nil else { return } + guard !UserDefaults.standard.bool(for: .clockSizeUserChosen) else { return } + let height = Int(tc_gtk_primary_workarea_height()) + selectedSize = if height < 850 { + .small + } else if height < 1_250 { + .medium + } else if height < 1_500 { + .large + } else { + .extraLarge + } + UserDefaults.standard.setString(selectedSize.rawValue, for: .clockSize) + } + private func buildInterface() { - let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL) + guard let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL) else { return } window = createdWindow + let diameter = gint(selectedSize.diameter) gtk_window_set_title(tc_gtk_window(createdWindow), "TokenClock") - gtk_window_set_default_size(tc_gtk_window(createdWindow), 360, 430) + gtk_window_set_default_size(tc_gtk_window(createdWindow), diameter, diameter) gtk_window_set_resizable(tc_gtk_window(createdWindow), 0) gtk_window_set_decorated(tc_gtk_window(createdWindow), 0) - gtk_window_set_keep_above(tc_gtk_window(createdWindow), 1) + gtk_window_set_keep_above( + tc_gtk_window(createdWindow), + alwaysOnTop ? 1 : 0 + ) gtk_window_set_skip_taskbar_hint(tc_gtk_window(createdWindow), 1) + gtk_window_set_skip_pager_hint(tc_gtk_window(createdWindow), 1) gtk_window_set_position(tc_gtk_window(createdWindow), GTK_WIN_POS_CENTER) - gtk_widget_add_events(createdWindow, gint(GDK_BUTTON_PRESS_MASK.rawValue)) - tc_gtk_add_class(createdWindow, "tokenclock-window") + tc_gtk_prepare_transparent_window(createdWindow) if UserDefaults.standard.object(forKey: "TCLinuxWindowX") != nil { - let x = UserDefaults.standard.integer(forKey: "TCLinuxWindowX") - let y = UserDefaults.standard.integer(forKey: "TCLinuxWindowY") - gtk_window_move(tc_gtk_window(createdWindow), gint(x), gint(y)) + gtk_window_move( + tc_gtk_window(createdWindow), + gint(UserDefaults.standard.integer(forKey: "TCLinuxWindowX")), + gint(UserDefaults.standard.integer(forKey: "TCLinuxWindowY")) + ) } - let root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8) - gtk_container_set_border_width(tc_gtk_container(root), 14) - gtk_container_add(tc_gtk_container(createdWindow), root) - - let createdDial = gtk_drawing_area_new() + guard let createdDial = gtk_drawing_area_new() else { return } dial = createdDial - gtk_widget_set_size_request(createdDial, 324, 324) - gtk_box_pack_start(tc_gtk_box(root), createdDial, 1, 1, 0) - gtk_widget_add_events(createdDial, gint(GDK_BUTTON_PRESS_MASK.rawValue)) - - let createdSummary = gtk_label_new("Scanning local usage…") - summaryLabel = createdSummary - tc_gtk_add_class(createdSummary, "summary") - gtk_box_pack_start(tc_gtk_box(root), createdSummary, 0, 0, 0) - - let createdDetails = gtk_label_new("") - detailsLabel = createdDetails - tc_gtk_add_class(createdDetails, "details") - gtk_label_set_xalign(tc_gtk_label(createdDetails), 0) - gtk_label_set_selectable(tc_gtk_label(createdDetails), 1) - gtk_box_pack_start(tc_gtk_box(root), createdDetails, 0, 0, 0) - - let controls = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) - gtk_box_set_homogeneous(tc_gtk_box(controls), 1) - let refreshButton = gtk_button_new_with_label("Refresh") - let detailsButton = gtk_button_new_with_label("Details") - let quitButton = gtk_button_new_with_label("Quit") - gtk_box_pack_start(tc_gtk_box(controls), refreshButton, 1, 1, 0) - gtk_box_pack_start(tc_gtk_box(controls), detailsButton, 1, 1, 0) - gtk_box_pack_start(tc_gtk_box(controls), quitButton, 1, 1, 0) - gtk_box_pack_end(tc_gtk_box(root), controls, 0, 0, 0) + gtk_widget_set_size_request(createdDial, diameter, diameter) + gtk_widget_set_app_paintable(createdDial, 1) + gtk_widget_add_events( + createdDial, + gint( + GDK_BUTTON_PRESS_MASK.rawValue + | GDK_BUTTON_RELEASE_MASK.rawValue + | GDK_POINTER_MOTION_MASK.rawValue + ) + ) + gtk_container_add(tc_gtk_container(createdWindow), createdDial) _ = tc_gtk_on_destroy(createdWindow, linuxDestroy, opaque) _ = tc_gtk_on_button_press(createdDial, linuxButtonPress, opaque) + _ = tc_gtk_on_button_release(createdDial, linuxButtonRelease, opaque) + _ = tc_gtk_on_motion(createdDial, linuxMotion, opaque) _ = tc_gtk_on_draw(createdDial, linuxDraw, opaque) - _ = tc_gtk_on_clicked(refreshButton, linuxRefresh, opaque) - _ = tc_gtk_on_clicked(detailsButton, linuxToggleDetails, opaque) - _ = tc_gtk_on_clicked(quitButton, linuxQuit, opaque) - - tc_gtk_apply_css( - """ - .tokenclock-window { - background: #f4f1ea; - border: 2px solid #69645b; - border-radius: 22px; - } - .summary { - color: #292722; - font: 600 15px Sans; - padding: 2px 6px; - } - .details { - color: #39362f; - background: #e7e1d6; - border-radius: 10px; - font: 13px Monospace; - padding: 10px; + + detailsPanel = LinuxDetailsPanel(parent: createdWindow) + detailsPanel?.setOpacity(windowOpacity) + themePicker = LinuxThemePicker(parent: createdWindow, owner: self) + settingsWindow = LinuxSettingsWindow(parent: createdWindow, owner: self, model: model) + buildContextMenu() + + gtk_widget_show_all(createdWindow) + gtk_widget_set_opacity(createdWindow, windowOpacity) + detailsPanel?.hide() + tc_gtk_shape_circle(createdWindow, diameter) + refreshUI() + } + + private func buildContextMenu() { + themeItems.removeAll() + sizeItems.removeAll() + opacityItems.removeAll() + timezoneItems.removeAll() + languageItems.removeAll() + guard let root = gtk_menu_new() else { return } + menu = root + + appendMenuItem(L10n.shared.tr("menu.clockFace"), name: "theme-picker", to: root) + + let savedThemes = LinuxCustomThemeStore.shared.themes + if !savedThemes.isEmpty { + let savedRoot = gtk_menu_item_new_with_label(L10n.shared.tr("menu.myClockFaces")) + let savedMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(savedRoot), savedMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), savedRoot) + let activeID = UserDefaults.standard.string(for: .activeCustomThemeId) + for saved in savedThemes { + let selected = selectedTheme == .custom && activeID == saved.id.uuidString + let item = gtk_menu_item_new_with_label( + menuSelectionLabel(selected, title: saved.name) + ) + gtk_widget_set_name(item, "custom-theme:\(saved.id.uuidString)") + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(savedMenu), item) } - button { - color: #292722; - background: #ded6c8; - border: 1px solid #8c8476; - border-radius: 8px; - padding: 6px; + } + + let sizeRoot = gtk_menu_item_new_with_label(L10n.shared.tr("size.title")) + let sizeMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(sizeRoot), sizeMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), sizeRoot) + for size in LinuxClockSize.allCases { + let item = gtk_menu_item_new_with_label( + menuSelectionLabel(size == selectedSize, title: size.displayName) + ) + gtk_widget_set_name(item, "size:\(size.rawValue)") + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(sizeMenu), item) + sizeItems[size] = item + } + + appendMenuItem( + L10n.shared.tr("menu.api", Int(Self.resolveAPIServerPort())), + name: "copy-api", to: root + ) + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + + let opacityRoot = gtk_menu_item_new_with_label(L10n.shared.tr("menu.opacity")) + let opacityMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(opacityRoot), opacityMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), opacityRoot) + for value in [25, 50, 75, 100] { + let item = gtk_menu_item_new_with_label( + menuSelectionLabel(Int(windowOpacity * 100) == value, title: "\(value)%") + ) + gtk_widget_set_name(item, "opacity:\(value)") + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(opacityMenu), item) + opacityItems[value] = item + } + + appendMenuItem( + menuSelectionLabel(alwaysOnTop, title: L10n.shared.tr("menu.alwaysOnTop")), + name: "always-on-top", to: root + ) + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + + let temperatureRoot = gtk_menu_item_new_with_label(L10n.shared.tr("menu.temperature")) + let temperatureMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(temperatureRoot), temperatureMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), temperatureRoot) + let celsius = gtk_menu_item_new_with_label( + menuSelectionLabel(!useFahrenheit, title: L10n.shared.tr("menu.celsius")) + ) + gtk_widget_set_name(celsius, "temperature:c") + _ = tc_gtk_on_activate(celsius, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(temperatureMenu), celsius) + let fahrenheit = gtk_menu_item_new_with_label( + menuSelectionLabel(useFahrenheit, title: L10n.shared.tr("menu.fahrenheit")) + ) + gtk_widget_set_name(fahrenheit, "temperature:f") + _ = tc_gtk_on_activate(fahrenheit, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(temperatureMenu), fahrenheit) + + let cityRoot = gtk_menu_item_new_with_label(L10n.shared.tr("menu.city")) + let cityMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(cityRoot), cityMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), cityRoot) + for city in Self.cityOptions { + let title: String + if city == "auto" { + let resolved = weatherService.weather.cityName + title = resolved.isEmpty + ? L10n.shared.tr("menu.cityAutoLocating") + : L10n.shared.tr("menu.cityAuto", resolved) + } else { + title = city } - button:hover { background: #d0c6b6; } - """ + let item = gtk_menu_item_new_with_label( + menuSelectionLabel(selectedCity == city, title: title) + ) + gtk_widget_set_name(item, "city:\(city)") + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(cityMenu), item) + } + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + + let timezoneRoot = gtk_menu_item_new_with_label(L10n.shared.tr("menu.timezone")) + let timezoneMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(timezoneRoot), timezoneMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), timezoneRoot) + for option in Self.timezoneOptions { + let item = gtk_menu_item_new_with_label( + menuSelectionLabel( + selectedTimezone == option.identifier, + title: L10n.shared.tr(option.label) + ) + ) + gtk_widget_set_name(item, "timezone:\(option.identifier)") + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(timezoneMenu), item) + timezoneItems[option.identifier] = item + } + + let languageRoot = gtk_menu_item_new_with_label(L10n.shared.tr("menu.language")) + let languageMenu = gtk_menu_new() + gtk_menu_item_set_submenu(tc_gtk_menu_item(languageRoot), languageMenu) + gtk_menu_shell_append(tc_gtk_menu_shell(root), languageRoot) + for language in AppLanguage.allCases { + let item = gtk_menu_item_new_with_label( + menuSelectionLabel(language == L10n.shared.language, title: language.displayName) + ) + gtk_widget_set_name(item, "language:\(language.rawValue)") + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(languageMenu), item) + languageItems[language] = item + } + + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + appendMenuItem(localized(zh: "查看详情", en: "Show Details"), name: "details", to: root) + appendMenuItem(localized(zh: "刷新数据", en: "Refresh Usage"), name: "refresh", to: root) + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + appendMenuItem(L10n.shared.tr("menu.settings"), name: "settings", to: root) + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + appendMenuItem( + menuSelectionLabel(LinuxAutostart.isEnabled, title: L10n.shared.tr("menu.launchAtLogin")), + name: "launch-at-login", to: root ) + gtk_menu_shell_append(tc_gtk_menu_shell(root), gtk_separator_menu_item_new()) + appendMenuItem(L10n.shared.tr("menu.about"), name: "about", to: root) + appendMenuItem(L10n.shared.tr("menu.quit"), name: "quit", to: root) + gtk_widget_show_all(root) + } - gtk_widget_show_all(createdWindow) - gtk_widget_hide(createdDetails) - refreshUI() + private func appendMenuItem(_ title: String, name: String, to menu: UnsafeMutablePointer) { + let item = gtk_menu_item_new_with_label(title) + gtk_widget_set_name(item, name) + _ = tc_gtk_on_activate(item, linuxMenuAction, opaque) + gtk_menu_shell_append(tc_gtk_menu_shell(menu), item) + } + + private func localized(zh: String, en: String) -> String { + L10n.shared.language == .en ? en : zh + } + + private func menuSelectionLabel(_ selected: Bool, title: String) -> String { + selected ? "✓ \(title)" : " \(title)" } fileprivate func scheduleScan(incremental: Bool) { @@ -131,110 +350,331 @@ final class LinuxApp: @unchecked Sendable { } fileprivate func refreshUI() { - guard let summaryLabel, let detailsLabel, let dial else { return } - let tools = model.tools - let tokens = UsageAggregator.totalTokens(tools) - let messages = UsageAggregator.totalMessages(tools) - let active = tools.filter { $0.isActive || $0.todayTokens > 0 } - let rate = UsageAggregator.rateEmoji(tools) - - gtk_label_set_text( - tc_gtk_label(summaryLabel), - "Today \(TokenFormat.compact(tokens)) tokens · \(messages) messages \(rate)" + guard let dial else { return } + updateDetailsPanel() + gtk_widget_queue_draw(dial) + } + + private func updateDetailsPanel() { + detailsPanel?.update( + tools: model.tools, + weather: weatherService.weather, + useFahrenheit: useFahrenheit, + theme: selectedTheme, + size: selectedSize ) + } - let detailText: String - if active.isEmpty { - detailText = "No local AI usage found today." - } else { - detailText = active.prefix(10).map { - let marker = $0.isActive ? "●" : " " - return "\(marker) \($0.emoji) \($0.name.padding(toLength: 15, withPad: " ", startingAt: 0)) \($0.formattedTokens)" - }.joined(separator: "\n") + fileprivate func refreshClock() { + if let dial { gtk_widget_queue_draw(dial) } + } + + fileprivate func draw(_ context: OpaquePointer) { + guard let dial else { return } + renderer.draw( + context, + width: Double(gtk_widget_get_allocated_width(dial)), + height: Double(gtk_widget_get_allocated_height(dial)), + snapshot: LinuxClockSnapshot( + date: Date(), + timeZone: effectiveTimeZone, + tools: model.tools, + weather: weatherService.weather, + useFahrenheit: useFahrenheit, + theme: selectedTheme, + size: selectedSize + ) + ) + } + + fileprivate func handleButtonPress(event: UnsafeMutablePointer) { + switch tc_gtk_event_button(event) { + case 1: + leftButtonDown = true + dragStarted = false + pressRootX = tc_gtk_button_root_x(event) + pressRootY = tc_gtk_button_root_y(event) + case 3: + if let menu { tc_gtk_popup_menu(menu, event) } + default: + break } - gtk_label_set_text(tc_gtk_label(detailsLabel), detailText) - gtk_widget_queue_draw(dial) } - fileprivate func toggleDetails() { - guard let detailsLabel, let window else { return } - detailsVisible.toggle() - if detailsVisible { - gtk_widget_show(detailsLabel) - gtk_window_resize(tc_gtk_window(window), 360, 590) + fileprivate func handleMotion(event: UnsafeMutablePointer) { + guard leftButtonDown, !dragStarted else { return } + let state = tc_gtk_motion_state(event) + guard state & guint(GDK_BUTTON1_MASK.rawValue) != 0 else { return } + let x = tc_gtk_motion_root_x(event) + let y = tc_gtk_motion_root_y(event) + guard hypot(x - pressRootX, y - pressRootY) >= 5 else { return } + dragStarted = true + detailsPanel?.hide() + if let window { + tc_gtk_begin_move_at(window, 1, x, y, tc_gtk_motion_time(event)) + } + } + + fileprivate func handleButtonRelease(event: UnsafeMutablePointer) { + guard tc_gtk_event_button(event) == 1 else { return } + let shouldToggle = leftButtonDown && !dragStarted + leftButtonDown = false + dragStarted = false + if shouldToggle { detailsPanel?.toggle() } + } + + fileprivate func handleMenuAction(widget: UnsafeMutablePointer) { + defer { UserDefaults.standard.synchronize() } + let name = String(cString: tc_gtk_widget_name(widget)) + if name.hasPrefix("theme:"), + let theme = LinuxClockTheme(rawValue: String(name.dropFirst("theme:".count))) { + selectTheme(theme) + return + } + if name.hasPrefix("size:"), + let size = LinuxClockSize(rawValue: String(name.dropFirst("size:".count))) { + selectSize(size) + return + } + if name.hasPrefix("opacity:"), + let percent = Int(name.dropFirst("opacity:".count)) { + setOpacity(Double(percent) / 100) + return + } + if name.hasPrefix("timezone:") { + selectTimezone(String(name.dropFirst("timezone:".count))) + return + } + if name.hasPrefix("language:"), + let language = AppLanguage(rawValue: String(name.dropFirst("language:".count))) { + selectLanguage(language) + return + } + if name.hasPrefix("temperature:") { + setFahrenheit(name.hasSuffix(":f")) + return + } + if name.hasPrefix("city:") { + selectCity(String(name.dropFirst("city:".count))) + return + } + if name.hasPrefix("custom-theme:"), + let id = UUID(uuidString: String(name.dropFirst("custom-theme:".count))) { + applyCustomTheme(id: id) + return + } + switch name { + case "theme-picker": themePicker?.show(selected: selectedTheme) + case "copy-api": + let endpoint = Self.apiEndpointURL + endpoint.withCString { tc_gtk_clipboard_set_text($0) } + print("[TokenClock] Copied \(endpoint)") + case "always-on-top": toggleAlwaysOnTop() + case "details": toggleDetails() + case "refresh": scheduleScan(incremental: true) + case "settings": showSettings() + case "launch-at-login": toggleAutostart() + case "about": + if let window { tc_gtk_show_about(window, "normal · Linux") } + case "quit": + shutdown() + gtk_main_quit() + default: break + } + } + + private func selectTheme(_ theme: LinuxClockTheme) { + selectedTheme = theme + UserDefaults.standard.setString(theme.rawValue, for: .selectedTheme) + for (candidate, item) in themeItems { + gtk_menu_item_set_label( + tc_gtk_menu_item(item), + menuSelectionLabel(candidate == theme, title: candidate.displayName) + ) + } + print("[TokenClock] Theme changed to \(theme.rawValue)") + updateDetailsPanel() + refreshUI() + } + + func chooseThemeFromPicker(_ theme: LinuxClockTheme) { + selectTheme(theme) + } + + func applyCustomTheme(id: UUID?) { + if let id { + guard LinuxCustomThemeStore.shared.apply(id: id) else { return } } else { - gtk_widget_hide(detailsLabel) - gtk_window_resize(tc_gtk_window(window), 360, 430) + UserDefaults.standard.remove(.activeCustomThemeId) + } + selectTheme(.custom) + rebuildContextMenu() + } + + func customThemeListChanged() { + if selectedTheme == .custom, + let active = UserDefaults.standard.string(for: .activeCustomThemeId), + !LinuxCustomThemeStore.shared.themes.contains(where: { $0.id.uuidString == active }) { + selectTheme(.classic) } + rebuildContextMenu() } - fileprivate func beginMove(event: UnsafeMutablePointer) { + private func selectSize(_ size: LinuxClockSize) { + selectedSize = size + UserDefaults.standard.setString(size.rawValue, for: .clockSize) + UserDefaults.standard.setBool(true, for: .clockSizeUserChosen) + for (candidate, item) in sizeItems { + gtk_menu_item_set_label( + tc_gtk_menu_item(item), + menuSelectionLabel(candidate == size, title: candidate.displayName) + ) + } + guard let window, let dial else { return } + let diameter = gint(size.diameter) + gtk_widget_set_size_request(dial, diameter, diameter) + gtk_window_resize(tc_gtk_window(window), diameter, diameter) + detailsPanel?.hide() + updateDetailsPanel() + _ = tc_gtk_idle_add(linuxShapeFinished, opaque) + refreshUI() + } + + fileprivate func applyCircularShape() { guard let window else { return } - tc_gtk_begin_move(window, event) + tc_gtk_shape_circle(window, gint(selectedSize.diameter)) } - fileprivate func draw(_ context: OpaquePointer) { - guard let dial else { return } - let width = Double(gtk_widget_get_allocated_width(dial)) - let height = Double(gtk_widget_get_allocated_height(dial)) - let centerX = width / 2 - let centerY = height / 2 - let radius = min(width, height) / 2 - 10 - - cairo_set_line_cap(context, CAIRO_LINE_CAP_ROUND) - cairo_arc(context, centerX, centerY, radius, 0, Double.pi * 2) - tc_cairo_set_source_rgba(context, 0.91, 0.88, 0.81, 1) - cairo_fill_preserve(context) - tc_cairo_set_source_rgba(context, 0.25, 0.24, 0.21, 1) - cairo_set_line_width(context, 3) - cairo_stroke(context) - - for tick in 0..<60 { - let angle = Double(tick) * Double.pi / 30 - Double.pi / 2 - let isHour = tick % 5 == 0 - let inner = radius - (isHour ? 18 : 10) - cairo_move_to(context, centerX + cos(angle) * inner, centerY + sin(angle) * inner) - cairo_line_to(context, centerX + cos(angle) * (radius - 4), centerY + sin(angle) * (radius - 4)) - cairo_set_line_width(context, isHour ? 3 : 1) - cairo_stroke(context) - } - - let components = Calendar.current.dateComponents([.hour, .minute, .second], from: Date()) - let second = Double(components.second ?? 0) - let minute = Double(components.minute ?? 0) + second / 60 - let hour = Double((components.hour ?? 0) % 12) + minute / 60 - drawHand(context, centerX, centerY, angle: hour * Double.pi / 6 - Double.pi / 2, length: radius * 0.50, width: 6, red: 0.12, green: 0.12, blue: 0.11) - drawHand(context, centerX, centerY, angle: minute * Double.pi / 30 - Double.pi / 2, length: radius * 0.72, width: 4, red: 0.12, green: 0.12, blue: 0.11) - drawHand(context, centerX, centerY, angle: second * Double.pi / 30 - Double.pi / 2, length: radius * 0.78, width: 2, red: 0.78, green: 0.20, blue: 0.12) - - cairo_arc(context, centerX, centerY, 6, 0, Double.pi * 2) - tc_cairo_set_source_rgba(context, 0.18, 0.17, 0.15, 1) - cairo_fill(context) - - let total = TokenFormat.compact(UsageAggregator.totalTokens(model.tools)) - cairo_select_font_face(context, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD) - cairo_set_font_size(context, 20) - tc_cairo_set_source_rgba(context, 0.20, 0.19, 0.17, 0.92) - cairo_move_to(context, centerX - Double(total.count) * 6, centerY + radius * 0.52) - cairo_show_text(context, total) - } - - private func drawHand( - _ context: OpaquePointer, - _ centerX: Double, - _ centerY: Double, - angle: Double, - length: Double, - width: Double, - red: Double, - green: Double, - blue: Double - ) { - cairo_move_to(context, centerX, centerY) - cairo_line_to(context, centerX + cos(angle) * length, centerY + sin(angle) * length) - tc_cairo_set_source_rgba(context, red, green, blue, 1) - cairo_set_line_width(context, width) - cairo_stroke(context) + private func toggleDetails() { + detailsPanel?.toggle() + } + + private var effectiveTimeZone: TimeZone { + selectedTimezone == "auto" + ? .current + : (TimeZone(identifier: selectedTimezone) ?? .current) + } + + private func setOpacity(_ opacity: Double) { + windowOpacity = min(1, max(0.25, opacity)) + UserDefaults.standard.set(windowOpacity, forKey: SettingsKey.windowOpacity.rawValue) + if let window { gtk_widget_set_opacity(window, windowOpacity) } + detailsPanel?.setOpacity(windowOpacity) + for (value, item) in opacityItems { + gtk_menu_item_set_label( + tc_gtk_menu_item(item), + menuSelectionLabel(value == Int(windowOpacity * 100), title: "\(value)%") + ) + } + } + + private func toggleAlwaysOnTop() { + alwaysOnTop.toggle() + UserDefaults.standard.setBool(alwaysOnTop, for: .alwaysOnTop) + if let window { gtk_window_set_keep_above(tc_gtk_window(window), alwaysOnTop ? 1 : 0) } + detailsPanel?.setKeepAbove(alwaysOnTop) + rebuildContextMenu() + } + + private func selectTimezone(_ identifier: String) { + selectedTimezone = identifier + UserDefaults.standard.setString(identifier, for: .selectedTimezone) + refreshClock() + rebuildContextMenu() + } + + private func selectLanguage(_ language: AppLanguage) { + L10n.shared.language = language + updateDetailsPanel() + themePicker?.refreshLanguage() + settingsWindow?.refreshLanguage() + rebuildContextMenu() + refreshClock() + } + + private func setFahrenheit(_ enabled: Bool) { + useFahrenheit = enabled + UserDefaults.standard.setBool(enabled, for: .useFahrenheit) + refreshUI() + rebuildContextMenu() + } + + private func selectCity(_ city: String) { + selectedCity = city + UserDefaults.standard.setString(city, for: .selectedCity) + refreshWeather() + rebuildContextMenu() + } + + fileprivate func refreshWeather() { + weatherService.fetch(city: selectedCity) { [weak self] in + guard let self else { return } + _ = tc_gtk_idle_add(linuxWeatherFinished, self.opaque) + } + } + + fileprivate func weatherDidFinish() { + refreshUI() + rebuildContextMenu() + } + + private func toggleAutostart() { + do { + try LinuxAutostart.setEnabled(!LinuxAutostart.isEnabled) + UserDefaults.standard.setBool(LinuxAutostart.isEnabled, for: .launchAtLogin) + rebuildContextMenu() + } catch { + guard let window else { return } + localized(zh: "无法更改开机自启", en: "Unable to change autostart").withCString { title in + error.localizedDescription.withCString { message in + tc_gtk_show_message(window, GTK_MESSAGE_ERROR, title, message) + } + } + } + } + + private func rebuildContextMenu() { + let oldMenu = menu + menu = nil + buildContextMenu() + if let oldMenu { gtk_widget_destroy(oldMenu) } + } + + private func showSettings() { + settingsWindow?.show() + } + + func openThemePickerFromSettings() { + settingsWindow?.hide() + themePicker?.show(selected: selectedTheme) + } + + func settingsDidSave(apiChanged: Bool) { + UserDefaults.standard.synchronize() + if apiChanged { + apiServer?.stop() + apiServer = nil + startAPIServerIfEnabled() + } + scheduleScan(incremental: false) + refreshUI() + rebuildContextMenu() + } + + private static func resolveAPIServerPort() -> UInt16 { + let raw = UserDefaults.standard.integer(forKey: SettingsKey.apiServerPort.rawValue) + if raw > 0, raw <= Int(UInt16.max) { return UInt16(raw) } + return AppConfig.LocalServer.defaultPort + } + + private static var apiEndpointURL: String { + "http://localhost:\(resolveAPIServerPort())\(AppConfig.LocalServer.usageEndpoint)" + } + + private func startAPIServerIfEnabled() { + guard UserDefaults.standard.bool(for: .apiServerEnabled, default: true) else { return } + apiServer = LinuxAPIServer(model: model, port: Self.resolveAPIServerPort()) + apiServer?.start() } fileprivate func shutdown() { @@ -245,6 +685,9 @@ final class LinuxApp: @unchecked Sendable { UserDefaults.standard.set(Int(x), forKey: "TCLinuxWindowX") UserDefaults.standard.set(Int(y), forKey: "TCLinuxWindowY") } + // swift-corelibs-foundation flushes defaults less eagerly than the + // Darwin implementation, so persist all Linux preferences on exit. + UserDefaults.standard.synchronize() apiServer?.stop() } } @@ -259,34 +702,39 @@ private func linuxDestroy(_ widget: UnsafeMutablePointer?, _ data: gp gtk_main_quit() } -private func linuxQuit(_ widget: UnsafeMutablePointer?, _ data: gpointer?) { - app(from: data)?.shutdown() - gtk_main_quit() -} - -private func linuxRefresh(_ widget: UnsafeMutablePointer?, _ data: gpointer?) { - app(from: data)?.scheduleScan(incremental: true) +private func linuxButtonPress( + _ widget: UnsafeMutablePointer?, + _ event: UnsafeMutablePointer?, + _ data: gpointer? +) -> gboolean { + guard let event else { return 0 } + app(from: data)?.handleButtonPress(event: event) + return 1 } -private func linuxToggleDetails(_ widget: UnsafeMutablePointer?, _ data: gpointer?) { - app(from: data)?.toggleDetails() +private func linuxButtonRelease( + _ widget: UnsafeMutablePointer?, + _ event: UnsafeMutablePointer?, + _ data: gpointer? +) -> gboolean { + guard let event else { return 0 } + app(from: data)?.handleButtonRelease(event: event) + return 1 } -private func linuxButtonPress( +private func linuxMotion( _ widget: UnsafeMutablePointer?, - _ event: UnsafeMutablePointer?, + _ event: UnsafeMutablePointer?, _ data: gpointer? ) -> gboolean { guard let event else { return 0 } - if tc_gtk_event_button(event) == 1 { - app(from: data)?.beginMove(event: event) - return 1 - } - if tc_gtk_event_button(event) == 3 { - app(from: data)?.toggleDetails() - return 1 - } - return 0 + app(from: data)?.handleMotion(event: event) + return 1 +} + +private func linuxMenuAction(_ widget: UnsafeMutablePointer?, _ data: gpointer?) { + guard let widget else { return } + app(from: data)?.handleMenuAction(widget: widget) } private func linuxDraw( @@ -300,22 +748,34 @@ private func linuxDraw( } private func linuxClockTick(_ data: gpointer?) -> gboolean { - if let app = app(from: data) { - app.refreshUI() - return 1 - } - return 0 + guard let app = app(from: data) else { return 0 } + app.refreshClock() + return 1 } private func linuxScanTick(_ data: gpointer?) -> gboolean { - if let app = app(from: data) { - app.scheduleScan(incremental: true) - return 1 - } - return 0 + guard let app = app(from: data) else { return 0 } + app.scheduleScan(incremental: true) + return 1 } private func linuxScanFinished(_ data: gpointer?) -> gboolean { app(from: data)?.refreshUI() return 0 } + +private func linuxWeatherTick(_ data: gpointer?) -> gboolean { + guard let app = app(from: data) else { return 0 } + app.refreshWeather() + return 1 +} + +private func linuxWeatherFinished(_ data: gpointer?) -> gboolean { + app(from: data)?.weatherDidFinish() + return 0 +} + +private func linuxShapeFinished(_ data: gpointer?) -> gboolean { + app(from: data)?.applyCircularShape() + return 0 +} diff --git a/Sources/TokenClock/Linux/LinuxAutostart.swift b/Sources/TokenClock/Linux/LinuxAutostart.swift new file mode 100644 index 0000000..4a95cfb --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxAutostart.swift @@ -0,0 +1,55 @@ +import Foundation + +/// XDG autostart counterpart of macOS's LaunchAgent helper. +enum LinuxAutostart { + private static var desktopFileURL: URL { + let environment = ProcessInfo.processInfo.environment + let configDirectory: URL + if let configured = environment["XDG_CONFIG_HOME"], !configured.isEmpty { + configDirectory = URL(fileURLWithPath: configured, isDirectory: true) + } else { + configDirectory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config", isDirectory: true) + } + return configDirectory + .appendingPathComponent("autostart", isDirectory: true) + .appendingPathComponent("tokenclock.desktop", isDirectory: false) + } + + static var isEnabled: Bool { + FileManager.default.fileExists(atPath: desktopFileURL.path) + } + + static func setEnabled(_ enabled: Bool) throws { + let manager = FileManager.default + let url = desktopFileURL + if !enabled { + if manager.fileExists(atPath: url.path) { try manager.removeItem(at: url) } + return + } + + try manager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // AppImage exposes its stable outer path in APPIMAGE; argv[0] points into + // a temporary mount and must never be persisted in an autostart file. + let executable = ProcessInfo.processInfo.environment["APPIMAGE"] + ?? ProcessInfo.processInfo.arguments.first + ?? "/usr/bin/tokenclock" + let quotedExecutable = "\"\(executable.replacingOccurrences(of: "\"", with: "\\\""))\"" + let desktopFile = """ + [Desktop Entry] + Type=Application + Version=1.0 + Name=TokenClock + Comment=Beautiful local AI token usage clock + Exec=\(quotedExecutable) + Terminal=false + Categories=Utility; + X-GNOME-Autostart-enabled=true + + """ + try desktopFile.write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Sources/TokenClock/Linux/LinuxClockRenderer.swift b/Sources/TokenClock/Linux/LinuxClockRenderer.swift new file mode 100644 index 0000000..e71999e --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxClockRenderer.swift @@ -0,0 +1,501 @@ +import Foundation +import CGtk + +struct LinuxClockSnapshot { + let date: Date + let timeZone: TimeZone + let tools: [ToolUsage] + let weather: WeatherInfo + let useFahrenheit: Bool + let theme: LinuxClockTheme + let size: LinuxClockSize +} + +/// Cairo/Pango port of macOS normal's `ClockFaceView` + `ClockContentView`. +final class LinuxClockRenderer: @unchecked Sendable { + private let glassSurface: OpaquePointer? + + init() { + guard let url = Bundle.module.url(forResource: "glass_disc", withExtension: "png") else { + glassSurface = nil + return + } + glassSurface = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return nil } + let surface = cairo_image_surface_create_from_png(path) + guard cairo_surface_status(surface) == CAIRO_STATUS_SUCCESS else { + cairo_surface_destroy(surface) + return nil + } + return surface + } + } + + deinit { + if let glassSurface { cairo_surface_destroy(glassSurface) } + } + + func draw(_ context: OpaquePointer, width: Double, height: Double, snapshot: LinuxClockSnapshot) { + cairo_save(context) + cairo_set_operator(context, CAIRO_OPERATOR_SOURCE) + setSource(context, .clear) + cairo_paint(context) + cairo_restore(context) + + let centerX = width / 2 + let centerY = height / 2 + let radius = min(width, height) / 2 - 4 + let theme = snapshot.theme + + drawDial(context, centerX, centerY, radius, theme) + if theme.hasDialDecoration { + drawSkyDecoration(context, centerX, centerY, radius) + } + drawTickMarks(context, centerX, centerY, radius, theme) + drawNumbers(context, centerX, centerY, radius, theme, snapshot.size.scale) + drawHands(context, centerX, centerY, radius, theme, snapshot.date, snapshot.timeZone) + drawCenterDot(context, centerX, centerY, theme) + drawOverlay(context, width, height, snapshot) + } + + /// Compact face-only rendering used by Linux's visual clock-face picker. + func drawPreview( + _ context: OpaquePointer, + width: Double, + height: Double, + theme: LinuxClockTheme, + date: Date = Date() + ) { + let centerX = width / 2 + let centerY = height / 2 + let radius = min(width, height) / 2 - 3 + let scale = min(width, height) / 400 + drawDial(context, centerX, centerY, radius, theme) + if theme.hasDialDecoration { drawSkyDecoration(context, centerX, centerY, radius) } + drawTickMarks(context, centerX, centerY, radius, theme) + drawNumbers(context, centerX, centerY, radius, theme, scale) + drawHands(context, centerX, centerY, radius, theme, date, .current) + drawCenterDot(context, centerX, centerY, theme) + } + + private func drawDial( + _ context: OpaquePointer, + _ centerX: Double, + _ centerY: Double, + _ radius: Double, + _ theme: LinuxClockTheme + ) { + if theme == .glass, let glassSurface { + let sourceWidth = Double(cairo_image_surface_get_width(glassSurface)) + let sourceHeight = Double(cairo_image_surface_get_height(glassSurface)) + cairo_save(context) + cairo_translate(context, centerX - radius, centerY - radius) + cairo_scale(context, radius * 2 / sourceWidth, radius * 2 / sourceHeight) + cairo_set_source_surface(context, glassSurface, 0, 0) + cairo_paint(context) + cairo_restore(context) + return + } + + circle(context, centerX, centerY, radius) + setSource(context, theme.dialColor) + cairo_fill_preserve(context) + if theme.dialRimWidth > 0, theme.dialRimColor.alpha > 0 { + setSource(context, theme.dialRimColor) + cairo_set_line_width(context, theme.dialRimWidth) + cairo_set_line_cap(context, CAIRO_LINE_CAP_ROUND) + cairo_stroke(context) + } else { + cairo_new_path(context) + } + } + + private func drawTickMarks( + _ context: OpaquePointer, + _ centerX: Double, + _ centerY: Double, + _ radius: Double, + _ theme: LinuxClockTheme + ) { + for value in 1...12 { + let angle = Double(value) * Double.pi / 6 - Double.pi / 2 + let isMajor = value % 3 == 0 + let innerRadius = radius * (isMajor ? 0.91 : 0.935) + let outerRadius = radius * 0.97 + let color = isMajor ? theme.majorTickMarkColor : theme.tickMarkColor + guard color.alpha > 0 else { continue } + cairo_move_to(context, centerX + innerRadius * cos(angle), centerY + innerRadius * sin(angle)) + cairo_line_to(context, centerX + outerRadius * cos(angle), centerY + outerRadius * sin(angle)) + setSource(context, color) + cairo_set_line_width(context, isMajor ? 2 : 1.2) + cairo_set_line_cap(context, CAIRO_LINE_CAP_ROUND) + cairo_stroke(context) + } + } + + private func drawNumbers( + _ context: OpaquePointer, + _ centerX: Double, + _ centerY: Double, + _ radius: Double, + _ theme: LinuxClockTheme, + _ scale: Double + ) { + guard theme.numberColor.alpha > 0 else { return } + let chinese = ["", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖", "拾", "拾壹", "拾贰"] + let numberRadius = radius * 0.84 + for value in 1...12 { + let angle = Double(value) * Double.pi / 6 - Double.pi / 2 + let label = theme.numberStyle == .chinese ? chinese[value] : "\(value)" + drawText( + context, + label, + family: theme.numberFontFamily, + size: 13 * scale, + weight: 500, + x: centerX + numberRadius * cos(angle), + y: centerY + numberRadius * sin(angle), + alignment: 1, + color: theme.numberColor + ) + } + } + + private func drawHands( + _ context: OpaquePointer, + _ centerX: Double, + _ centerY: Double, + _ radius: Double, + _ theme: LinuxClockTheme, + _ date: Date, + _ timeZone: TimeZone + ) { + var calendar = Calendar.current + calendar.timeZone = timeZone + let components = calendar.dateComponents([.hour, .minute, .second], from: date) + let seconds = Double(components.second ?? 0) + let minutes = Double(components.minute ?? 0) + let hours = Double((components.hour ?? 0) % 12) + let hourAngle = (hours * 30 + minutes * 0.5) * Double.pi / 180 - Double.pi / 2 + let minuteAngle = minutes * Double.pi / 30 - Double.pi / 2 + let secondAngle = seconds * Double.pi / 30 - Double.pi / 2 + + switch theme.handStyle { + case .round: + roundHand(context, centerX, centerY, radius * theme.hourHandLength, hourAngle, theme.hourHandWidth, theme.hourHandColor) + roundHand(context, centerX, centerY, radius * theme.minuteHandLength, minuteAngle, theme.minuteHandWidth, theme.minuteHandColor) + roundHand(context, centerX, centerY, radius * theme.secondHandLength, secondAngle, theme.secondHandWidth, theme.secondHandColor) + case .tapered: + taperedHand(context, centerX, centerY, radius * theme.hourHandLength, hourAngle, theme.hourHandWidth, 1.5, theme.hourHandColor) + taperedHand(context, centerX, centerY, radius * theme.minuteHandLength, minuteAngle, theme.minuteHandWidth, 1.2, theme.minuteHandColor) + roundHand(context, centerX, centerY, radius * theme.secondHandLength, secondAngle, theme.secondHandWidth, theme.secondHandColor) + case .lance: + lanceHand(context, centerX, centerY, radius * theme.hourHandLength, hourAngle, theme.hourHandWidth, theme.hourHandColor) + lanceHand(context, centerX, centerY, radius * theme.minuteHandLength, minuteAngle, theme.minuteHandWidth, theme.minuteHandColor) + roundHand(context, centerX, centerY, radius * theme.secondHandLength, secondAngle, theme.secondHandWidth, theme.secondHandColor) + case .sword: + swordHand(context, centerX, centerY, radius * theme.hourHandLength, hourAngle, theme.hourHandWidth, theme.hourHandColor) + swordHand(context, centerX, centerY, radius * theme.minuteHandLength, minuteAngle, theme.minuteHandWidth, theme.minuteHandColor) + swordHand(context, centerX, centerY, radius * theme.secondHandLength, secondAngle, theme.secondHandWidth, theme.secondHandColor) + } + } + + private func drawCenterDot( + _ context: OpaquePointer, + _ centerX: Double, + _ centerY: Double, + _ theme: LinuxClockTheme + ) { + circle(context, centerX, centerY, 4) + setSource(context, theme.centerDotOuterColor) + cairo_fill(context) + circle(context, centerX, centerY, 2) + setSource(context, theme.centerDotInnerColor) + cairo_fill(context) + } + + private func drawOverlay( + _ context: OpaquePointer, + _ width: Double, + _ height: Double, + _ snapshot: LinuxClockSnapshot + ) { + let scale = snapshot.size.scale + let theme = snapshot.theme + let tools = snapshot.tools + let totalTokens = TokenFormat.compact(UsageAggregator.totalTokens(tools)) + let totalMessages = UsageAggregator.totalMessages(tools) + + let formatter = DateFormatter() + formatter.timeZone = snapshot.timeZone + switch L10n.shared.language { + case .zhHans: + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "M月d日 EEEE" + case .zhHant: + formatter.locale = Locale(identifier: "zh_TW") + formatter.dateFormat = "M月d日 EEEE" + case .en: + formatter.locale = Locale(identifier: "en_US") + formatter.setLocalizedDateFormatFromTemplate("EEEEMMMd") + } + let dateText = formatter.string(from: snapshot.date) + drawText(context, dateText, family: "Sans", size: 11 * scale, weight: 500, + x: width / 2, y: 64 * scale, alignment: 1, color: theme.textSecondaryColor) + + if !snapshot.weather.cityName.isEmpty { + let temperature: Int + let unit: String + if snapshot.useFahrenheit { + temperature = Int(Double(snapshot.weather.temperature) * 9 / 5 + 32) + unit = "F" + } else { + temperature = snapshot.weather.temperature + unit = "C" + } + drawText( + context, + "\(snapshot.weather.emoji) \(temperature)°\(unit)", + family: "Noto Color Emoji, Sans", + size: 13 * scale, + weight: 400, + x: width / 2, + y: 82 * scale, + alignment: 1, + color: theme.textPrimaryColor + ) + } + + drawText(context, L10n.shared.tr("clock.todayTokens"), family: "Sans", size: 9 * scale, weight: 400, + x: width / 2, y: height - 91 * scale, alignment: 1, color: theme.textSecondaryColor) + drawText(context, totalTokens, family: "Sans", size: 20 * scale, weight: 700, + x: width / 2, y: height - 71 * scale, alignment: 1, color: theme.textPrimaryColor) + drawText(context, L10n.shared.tr("clock.messagesCount", totalMessages), family: "Sans", size: 10 * scale, weight: 400, + x: width / 2, y: height - 51 * scale, alignment: 1, color: theme.textSecondaryColor) + + let activeTools = UsageAggregator.topToolsByTokens(tools, limit: 2) + let rowSpacing = 17 * scale + let firstY = height / 2 - Double(max(0, activeTools.count - 1)) * rowSpacing / 2 + for (index, tool) in activeTools.enumerated() { + drawText(context, "\(tool.emoji) \(tool.abbreviation)", family: "Sans", size: 13 * scale, weight: 600, + x: 22 * scale, y: firstY + Double(index) * rowSpacing, alignment: 0, + color: withAlpha(theme.textPrimaryColor, theme.textPrimaryColor.alpha * 0.75)) + } + + drawText(context, UsageAggregator.rateEmoji(tools), family: "Noto Color Emoji, Emoji, Sans", size: 28 * scale, weight: 400, + x: width - 22 * scale, y: height / 2, alignment: 2, color: LinuxColor(1, 1, 1)) + } + + private func roundHand( + _ context: OpaquePointer, _ centerX: Double, _ centerY: Double, + _ radius: Double, _ angle: Double, _ width: Double, _ color: LinuxColor + ) { + cairo_move_to(context, centerX, centerY) + cairo_line_to(context, centerX + radius * cos(angle), centerY + radius * sin(angle)) + setSource(context, color) + cairo_set_line_width(context, width) + cairo_set_line_cap(context, CAIRO_LINE_CAP_ROUND) + cairo_stroke(context) + } + + private func taperedHand( + _ context: OpaquePointer, _ centerX: Double, _ centerY: Double, + _ radius: Double, _ angle: Double, _ baseWidth: Double, _ tipWidth: Double, _ color: LinuxColor + ) { + let endX = centerX + radius * cos(angle) + let endY = centerY + radius * sin(angle) + let backRadius = radius * 0.15 + let backX = centerX - backRadius * cos(angle) + let backY = centerY - backRadius * sin(angle) + let dx = cos(angle + Double.pi / 2) + let dy = sin(angle + Double.pi / 2) + cairo_move_to(context, backX + dx * baseWidth / 2, backY + dy * baseWidth / 2) + cairo_line_to(context, endX + dx * tipWidth / 2, endY + dy * tipWidth / 2) + cairo_line_to(context, endX - dx * tipWidth / 2, endY - dy * tipWidth / 2) + cairo_line_to(context, backX - dx * baseWidth / 2, backY - dy * baseWidth / 2) + cairo_close_path(context) + setSource(context, color) + cairo_fill(context) + } + + private func lanceHand( + _ context: OpaquePointer, _ centerX: Double, _ centerY: Double, + _ radius: Double, _ angle: Double, _ width: Double, _ color: LinuxColor + ) { + let endX = centerX + radius * cos(angle) + let endY = centerY + radius * sin(angle) + let middleX = centerX + radius * 0.35 * cos(angle) + let middleY = centerY + radius * 0.35 * sin(angle) + let backX = centerX - radius * 0.12 * cos(angle) + let backY = centerY - radius * 0.12 * sin(angle) + let dx = cos(angle + Double.pi / 2) + let dy = sin(angle + Double.pi / 2) + cairo_move_to(context, backX, backY) + cairo_line_to(context, middleX + dx * width / 2, middleY + dy * width / 2) + cairo_line_to(context, endX, endY) + cairo_line_to(context, middleX - dx * width / 2, middleY - dy * width / 2) + cairo_close_path(context) + setSource(context, color) + cairo_fill(context) + } + + private func swordHand( + _ context: OpaquePointer, _ centerX: Double, _ centerY: Double, + _ radius: Double, _ angle: Double, _ width: Double, _ color: LinuxColor + ) { + func point(_ along: Double, _ across: Double) -> (Double, Double) { + let dx = cos(angle + Double.pi / 2) + let dy = sin(angle + Double.pi / 2) + return ( + centerX + along * cos(angle) + across * dx, + centerY + along * sin(angle) + across * dy + ) + } + + let halfWidth = width / 2 + let guardWidth = width * 0.9 + let handleWidth = width * 0.35 + let pommelWidth = width * 0.45 + let points = [ + point(-radius * 0.16, pommelWidth), + point(-radius * 0.12, handleWidth), + point(radius * 0.02, guardWidth), + point(radius * 0.10, guardWidth), + point(radius * 0.10, halfWidth), + point(radius * 0.85, halfWidth), + point(radius, 0), + point(radius * 0.85, -halfWidth), + point(radius * 0.10, -halfWidth), + point(radius * 0.10, -guardWidth), + point(radius * 0.02, -guardWidth), + point(-radius * 0.12, -handleWidth), + point(-radius * 0.16, -pommelWidth), + ] + cairo_move_to(context, points[0].0, points[0].1) + for point in points.dropFirst() { cairo_line_to(context, point.0, point.1) } + cairo_close_path(context) + setSource(context, color) + cairo_fill(context) + } + + private func drawSkyDecoration( + _ context: OpaquePointer, + _ centerX: Double, + _ centerY: Double, + _ radius: Double + ) { + let sunAngle = -55.0 * Double.pi / 180 + let sunX = centerX + radius * 0.62 * cos(sunAngle) + let sunY = centerY + radius * 0.62 * sin(sunAngle) + let sunSize = radius * 0.10 + circle(context, sunX, sunY, sunSize * 1.5) + setSource(context, LinuxColor(1.0, 0.920, 0.600, 0.25)) + cairo_fill(context) + circle(context, sunX, sunY, sunSize) + setSource(context, LinuxColor(1.0, 0.850, 0.300)) + cairo_fill(context) + + drawCloud(context, centerX - radius * 0.38, centerY - radius * 0.28, radius * 0.10, LinuxColor(0.95, 0.95, 0.95)) + drawCloud(context, centerX + radius * 0.25, centerY + radius * 0.32, radius * 0.07, LinuxColor(0.95, 0.95, 0.95, 0.7)) + drawCloud(context, centerX - radius * 0.10, centerY + radius * 0.50, radius * 0.055, LinuxColor(0.95, 0.95, 0.95, 0.5)) + drawBird(context, centerX - radius * 0.35, centerY + radius * 0.18, radius * 0.05, LinuxColor(0.280, 0.380, 0.500)) + drawBird(context, centerX - radius * 0.22, centerY + radius * 0.10, radius * 0.035, LinuxColor(0.280, 0.380, 0.500, 0.6)) + } + + private func drawCloud( + _ context: OpaquePointer, _ centerX: Double, _ centerY: Double, + _ scale: Double, _ color: LinuxColor + ) { + ellipse(context, centerX - scale * 1.1, centerY - scale * 0.4, scale * 1.4, scale) + setSource(context, color) + cairo_fill(context) + ellipse(context, centerX - scale * 0.4, centerY - scale * 0.9, scale * 1.2, scale) + setSource(context, color) + cairo_fill(context) + ellipse(context, centerX + scale * 0.2, centerY - scale * 0.3, scale * 1.2, scale * 0.9) + setSource(context, color) + cairo_fill(context) + } + + private func drawBird( + _ context: OpaquePointer, _ centerX: Double, _ centerY: Double, + _ scale: Double, _ color: LinuxColor + ) { + let start = (centerX - scale, centerY + scale * 0.3) + let middle = (centerX, centerY - scale * 0.2) + let end = (centerX + scale, centerY + scale * 0.3) + let control1 = (centerX - scale * 0.3, centerY - scale * 0.5) + let control2 = (centerX + scale * 0.3, centerY - scale * 0.5) + cairo_move_to(context, start.0, start.1) + quadraticCurve(context, from: start, control: control1, to: middle) + quadraticCurve(context, from: middle, control: control2, to: end) + setSource(context, color) + cairo_set_line_width(context, scale * 0.25) + cairo_set_line_cap(context, CAIRO_LINE_CAP_ROUND) + cairo_stroke(context) + } + + private func quadraticCurve( + _ context: OpaquePointer, + from: (Double, Double), + control: (Double, Double), + to: (Double, Double) + ) { + cairo_curve_to( + context, + from.0 + (control.0 - from.0) * 2 / 3, + from.1 + (control.1 - from.1) * 2 / 3, + to.0 + (control.0 - to.0) * 2 / 3, + to.1 + (control.1 - to.1) * 2 / 3, + to.0, + to.1 + ) + } + + private func drawText( + _ context: OpaquePointer, + _ text: String, + family: String, + size: Double, + weight: Int, + x: Double, + y: Double, + alignment: Int, + color: LinuxColor + ) { + text.withCString { textPointer in + family.withCString { familyPointer in + tc_cairo_draw_text( + context, textPointer, familyPointer, size, gint(weight), x, y, gint(alignment), + color.red, color.green, color.blue, color.alpha + ) + } + } + } + + private func circle(_ context: OpaquePointer, _ x: Double, _ y: Double, _ radius: Double) { + cairo_arc(context, x, y, radius, 0, Double.pi * 2) + } + + private func ellipse( + _ context: OpaquePointer, + _ x: Double, + _ y: Double, + _ width: Double, + _ height: Double + ) { + cairo_save(context) + cairo_translate(context, x + width / 2, y + height / 2) + cairo_scale(context, width / 2, height / 2) + cairo_arc(context, 0, 0, 1, 0, Double.pi * 2) + cairo_restore(context) + } + + private func setSource(_ context: OpaquePointer, _ color: LinuxColor) { + tc_cairo_set_source_rgba(context, color.red, color.green, color.blue, color.alpha) + } + + private func withAlpha(_ color: LinuxColor, _ alpha: Double) -> LinuxColor { + LinuxColor(color.red, color.green, color.blue, alpha) + } +} diff --git a/Sources/TokenClock/Linux/LinuxClockTheme.swift b/Sources/TokenClock/Linux/LinuxClockTheme.swift new file mode 100644 index 0000000..77d7240 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxClockTheme.swift @@ -0,0 +1,398 @@ +import Foundation + +struct LinuxColor: Sendable { + let red: Double + let green: Double + let blue: Double + let alpha: Double + + init(_ red: Double, _ green: Double, _ blue: Double, _ alpha: Double = 1) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + static let clear = LinuxColor(0, 0, 0, 0) +} + +/// Linux normal 的内置主题镜像。数值与 macOS normal 的 `ClockFaceTheme` 保持一致; +/// 自定义主题是编辑器生成的数据,并非固定表盘,因此不列入内置主题菜单。 +enum LinuxClockTheme: String, CaseIterable, Sendable { + case glass + case classic + case glacier + case midnight + case luxe + case gufeng + case railgun + case sky + case custom + + enum HandStyle: Sendable { case round, tapered, lance, sword } + enum NumberStyle: Sendable { case arabic, chinese } + + static let builtInCases: [LinuxClockTheme] = [ + .glass, .classic, .glacier, .midnight, .luxe, .gufeng, .railgun, .sky, + ] + + private var custom: LinuxCustomThemeConfig { LinuxCustomThemeStore.shared.config } + + var displayName: String { L10n.shared.tr("themeName.\(rawValue)") } + + var dialColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.91, 0.93, 0.96) + case .classic: return LinuxColor(0.97, 0.97, 0.98) + case .glacier: return LinuxColor(0.96, 0.96, 0.96) + case .midnight: return LinuxColor(0.106, 0.157, 0.220) + case .luxe: return LinuxColor(0.102, 0.102, 0.180) + case .gufeng: return LinuxColor(0.925, 0.886, 0.812) + case .railgun: return LinuxColor(0.918, 0.898, 0.855) + case .sky: return LinuxColor(0.529, 0.745, 0.922) + case .custom: return custom.dialColor.linuxColor + } + } + + var dialRimColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.85, 0.85, 0.85) + case .classic: return LinuxColor(0.82, 0.82, 0.82) + case .glacier: return .clear + case .midnight: return LinuxColor(0.165, 0.247, 0.373) + case .luxe: return LinuxColor(0.176, 0.176, 0.267) + case .gufeng: return LinuxColor(0.580, 0.400, 0.247) + case .railgun: return LinuxColor(0.620, 0.470, 0.380) + case .sky: return LinuxColor(0.420, 0.620, 0.820) + case .custom: return custom.dialRimColor.linuxColor + } + } + + var dialRimWidth: Double { + switch self { + case .glass: return 1.5 + case .classic: return 6 + case .glacier: return 0 + case .midnight: return 2.5 + case .luxe: return 2 + case .gufeng: return 3 + case .railgun, .sky: return 2.5 + case .custom: return custom.dialRimWidth + } + } + + var hasDialDecoration: Bool { self == .sky || (self == .custom && custom.hasDialDecoration) } + + var hourHandColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.16, 0.16, 0.18) + case .classic: return LinuxColor(0.718, 0.110, 0.110) + case .glacier: return LinuxColor(0.18, 0.18, 0.18) + case .midnight: return LinuxColor(0.149, 0.776, 0.855) + case .luxe: return LinuxColor(1.0, 0.835, 0.310) + case .gufeng: return LinuxColor(0.200, 0.180, 0.160) + case .railgun: return LinuxColor(0.820, 0.580, 0.560) + case .sky: return LinuxColor(0.960, 0.878, 0.400) + case .custom: return custom.hourHandColor.linuxColor + } + } + + var minuteHandColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.16, 0.16, 0.18) + case .classic: return LinuxColor(0.898, 0.224, 0.208) + case .glacier: return LinuxColor(0.35, 0.35, 0.35) + case .midnight: return LinuxColor(0.502, 0.871, 0.918) + case .luxe: return LinuxColor(1.0, 0.718, 0.302) + case .gufeng: return LinuxColor(0.350, 0.280, 0.220) + case .railgun: return LinuxColor(0.850, 0.650, 0.600) + case .sky: return LinuxColor(0.980, 0.910, 0.520) + case .custom: return custom.minuteHandColor.linuxColor + } + } + + var secondHandColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.90, 0.42, 0.18) + case .classic: return LinuxColor(1.0, 0.322, 0.322) + case .glacier: return LinuxColor(0.95, 0.40, 0.55) + case .midnight: return LinuxColor(1, 1, 1) + case .luxe: return LinuxColor(0.941, 0.384, 0.573) + case .gufeng: return LinuxColor(0.722, 0.184, 0.184) + case .railgun: return LinuxColor(0.400, 0.620, 0.950) + case .sky: return LinuxColor(1.0, 0.580, 0.200) + case .custom: return custom.secondHandColor.linuxColor + } + } + + var handStyle: HandStyle { + switch self { + case .glass, .classic: return .round + case .midnight, .railgun, .sky: return .tapered + case .luxe: return .lance + case .glacier, .gufeng: return .sword + case .custom: + switch custom.handStyleRaw { + case "tapered": return .tapered + case "lance": return .lance + case "sword": return .sword + default: return .round + } + } + } + + var centerDotOuterColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.16, 0.16, 0.18) + case .classic: return LinuxColor(0.82, 0.82, 0.82) + case .glacier: return LinuxColor(0.18, 0.18, 0.18) + case .midnight: return LinuxColor(0.149, 0.776, 0.855) + case .luxe: return LinuxColor(1.0, 0.835, 0.310) + case .gufeng: return LinuxColor(0.580, 0.400, 0.247) + case .railgun: return LinuxColor(0.820, 0.580, 0.560) + case .sky: return LinuxColor(0.960, 0.878, 0.400) + case .custom: return custom.centerDotOuterColor.linuxColor + } + } + + var centerDotInnerColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.90, 0.42, 0.18) + case .classic: return LinuxColor(0.898, 0.224, 0.208) + case .glacier: return LinuxColor(0.95, 0.40, 0.55) + case .midnight: return LinuxColor(1, 1, 1) + case .luxe: return LinuxColor(0.102, 0.102, 0.180) + case .gufeng: return LinuxColor(0.722, 0.184, 0.184) + case .railgun: return LinuxColor(0.400, 0.620, 0.950) + case .sky: return LinuxColor(1.0, 0.580, 0.200) + case .custom: return custom.centerDotInnerColor.linuxColor + } + } + + var tickMarkColor: LinuxColor { + switch self { + case .glass: return LinuxColor(1, 1, 1, 0.7) + case .classic: return .clear + case .glacier: return LinuxColor(0.10, 0.20, 0.42, 0.55) + case .midnight: return LinuxColor(0.290, 0.396, 0.502) + case .luxe: return LinuxColor(0.400, 0.333, 0.200) + case .gufeng: return LinuxColor(0.650, 0.500, 0.350) + case .railgun: return LinuxColor(0.750, 0.650, 0.580) + case .sky: return LinuxColor(0.620, 0.780, 0.920) + case .custom: return custom.hasTickMarks ? custom.tickMarkColor.linuxColor : .clear + } + } + + var majorTickMarkColor: LinuxColor { + switch self { + case .glass: return LinuxColor(1, 1, 1) + case .classic: return .clear + case .glacier: return LinuxColor(0.10, 0.20, 0.42) + case .midnight: return LinuxColor(0.502, 0.871, 0.918) + case .luxe: return LinuxColor(1.0, 0.835, 0.310) + case .gufeng: return LinuxColor(0.350, 0.220, 0.160) + case .railgun: return LinuxColor(0.620, 0.470, 0.380) + case .sky: return LinuxColor(0.960, 0.920, 0.780) + case .custom: return custom.hasTickMarks ? custom.majorTickMarkColor.linuxColor : .clear + } + } + + var numberColor: LinuxColor { + switch self { + case .glass, .classic: return .clear + case .glacier: return LinuxColor(0.10, 0.20, 0.42) + case .midnight: return LinuxColor(0.400, 0.533, 0.667) + case .luxe: return LinuxColor(0.667, 0.567, 0.333) + case .gufeng: return LinuxColor(0.300, 0.200, 0.150) + case .railgun: return LinuxColor(0.500, 0.350, 0.300) + case .sky: return LinuxColor(0.220, 0.380, 0.560) + case .custom: return custom.showNumbers ? custom.numberColor.linuxColor : .clear + } + } + + var numberStyle: NumberStyle { + self == .gufeng || (self == .custom && custom.numberStyleRaw == "chinese") + ? .chinese : .arabic + } + + var numberFontFamily: String { + switch self { + case .gufeng: return "Noto Serif CJK SC, Serif" + case .railgun: return "Monospace" + case .custom: + switch custom.numberFontDesignRaw { + case "serif": return "Noto Serif CJK SC, Serif" + case "monospaced": return "Monospace" + default: return "Sans" + } + default: return "Sans" + } + } + + var hourHandWidth: Double { + switch self { + case .glass, .classic: return 4.5 + case .glacier: return 4 + case .midnight, .railgun: return 5.5 + case .luxe: return 6 + case .gufeng, .sky: return 5 + case .custom: return custom.hourHandWidth + } + } + + var minuteHandWidth: Double { + switch self { + case .glass, .classic: return 3 + case .glacier: return 2.8 + case .luxe: return 4.5 + case .midnight, .gufeng, .railgun, .sky: return 3.5 + case .custom: return custom.minuteHandWidth + } + } + + var secondHandWidth: Double { + switch self { + case .glass, .classic, .gufeng, .sky: return 1.5 + case .glacier, .midnight: return 1.2 + case .luxe: return 1 + case .railgun: return 1.8 + case .custom: return custom.secondHandWidth + } + } + + var hourHandLength: Double { self == .custom ? custom.hourHandLength : 0.48 } + var minuteHandLength: Double { self == .custom ? custom.minuteHandLength : 0.68 } + var secondHandLength: Double { self == .custom ? custom.secondHandLength : 0.78 } + + var textPrimaryColor: LinuxColor { + switch self { + case .glass: return LinuxColor(1, 1, 1) + case .classic: return LinuxColor(0.18, 0.18, 0.20) + case .glacier: return LinuxColor(0.10, 0.20, 0.42) + case .midnight: return LinuxColor(0.878, 0.878, 0.878) + case .luxe: return LinuxColor(0.910, 0.835, 0.639) + case .gufeng: return LinuxColor(0.250, 0.180, 0.130) + case .railgun: return LinuxColor(0.500, 0.350, 0.300) + case .sky: return LinuxColor(0.180, 0.340, 0.520) + case .custom: return custom.textPrimaryColor.linuxColor + } + } + + var textSecondaryColor: LinuxColor { + switch self { + case .glass: return LinuxColor(1, 1, 1, 0.8) + case .classic: return LinuxColor(0.45, 0.45, 0.48) + case .glacier: return LinuxColor(0.10, 0.20, 0.42, 0.7) + case .midnight: return LinuxColor(0.533, 0.600, 0.667) + case .luxe: return LinuxColor(0.600, 0.533, 0.400) + case .gufeng: return LinuxColor(0.500, 0.380, 0.280) + case .railgun: return LinuxColor(0.680, 0.560, 0.480) + case .sky: return LinuxColor(0.380, 0.520, 0.660) + case .custom: return custom.textSecondaryColor.linuxColor + } + } + + var dropdownBackgroundColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.93, 0.95, 0.97) + case .classic: return LinuxColor(0.94, 0.94, 0.95) + case .glacier: return LinuxColor(0.94, 0.97, 1.00) + case .midnight: return LinuxColor(0.133, 0.184, 0.247) + case .luxe: return LinuxColor(0.125, 0.125, 0.200) + case .gufeng: return LinuxColor(0.910, 0.878, 0.812) + case .railgun: return LinuxColor(0.925, 0.906, 0.875) + case .sky: return LinuxColor(0.600, 0.780, 0.920) + case .custom: return custom.dropdownBgColor.linuxColor + } + } + + var dropdownHeaderColor: LinuxColor { + switch self { + case .glass: return LinuxColor(0.55, 0.55, 0.58) + case .classic: return LinuxColor(0.55, 0.55, 0.58) + case .glacier: return LinuxColor(0.10, 0.20, 0.42, 0.7) + case .midnight: return LinuxColor(0.533, 0.600, 0.667) + case .luxe: return LinuxColor(0.667, 0.567, 0.400) + case .gufeng: return LinuxColor(0.450, 0.320, 0.220) + case .railgun: return LinuxColor(0.620, 0.470, 0.380) + case .sky: return LinuxColor(0.300, 0.480, 0.660) + case .custom: return custom.dropdownSubtextColor.linuxColor + } + } + + var dropdownTextColor: LinuxColor { + switch self { + case .glass, .classic: return LinuxColor(0.18, 0.18, 0.20) + case .glacier: return LinuxColor(0.10, 0.20, 0.42) + case .midnight: return LinuxColor(0.878, 0.878, 0.878) + case .luxe: return LinuxColor(0.910, 0.835, 0.639) + case .gufeng: return LinuxColor(0.280, 0.200, 0.150) + case .railgun: return LinuxColor(0.500, 0.350, 0.300) + case .sky: return LinuxColor(0.180, 0.340, 0.520) + case .custom: return custom.dropdownTextColor.linuxColor + } + } + + var dropdownSubtextColor: LinuxColor { + switch self { + case .glass, .classic: return LinuxColor(0.45, 0.45, 0.48) + case .glacier: return LinuxColor(0.10, 0.20, 0.42, 0.7) + case .midnight: return LinuxColor(0.533, 0.600, 0.667) + case .luxe: return LinuxColor(0.600, 0.533, 0.400) + case .gufeng: return LinuxColor(0.520, 0.400, 0.300) + case .railgun: return LinuxColor(0.680, 0.560, 0.480) + case .sky: return LinuxColor(0.380, 0.520, 0.660) + case .custom: return custom.dropdownSubtextColor.linuxColor + } + } + + var dropdownBorderColor: LinuxColor { + switch self { + case .glass, .classic: return LinuxColor(0.82, 0.82, 0.82) + case .glacier: return LinuxColor(0.10, 0.20, 0.42, 0.18) + case .midnight: return LinuxColor(0.200, 0.290, 0.400) + case .luxe: return LinuxColor(0.250, 0.220, 0.150) + case .gufeng: return LinuxColor(0.650, 0.480, 0.320) + case .railgun: return LinuxColor(0.780, 0.680, 0.600) + case .sky: return LinuxColor(0.500, 0.680, 0.840) + case .custom: return custom.dropdownBorderColor.linuxColor + } + } + + var dropdownDividerColor: LinuxColor { + switch self { + case .glass, .classic: return LinuxColor(0.85, 0.85, 0.85) + case .glacier: return LinuxColor(0.10, 0.20, 0.42, 0.18) + case .midnight: return LinuxColor(0.220, 0.310, 0.420) + case .luxe: return LinuxColor(0.280, 0.240, 0.160) + case .gufeng: return LinuxColor(0.700, 0.540, 0.380) + case .railgun: return LinuxColor(0.820, 0.730, 0.660) + case .sky: return LinuxColor(0.650, 0.780, 0.900) + case .custom: return custom.dropdownDividerColor.linuxColor + } + } +} + +enum LinuxClockSize: String, CaseIterable, Sendable { + case small, medium, large, extraLarge + + var diameter: Int { + switch self { + case .small: return 200 + case .medium: return 240 + case .large: return 300 + case .extraLarge: return 360 + } + } + + var scale: Double { Double(diameter) / 240 } + + var displayName: String { + switch self { + case .small: return L10n.shared.tr("size.small") + case .medium: return L10n.shared.tr("size.medium") + case .large: return L10n.shared.tr("size.large") + case .extraLarge: return L10n.shared.tr("size.extraLarge") + } + } +} diff --git a/Sources/TokenClock/Linux/LinuxCustomTheme.swift b/Sources/TokenClock/Linux/LinuxCustomTheme.swift new file mode 100644 index 0000000..96e2116 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxCustomTheme.swift @@ -0,0 +1,198 @@ +import Foundation + +struct LinuxThemeColor: Codable, Equatable, Sendable { + var red: Double + var green: Double + var blue: Double + var opacity: Double = 1 + + var linuxColor: LinuxColor { LinuxColor(red, green, blue, opacity) } +} + +/// JSON-compatible mirror of macOS normal's `CustomThemeConfig`. +struct LinuxCustomThemeConfig: Codable, Equatable, Sendable { + var dialColor = LinuxThemeColor(red: 0.97, green: 0.97, blue: 0.98) + var dialRimColor = LinuxThemeColor(red: 0.82, green: 0.82, blue: 0.82) + var dialRimWidth = 6.0 + var hourHandColor = LinuxThemeColor(red: 0.718, green: 0.110, blue: 0.110) + var minuteHandColor = LinuxThemeColor(red: 0.898, green: 0.224, blue: 0.208) + var secondHandColor = LinuxThemeColor(red: 1, green: 0.322, blue: 0.322) + var hourHandWidth = 4.5 + var minuteHandWidth = 3.0 + var secondHandWidth = 1.5 + var hourHandLength = 0.48 + var minuteHandLength = 0.68 + var secondHandLength = 0.78 + var handStyleRaw = "round" + var centerDotOuterColor = LinuxThemeColor(red: 0.82, green: 0.82, blue: 0.82) + var centerDotInnerColor = LinuxThemeColor(red: 0.898, green: 0.224, blue: 0.208) + var numberColor = LinuxThemeColor(red: 0, green: 0, blue: 0, opacity: 0) + var tickMarkColor = LinuxThemeColor(red: 0, green: 0, blue: 0, opacity: 0) + var majorTickMarkColor = LinuxThemeColor(red: 0, green: 0, blue: 0, opacity: 0) + var dropdownBgColor = LinuxThemeColor(red: 0.94, green: 0.94, blue: 0.95) + var dropdownTextColor = LinuxThemeColor(red: 0.18, green: 0.18, blue: 0.20) + var dropdownSubtextColor = LinuxThemeColor(red: 0.45, green: 0.45, blue: 0.48) + var dropdownBorderColor = LinuxThemeColor(red: 0.82, green: 0.82, blue: 0.82) + var dropdownDividerColor = LinuxThemeColor(red: 0.85, green: 0.85, blue: 0.85) + var textPrimaryColor = LinuxThemeColor(red: 0.18, green: 0.18, blue: 0.20) + var textSecondaryColor = LinuxThemeColor(red: 0.45, green: 0.45, blue: 0.48) + var showNumbers = true + var hasTickMarks = true + var hasDialDecoration = false + var numberStyleRaw = "arabic" + var numberFontDesignRaw = "rounded" + + init() {} + + private enum CodingKeys: String, CodingKey { + case dialColor, dialRimColor, dialRimWidth + case hourHandColor, minuteHandColor, secondHandColor + case hourHandWidth, minuteHandWidth, secondHandWidth + case hourHandLength, minuteHandLength, secondHandLength, handStyleRaw + case centerDotOuterColor, centerDotInnerColor + case numberColor, tickMarkColor, majorTickMarkColor + case dropdownBgColor, dropdownTextColor, dropdownSubtextColor, dropdownBorderColor, dropdownDividerColor + case textPrimaryColor, textSecondaryColor + case showNumbers, hasTickMarks, hasDialDecoration, numberStyleRaw, numberFontDesignRaw + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let defaults = LinuxCustomThemeConfig() + dialColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dialColor) ?? defaults.dialColor + dialRimColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dialRimColor) ?? defaults.dialRimColor + dialRimWidth = try values.decodeIfPresent(Double.self, forKey: .dialRimWidth) ?? defaults.dialRimWidth + hourHandColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .hourHandColor) ?? defaults.hourHandColor + minuteHandColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .minuteHandColor) ?? defaults.minuteHandColor + secondHandColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .secondHandColor) ?? defaults.secondHandColor + hourHandWidth = try values.decodeIfPresent(Double.self, forKey: .hourHandWidth) ?? defaults.hourHandWidth + minuteHandWidth = try values.decodeIfPresent(Double.self, forKey: .minuteHandWidth) ?? defaults.minuteHandWidth + secondHandWidth = try values.decodeIfPresent(Double.self, forKey: .secondHandWidth) ?? defaults.secondHandWidth + hourHandLength = try values.decodeIfPresent(Double.self, forKey: .hourHandLength) ?? defaults.hourHandLength + minuteHandLength = try values.decodeIfPresent(Double.self, forKey: .minuteHandLength) ?? defaults.minuteHandLength + secondHandLength = try values.decodeIfPresent(Double.self, forKey: .secondHandLength) ?? defaults.secondHandLength + handStyleRaw = try values.decodeIfPresent(String.self, forKey: .handStyleRaw) ?? defaults.handStyleRaw + centerDotOuterColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .centerDotOuterColor) ?? defaults.centerDotOuterColor + centerDotInnerColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .centerDotInnerColor) ?? defaults.centerDotInnerColor + numberColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .numberColor) ?? defaults.numberColor + tickMarkColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .tickMarkColor) ?? defaults.tickMarkColor + majorTickMarkColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .majorTickMarkColor) ?? defaults.majorTickMarkColor + dropdownBgColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dropdownBgColor) ?? defaults.dropdownBgColor + dropdownTextColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dropdownTextColor) ?? defaults.dropdownTextColor + dropdownSubtextColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dropdownSubtextColor) ?? defaults.dropdownSubtextColor + dropdownBorderColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dropdownBorderColor) ?? defaults.dropdownBorderColor + dropdownDividerColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .dropdownDividerColor) ?? defaults.dropdownDividerColor + textPrimaryColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .textPrimaryColor) ?? defaults.textPrimaryColor + textSecondaryColor = try values.decodeIfPresent(LinuxThemeColor.self, forKey: .textSecondaryColor) ?? defaults.textSecondaryColor + showNumbers = try values.decodeIfPresent(Bool.self, forKey: .showNumbers) ?? defaults.showNumbers + hasTickMarks = try values.decodeIfPresent(Bool.self, forKey: .hasTickMarks) ?? defaults.hasTickMarks + hasDialDecoration = try values.decodeIfPresent(Bool.self, forKey: .hasDialDecoration) ?? defaults.hasDialDecoration + numberStyleRaw = try values.decodeIfPresent(String.self, forKey: .numberStyleRaw) ?? defaults.numberStyleRaw + numberFontDesignRaw = try values.decodeIfPresent(String.self, forKey: .numberFontDesignRaw) ?? defaults.numberFontDesignRaw + } +} + +struct LinuxSavedCustomTheme: Codable, Equatable, Sendable { + let id: UUID + var name: String + var config: LinuxCustomThemeConfig +} + +final class LinuxCustomThemeStore: @unchecked Sendable { + static let shared = LinuxCustomThemeStore() + private let lock = NSLock() + private var storedConfig: LinuxCustomThemeConfig + private var storedThemes: [LinuxSavedCustomTheme] + + private init() { + let defaults = UserDefaults.standard + storedConfig = defaults.data(forKey: SettingsKey.customThemeConfig.rawValue) + .flatMap { try? JSONDecoder().decode(LinuxCustomThemeConfig.self, from: $0) } + ?? LinuxCustomThemeConfig() + storedThemes = defaults.data(forKey: SettingsKey.savedCustomThemes.rawValue) + .flatMap { try? JSONDecoder().decode([LinuxSavedCustomTheme].self, from: $0) } + ?? [] + } + + var config: LinuxCustomThemeConfig { + lock.lock(); defer { lock.unlock() } + return storedConfig + } + + var themes: [LinuxSavedCustomTheme] { + lock.lock(); defer { lock.unlock() } + return storedThemes + } + + @discardableResult + func save(config: LinuxCustomThemeConfig, name: String) -> LinuxSavedCustomTheme { + lock.lock() + storedConfig = config + let activeID = UserDefaults.standard.string(for: .activeCustomThemeId).flatMap(UUID.init(uuidString:)) + let saved: LinuxSavedCustomTheme + if let activeID, let index = storedThemes.firstIndex(where: { $0.id == activeID }) { + storedThemes[index].name = name + storedThemes[index].config = config + saved = storedThemes[index] + } else { + saved = LinuxSavedCustomTheme(id: UUID(), name: name, config: config) + storedThemes.append(saved) + } + let themes = storedThemes + lock.unlock() + persist(config: config, themes: themes, activeID: saved.id) + return saved + } + + func apply(id: UUID) -> Bool { + lock.lock() + guard let theme = storedThemes.first(where: { $0.id == id }) else { + lock.unlock() + return false + } + storedConfig = theme.config + lock.unlock() + persist(config: theme.config, themes: themes, activeID: id) + return true + } + + func resetDraft() -> LinuxCustomThemeConfig { + let config = LinuxCustomThemeConfig() + lock.lock(); storedConfig = config; lock.unlock() + if let data = try? JSONEncoder().encode(config) { + UserDefaults.standard.set(data, forKey: SettingsKey.customThemeConfig.rawValue) + } + UserDefaults.standard.synchronize() + return config + } + + func delete(id: UUID) { + lock.lock() + storedThemes.removeAll { $0.id == id } + let themes = storedThemes + lock.unlock() + if let data = try? JSONEncoder().encode(themes) { + UserDefaults.standard.set(data, forKey: SettingsKey.savedCustomThemes.rawValue) + } + if UserDefaults.standard.string(for: .activeCustomThemeId) == id.uuidString { + UserDefaults.standard.remove(.activeCustomThemeId) + } + UserDefaults.standard.synchronize() + } + + private func persist( + config: LinuxCustomThemeConfig, + themes: [LinuxSavedCustomTheme], + activeID: UUID + ) { + let encoder = JSONEncoder() + if let data = try? encoder.encode(config) { + UserDefaults.standard.set(data, forKey: SettingsKey.customThemeConfig.rawValue) + } + if let data = try? encoder.encode(themes) { + UserDefaults.standard.set(data, forKey: SettingsKey.savedCustomThemes.rawValue) + } + UserDefaults.standard.setString(activeID.uuidString, for: .activeCustomThemeId) + UserDefaults.standard.synchronize() + } +} diff --git a/Sources/TokenClock/Linux/LinuxDetailsPanel.swift b/Sources/TokenClock/Linux/LinuxDetailsPanel.swift new file mode 100644 index 0000000..12bfca0 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxDetailsPanel.swift @@ -0,0 +1,497 @@ +import Foundation +import CGtk + +private enum LinuxGroupingMode: Int { + case session = 0 + case model = 1 +} + +/// Theme-aware Linux counterpart of macOS `DetailDropdownView`. +final class LinuxDetailsPanel: @unchecked Sendable { + private(set) var window: UnsafeMutablePointer? + private var card: UnsafeMutablePointer? + private var parent: UnsafeMutablePointer? + + private var tools: [ToolUsage] = [] + private var weather = WeatherInfo() + private var useFahrenheit = false + private var theme: LinuxClockTheme = .classic + private var size: LinuxClockSize = .medium + private var grouping = LinuxGroupingMode( + rawValue: UserDefaults.standard.int(for: .dropdownGrouping, default: 0) + ) ?? .session + private var showPercentage = UserDefaults.standard.bool( + for: .dropdownShowPercentage, default: false + ) + private var expandedTools: Set = [] + private var expandedModels: Set = [] + private var appliedTheme: LinuxClockTheme? + + private lazy var opaque = Unmanaged.passUnretained(self).toOpaque() + + init(parent: UnsafeMutablePointer) { + self.parent = parent + buildWindow(parent: parent) + } + + var isVisible: Bool { + guard let window else { return false } + return gtk_widget_get_visible(window) != 0 + } + + func update( + tools: [ToolUsage], + weather: WeatherInfo, + useFahrenheit: Bool, + theme: LinuxClockTheme, + size: LinuxClockSize + ) { + self.tools = tools + self.weather = weather + self.useFahrenheit = useFahrenheit + self.theme = theme + self.size = size + rebuild() + applyThemeIfNeeded(force: theme == .custom) + if isVisible { resizeAndPosition() } + } + + func toggle() { + guard let window else { return } + if isVisible { + gtk_widget_hide(window) + } else { + rebuild() + applyThemeIfNeeded() + resizeAndPosition() + gtk_widget_show_all(window) + } + } + + func hide() { + if let window { gtk_widget_hide(window) } + } + + func reposition() { + guard isVisible else { return } + resizeAndPosition() + } + + func setKeepAbove(_ enabled: Bool) { + guard let window else { return } + gtk_window_set_keep_above(tc_gtk_window(window), enabled ? 1 : 0) + } + + func setOpacity(_ opacity: Double) { + guard let window else { return } + gtk_widget_set_opacity(window, opacity) + } + + fileprivate func handleAction(widget: UnsafeMutablePointer) { + let name = String(cString: tc_gtk_widget_name(widget)) + switch name { + case "details:session": + grouping = .session + UserDefaults.standard.setInt(grouping.rawValue, for: .dropdownGrouping) + case "details:model": + grouping = .model + UserDefaults.standard.setInt(grouping.rawValue, for: .dropdownGrouping) + case "details:percentage": + showPercentage.toggle() + UserDefaults.standard.setBool(showPercentage, for: .dropdownShowPercentage) + default: + if name.hasPrefix("details:tool:") { + let value = String(name.dropFirst("details:tool:".count)) + if expandedTools.contains(value) { expandedTools.remove(value) } else { expandedTools.insert(value) } + } else if name.hasPrefix("details:model-row:") { + let value = String(name.dropFirst("details:model-row:".count)) + if expandedModels.contains(value) { expandedModels.remove(value) } else { expandedModels.insert(value) } + } + } + UserDefaults.standard.synchronize() + rebuild() + applyThemeIfNeeded(force: true) + resizeAndPosition() + if let window { gtk_widget_show_all(window) } + } + + private func buildWindow(parent: UnsafeMutablePointer) { + guard let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL), + let createdCard = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0) else { return } + window = createdWindow + card = createdCard + gtk_window_set_title(tc_gtk_window(createdWindow), "TokenClock Details") + gtk_window_set_decorated(tc_gtk_window(createdWindow), 0) + gtk_window_set_resizable(tc_gtk_window(createdWindow), 1) + gtk_window_set_skip_taskbar_hint(tc_gtk_window(createdWindow), 1) + gtk_window_set_skip_pager_hint(tc_gtk_window(createdWindow), 1) + gtk_window_set_transient_for(tc_gtk_window(createdWindow), tc_gtk_window(parent)) + gtk_window_set_keep_above( + tc_gtk_window(createdWindow), + UserDefaults.standard.bool(for: .alwaysOnTop) ? 1 : 0 + ) + tc_gtk_prepare_transparent_window(createdWindow) + gtk_widget_set_name(createdWindow, "tokenclock-details-window") + gtk_widget_set_name(createdCard, "tokenclock-details-card") + gtk_container_add(tc_gtk_container(createdWindow), createdCard) + rebuild() + applyThemeIfNeeded(force: true) + } + + private func rebuild() { + guard let card else { return } + tc_gtk_remove_all_children(card) + + if !weather.cityName.isEmpty { + buildWeatherBar(in: card) + gtk_box_pack_start(tc_gtk_box(card), separator(), 0, 0, 0) + } + + let controls = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + gtk_container_set_border_width(tc_gtk_container(controls), 10) + gtk_box_pack_start(tc_gtk_box(card), controls, 0, 0, 0) + appendControl( + grouping == .session ? "✓ \(tr("detail.groupBySession"))" : tr("detail.groupBySession"), + name: "details:session", to: controls + ) + appendControl( + grouping == .model ? "✓ \(tr("detail.groupByModel"))" : tr("detail.groupByModel"), + name: "details:model", to: controls + ) + let spacer = gtk_label_new("") + gtk_widget_set_hexpand(spacer, 1) + gtk_box_pack_start(tc_gtk_box(controls), spacer, 1, 1, 0) + appendControl( + showPercentage ? "✓ \(tr("detail.percent"))" : "% \(tr("detail.percent"))", + name: "details:percentage", to: controls + ) + + gtk_box_pack_start(tc_gtk_box(card), separator(), 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(card), headerRow(), 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(card), separator(), 0, 0, 0) + + guard let scroll = gtk_scrolled_window_new(nil, nil), + let list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0) else { return } + gtk_scrolled_window_set_policy( + tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC + ) + gtk_container_add(tc_gtk_container(scroll), list) + gtk_box_pack_start(tc_gtk_box(card), scroll, 1, 1, 0) + + switch grouping { + case .session: buildSessionRows(in: list) + case .model: buildModelRows(in: list) + } + } + + private func buildWeatherBar(in card: UnsafeMutablePointer) { + let box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5) + gtk_container_set_border_width(tc_gtk_container(box), 10) + let temperature = displayTemperature(weather.temperature) + let current = gtk_label_new("\(weather.emoji) \(weather.cityName) \(temperature)") + gtk_label_set_xalign(tc_gtk_label(current), 0) + tc_gtk_add_class(current, "tokenclock-detail-text") + gtk_box_pack_start(tc_gtk_box(box), current, 0, 0, 0) + + let slots = selectedForecastSlots() + if !slots.isEmpty { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4) + for slot in slots { + let hour = forecastHour(slot.time) + let label = gtk_label_new("\(String(format: "%02d", hour)):00\n\(slot.emoji) \(displayTemperature(slot.tempC))") + gtk_label_set_justify(tc_gtk_label(label), GTK_JUSTIFY_CENTER) + gtk_widget_set_hexpand(label, 1) + tc_gtk_add_class(label, "tokenclock-detail-subtext") + gtk_box_pack_start(tc_gtk_box(row), label, 1, 1, 0) + } + gtk_box_pack_start(tc_gtk_box(box), row, 0, 0, 2) + } + gtk_box_pack_start(tc_gtk_box(card), box, 0, 0, 0) + } + + private func selectedForecastSlots() -> [HourlyForecast] { + guard !weather.forecast.isEmpty else { return [] } + let currentHour = Calendar.current.component(.hour, from: Date()) + var index = 0 + var previous = -1 + for (candidate, slot) in weather.forecast.enumerated() { + let hour = forecastHour(slot.time) + if previous > hour { break } + if hour <= currentHour { index = candidate } else { break } + previous = hour + } + return Array(weather.forecast.dropFirst(index).prefix(4)) + } + + private func forecastHour(_ value: String) -> Int { + if value.count <= 2 { return Int(value) ?? 0 } + if value.count == 3 { return Int(value.prefix(1)) ?? 0 } + return Int(value.prefix(2)) ?? 0 + } + + private func displayTemperature(_ celsius: Int) -> String { + if useFahrenheit { return "\(Int(Double(celsius) * 9 / 5 + 32))°F" } + return "\(celsius)°C" + } + + private func appendControl( + _ title: String, + name: String, + to box: UnsafeMutablePointer? + ) { + guard let button = gtk_button_new_with_label(title) else { return } + gtk_widget_set_name(button, name) + tc_gtk_add_class(button, "tokenclock-detail-chip") + gtk_button_set_relief(tc_gtk_button(button), GTK_RELIEF_NONE) + _ = tc_gtk_on_clicked(button, linuxDetailsAction, opaque) + gtk_box_pack_start(tc_gtk_box(box), button, 0, 0, 0) + } + + private func headerRow() -> UnsafeMutablePointer? { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0) + gtk_container_set_border_width(tc_gtk_container(row), 8) + let first = grouping == .session ? tr("detail.instance") : tr("detail.model") + appendLabel(first, width: 130, expands: true, alignment: 0, style: "tokenclock-detail-header", to: row) + appendLabel(showPercentage ? tr("detail.share") : tr("detail.todayUsage"), width: 60, alignment: 1, style: "tokenclock-detail-header", to: row) + appendLabel(tr("detail.messages"), width: 40, alignment: 1, style: "tokenclock-detail-header", to: row) + if grouping == .session { + appendLabel(tr("detail.cacheRate"), width: 44, alignment: 1, style: "tokenclock-detail-header", to: row) + } + return row + } + + private func buildSessionRows(in list: UnsafeMutablePointer) { + let active = tools.filter { $0.todayTokens > 0 || $0.todayMessages > 0 } + guard !active.isEmpty else { + appendEmptyState(to: list) + return + } + let total = max(1, UsageAggregator.totalTokens(tools)) + for (index, tool) in active.enumerated() { + if index > 0 { gtk_box_pack_start(tc_gtk_box(list), separator(), 0, 0, 0) } + let expanded = expandedTools.contains(tool.name) + let prefix = tool.sessions.isEmpty ? " " : (expanded ? "▾" : "▸") + let title = "\(prefix) \(tool.emoji) \(tool.name)" + appendDataRow( + title: title, + tokens: usageText(tool.todayTokens, total: total), + messages: "\(tool.todayMessages)", + trailing: cacheText(tool.cacheRate), + actionName: tool.sessions.isEmpty ? nil : "details:tool:\(tool.name)", + child: false, + to: list + ) + if expanded { + for session in tool.sessions { + let activeMarker = session.isActive ? "●" : "○" + var name = " \(activeMarker) \(session.displayName)" + if let source = session.source, !source.isEmpty { name += " · \(source)" } + appendDataRow( + title: name, + tokens: usageText(session.todayTokens, total: total), + messages: "\(session.todayMessages)", + trailing: "–", + actionName: nil, + child: true, + to: list + ) + } + } + } + } + + private func buildModelRows(in list: UnsafeMutablePointer) { + let groups = UsageAggregator.groupedByModel( + tools, unknownLabel: tr("detail.unknownModel") + ) + guard !groups.isEmpty else { + appendEmptyState(to: list) + return + } + let total = max(1, UsageAggregator.totalTokens(tools)) + for (index, group) in groups.enumerated() { + if index > 0 { gtk_box_pack_start(tc_gtk_box(list), separator(), 0, 0, 0) } + let expanded = expandedModels.contains(group.name) + let prefix = group.contributions.isEmpty ? " " : (expanded ? "▾" : "▸") + appendDataRow( + title: "\(prefix) \(group.emoji) \(group.name)", + tokens: usageText(group.totalTokens, total: total), + messages: "\(group.totalMessages)", + trailing: nil, + actionName: group.contributions.isEmpty ? nil : "details:model-row:\(group.name)", + child: false, + to: list + ) + if expanded { + for contribution in group.contributions { + appendDataRow( + title: " \(contribution.emoji) \(contribution.tool)", + tokens: usageText(contribution.tokens, total: total), + messages: "\(contribution.messages)", + trailing: nil, + actionName: nil, + child: true, + to: list + ) + } + } + } + } + + private func appendDataRow( + title: String, + tokens: String, + messages: String, + trailing: String?, + actionName: String?, + child: Bool, + to list: UnsafeMutablePointer + ) { + guard let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0) else { return } + gtk_container_set_border_width(tc_gtk_container(row), child ? 5 : 7) + tc_gtk_add_class(row, child ? "tokenclock-detail-child-row" : "tokenclock-detail-row") + appendLabel(title, width: 130, expands: true, alignment: 0, style: child ? "tokenclock-detail-subtext" : "tokenclock-detail-text", to: row) + appendLabel(tokens, width: 60, alignment: 1, style: "tokenclock-detail-text", to: row) + appendLabel(messages, width: 40, alignment: 1, style: "tokenclock-detail-subtext", to: row) + if let trailing { + appendLabel(trailing, width: 44, alignment: 1, style: "tokenclock-detail-subtext", to: row) + } + + if let actionName, let button = gtk_button_new() { + gtk_widget_set_name(button, actionName) + tc_gtk_add_class(button, "tokenclock-detail-row-button") + gtk_button_set_relief(tc_gtk_button(button), GTK_RELIEF_NONE) + gtk_container_add(tc_gtk_container(button), row) + _ = tc_gtk_on_clicked(button, linuxDetailsAction, opaque) + gtk_box_pack_start(tc_gtk_box(list), button, 0, 0, 0) + } else { + gtk_box_pack_start(tc_gtk_box(list), row, 0, 0, 0) + } + } + + private func appendLabel( + _ text: String, + width: Int, + expands: Bool = false, + alignment: Float, + style: String, + to box: UnsafeMutablePointer? + ) { + guard let label = gtk_label_new(text) else { return } + gtk_label_set_xalign(tc_gtk_label(label), alignment) + gtk_label_set_ellipsize(tc_gtk_label(label), PANGO_ELLIPSIZE_END) + gtk_widget_set_size_request(label, gint(width), -1) + tc_gtk_add_class(label, style) + gtk_box_pack_start(tc_gtk_box(box), label, expands ? 1 : 0, expands ? 1 : 0, 0) + } + + private func appendEmptyState(to list: UnsafeMutablePointer) { + guard let label = gtk_label_new( + localized(zh: "今天尚未发现本地 AI 用量", en: "No local AI usage found today") + ) else { return } + tc_gtk_add_class(label, "tokenclock-detail-subtext") + gtk_widget_set_vexpand(label, 1) + gtk_box_pack_start(tc_gtk_box(list), label, 1, 1, 24) + } + + private func separator() -> UnsafeMutablePointer? { + let separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL) + tc_gtk_add_class(separator, "tokenclock-detail-separator") + return separator + } + + private func usageText(_ tokens: Int, total: Int) -> String { + guard showPercentage else { return TokenFormat.compact(tokens) } + guard tokens > 0, total > 0 else { return "–" } + let percent = Double(tokens) / Double(total) * 100 + return percent < 1 ? "<1%" : String(format: "%.0f%%", percent) + } + + private func cacheText(_ rate: Double) -> String { + guard rate > 0 else { return "–" } + let percent = rate <= 1 ? rate * 100 : rate + return percent < 1 ? "<1%" : String(format: "%.0f%%", percent) + } + + private func resizeAndPosition() { + guard let parent, let window else { return } + let width = size.diameter + 80 + let rowCount: Int + switch grouping { + case .session: + rowCount = tools.filter { $0.todayTokens > 0 || $0.todayMessages > 0 }.reduce(0) { + $0 + 1 + (expandedTools.contains($1.name) ? $1.sessions.count : 0) + } + case .model: + rowCount = UsageAggregator.groupedByModel( + tools, unknownLabel: tr("detail.unknownModel") + ).reduce(0) { $0 + 1 + (expandedModels.contains($1.name) ? $1.contributions.count : 0) } + } + let height = min(430, max(170, 100 + rowCount * 34)) + gtk_window_resize(tc_gtk_window(window), gint(width), gint(height)) + tc_gtk_position_adjacent_panel( + parent, window, gint(width), gint(height), 8 + ) + } + + private func applyThemeIfNeeded(force: Bool = false) { + guard force || appliedTheme != theme else { return } + appliedTheme = theme + let background = css(theme.dropdownBackgroundColor) + let text = css(theme.dropdownTextColor) + let subtext = css(theme.dropdownSubtextColor) + let header = css(theme.dropdownHeaderColor) + let border = css(theme.dropdownBorderColor) + let divider = css(theme.dropdownDividerColor) + tc_gtk_apply_css( + """ + #tokenclock-details-window { background: transparent; } + #tokenclock-details-card { + background: \(background); + border: 1px solid \(border); + border-radius: 14px; + } + .tokenclock-detail-text { color: \(text); font: 12px Sans; } + .tokenclock-detail-subtext { color: \(subtext); font: 11px Sans; } + .tokenclock-detail-header { color: \(header); font: 600 10px Sans; } + .tokenclock-detail-separator { background: \(divider); min-height: 1px; } + .tokenclock-detail-chip { + color: \(text); background: alpha(\(text), 0.07); + border: 1px solid alpha(\(text), 0.15); border-radius: 10px; + padding: 3px 8px; font: 600 10px Sans; + } + .tokenclock-detail-chip:hover { background: alpha(\(text), 0.14); } + .tokenclock-detail-row-button { background: transparent; border: 0; padding: 0; } + .tokenclock-detail-row-button:hover { background: alpha(\(text), 0.07); } + .tokenclock-detail-child-row { background: alpha(\(text), 0.025); } + scrollbar slider { background: alpha(\(text), 0.25); min-width: 6px; border-radius: 3px; } + """ + ) + } + + private func css(_ color: LinuxColor) -> String { + let r = Int((min(1, max(0, color.red)) * 255).rounded()) + let g = Int((min(1, max(0, color.green)) * 255).rounded()) + let b = Int((min(1, max(0, color.blue)) * 255).rounded()) + return String(format: "rgba(%d,%d,%d,%.3f)", r, g, b, color.alpha) + } + + private func tr(_ key: String) -> String { L10n.shared.tr(key) } + + private func localized(zh: String, en: String) -> String { + L10n.shared.language == .en ? en : zh + } +} + +private func detailsPanel(from data: gpointer?) -> LinuxDetailsPanel? { + guard let data else { return nil } + return Unmanaged.fromOpaque(data).takeUnretainedValue() +} + +private func linuxDetailsAction( + _ widget: UnsafeMutablePointer?, + _ data: gpointer? +) { + guard let widget else { return } + detailsPanel(from: data)?.handleAction(widget: widget) +} diff --git a/Sources/TokenClock/Linux/LinuxSettingsWindow.swift b/Sources/TokenClock/Linux/LinuxSettingsWindow.swift new file mode 100644 index 0000000..5824e90 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxSettingsWindow.swift @@ -0,0 +1,834 @@ +import Foundation +import CGtk + +/// Native GTK settings window mirroring the normal macOS preference model. +final class LinuxSettingsWindow: @unchecked Sendable { + struct ToolOption { + let emoji: String + let name: String + let service: String + let pathKey: SettingsKey + } + + static let toolOptions: [ToolOption] = [ + .init(emoji: "🦞", name: "OpenClaw", service: "openclaw", pathKey: .openclawPath), + .init(emoji: "✳️", name: "Claude Code", service: "claudeCode", pathKey: .claudeCodePath), + .init(emoji: "✨", name: "Gemini CLI", service: "gemini", pathKey: .geminiPath), + .init(emoji: "🤖", name: "Codex", service: "codex", pathKey: .codexPath), + .init(emoji: "⚕️", name: "Hermes", service: "hermes", pathKey: .hermesPath), + .init(emoji: "🐙", name: "OpenCode", service: "opencode", pathKey: .opencodePath), + .init(emoji: "🟣", name: "Qwen Code", service: "qwen", pathKey: .qwenPath), + .init(emoji: "🐙", name: "Copilot", service: "copilot", pathKey: .copilotPath), + .init(emoji: "⚡", name: "Grok", service: "grok", pathKey: .grokPath), + .init(emoji: "🤝", name: "Aider", service: "aider", pathKey: .aiderPath), + .init(emoji: "🛡️", name: "Antigravity", service: "antigravity", pathKey: .antigravityPath), + .init(emoji: "🤖", name: "Cline", service: "cline", pathKey: .clinePath), + .init(emoji: "▶️", name: "Continue", service: "continue", pathKey: .continuePath), + .init(emoji: "🖱️", name: "Cursor Agent", service: "cursorAgent", pathKey: .cursorAgentPath), + ] + + private weak var owner: LinuxApp? + private let model: LinuxUsageModel + private var parent: UnsafeMutablePointer? + private(set) var window: UnsafeMutablePointer? + + private var apiEnabledButton: UnsafeMutablePointer? + private var apiPortSpin: UnsafeMutablePointer? + private var cursorCloudButton: UnsafeMutablePointer? + private var detectionLabel: UnsafeMutablePointer? + private var toolButtons: [String: UnsafeMutablePointer] = [:] + private var pathEntries: [String: UnsafeMutablePointer] = [:] + private var rateWindowButtons: [Int: UnsafeMutablePointer] = [:] + private var thresholdSpins: [SettingsKey: UnsafeMutablePointer] = [:] + private var selectedRateWindow = 10 + private var customNameEntry: UnsafeMutablePointer? + private var customEntries: [String: UnsafeMutablePointer] = [:] + private var customNumericEntries: [String: UnsafeMutablePointer] = [:] + private var customToggleButtons: [String: UnsafeMutablePointer] = [:] + private var customHandButtons: [String: UnsafeMutablePointer] = [:] + private var customNumberStyleButtons: [String: UnsafeMutablePointer] = [:] + private var customFontButtons: [String: UnsafeMutablePointer] = [:] + private var selectedCustomHandStyle = "round" + private var selectedCustomNumberStyle = "arabic" + private var selectedCustomFont = "rounded" + + private lazy var opaque = Unmanaged.passUnretained(self).toOpaque() + + init(parent: UnsafeMutablePointer, owner: LinuxApp, model: LinuxUsageModel) { + self.parent = parent + self.owner = owner + self.model = model + buildWindow(parent: parent) + } + + func show() { + loadValues() + guard let window else { return } + gtk_widget_show_all(window) + gtk_window_present(tc_gtk_window(window)) + } + + func hide() { + if let window { gtk_widget_hide(window) } + } + + func refreshLanguage() { + guard let parent else { return } + if let window { gtk_widget_destroy(window) } + window = nil + clearWidgetReferences() + buildWindow(parent: parent) + } + + fileprivate func handleAction(widget: UnsafeMutablePointer) { + let name = String(cString: tc_gtk_widget_name(widget)) + if name.hasPrefix("settings:browse:") { + browse(service: String(name.dropFirst("settings:browse:".count))) + return + } + if name.hasPrefix("settings:rate-window:"), + let value = Int(name.dropFirst("settings:rate-window:".count)) { + selectedRateWindow = value + updateRateWindowButtons() + return + } + if name.hasPrefix("settings:custom-hand:") { + selectedCustomHandStyle = String(name.dropFirst("settings:custom-hand:".count)) + updateCustomHandButtons() + return + } + if name.hasPrefix("settings:custom-number-style:") { + selectedCustomNumberStyle = String(name.dropFirst("settings:custom-number-style:".count)) + updateCustomOptionButtons() + return + } + if name.hasPrefix("settings:custom-font:") { + selectedCustomFont = String(name.dropFirst("settings:custom-font:".count)) + updateCustomOptionButtons() + return + } + if name.hasPrefix("settings:custom-apply:"), + let id = UUID(uuidString: String(name.dropFirst("settings:custom-apply:".count))) { + owner?.applyCustomTheme(id: id) + loadCustomThemeValues() + return + } + if name.hasPrefix("settings:custom-delete:"), + let id = UUID(uuidString: String(name.dropFirst("settings:custom-delete:".count))) { + LinuxCustomThemeStore.shared.delete(id: id) + owner?.customThemeListChanged() + refreshLanguage() + show() + return + } + switch name { + case "settings:detect": runDetection() + case "settings:clock-face": owner?.openThemePickerFromSettings() + case "settings:custom-save": saveCustomTheme() + case "settings:custom-reset": resetCustomTheme() + case "settings:save": saveAndClose() + case "settings:cancel": hide() + default: break + } + } + + private func buildWindow(parent: UnsafeMutablePointer) { + guard let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL), + let root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0), + let notebook = gtk_notebook_new() else { return } + window = createdWindow + gtk_window_set_title(tc_gtk_window(createdWindow), L10n.shared.tr("settings.title")) + gtk_window_set_default_size(tc_gtk_window(createdWindow), 600, 600) + gtk_window_set_resizable(tc_gtk_window(createdWindow), 1) + gtk_window_set_transient_for(tc_gtk_window(createdWindow), tc_gtk_window(parent)) + gtk_window_set_position(tc_gtk_window(createdWindow), GTK_WIN_POS_CENTER_ON_PARENT) + gtk_window_set_keep_above(tc_gtk_window(createdWindow), 1) + _ = tc_gtk_hide_on_delete(createdWindow) + gtk_container_add(tc_gtk_container(createdWindow), root) + + appendPage(generalPage(), title: localized(zh: "常规", en: "General"), to: notebook) + appendPage(toolsPage(), title: localized(zh: "工具", en: "Tools"), to: notebook) + appendPage(pathsPage(), title: localized(zh: "路径", en: "Paths"), to: notebook) + appendPage(usagePage(), title: localized(zh: "速率", en: "Usage"), to: notebook) + appendPage(themesPage(), title: localized(zh: "表盘", en: "Faces"), to: notebook) + gtk_box_pack_start(tc_gtk_box(root), notebook, 1, 1, 0) + + let footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + gtk_container_set_border_width(tc_gtk_container(footer), 12) + let spacer = gtk_label_new("") + gtk_widget_set_hexpand(spacer, 1) + gtk_box_pack_start(tc_gtk_box(footer), spacer, 1, 1, 0) + appendButton(localized(zh: "取消", en: "Cancel"), name: "settings:cancel", to: footer) + appendButton(L10n.shared.tr("settings.done"), name: "settings:save", to: footer) + gtk_box_pack_start(tc_gtk_box(root), footer, 0, 0, 0) + + tc_gtk_apply_css(""" + .tokenclock-settings-section { font-size: 14px; font-weight: 700; } + .tokenclock-settings-hint { color: rgba(110, 110, 110, 0.95); font-size: 11px; } + .tokenclock-settings-row { padding: 5px; } + """) + loadValues() + } + + private func generalPage() -> UnsafeMutablePointer? { + let page = verticalPage() + appendSection(localized(zh: "本地 API", en: "Local API"), to: page) + apiEnabledButton = gtk_check_button_new_with_label( + localized(zh: "启用本地只读 API 服务", en: "Enable local read-only API server") + ) + gtk_box_pack_start(tc_gtk_box(page), apiEnabledButton, 0, 0, 0) + + let portRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + gtk_box_pack_start(tc_gtk_box(portRow), gtk_label_new(localized(zh: "端口", en: "Port")), 0, 0, 0) + apiPortSpin = gtk_entry_new() + gtk_entry_set_input_purpose(tc_gtk_entry(apiPortSpin), GTK_INPUT_PURPOSE_NUMBER) + gtk_widget_set_size_request(apiPortSpin, 90, -1) + gtk_box_pack_start(tc_gtk_box(portRow), apiPortSpin, 0, 0, 0) + let endpoint = gtk_label_new("/api/usage · /api/history") + tc_gtk_add_class(endpoint, "tokenclock-settings-hint") + gtk_box_pack_start(tc_gtk_box(portRow), endpoint, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(page), portRow, 0, 0, 0) + + appendSection(L10n.shared.tr("settings.autoDetect"), to: page) + let detectRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + appendButton(L10n.shared.tr("settings.redetect"), name: "settings:detect", to: detectRow) + detectionLabel = gtk_label_new("") + gtk_label_set_xalign(tc_gtk_label(detectionLabel), 0) + gtk_label_set_line_wrap(tc_gtk_label(detectionLabel), 1) + gtk_widget_set_hexpand(detectionLabel, 1) + tc_gtk_add_class(detectionLabel, "tokenclock-settings-hint") + gtk_box_pack_start(tc_gtk_box(detectRow), detectionLabel, 1, 1, 0) + gtk_box_pack_start(tc_gtk_box(page), detectRow, 0, 0, 0) + + appendSection(localized(zh: "网络数据", en: "Network Data"), to: page) + cursorCloudButton = gtk_check_button_new_with_label(L10n.shared.tr("dataFetch.cursorCloud")) + gtk_box_pack_start(tc_gtk_box(page), cursorCloudButton, 0, 0, 0) + let cursorHint = gtk_label_new(L10n.shared.tr("dataFetch.cursorCloudHint")) + gtk_label_set_xalign(tc_gtk_label(cursorHint), 0) + gtk_label_set_line_wrap(tc_gtk_label(cursorHint), 1) + tc_gtk_add_class(cursorHint, "tokenclock-settings-hint") + gtk_box_pack_start(tc_gtk_box(page), cursorHint, 0, 0, 0) + return page + } + + private func toolsPage() -> UnsafeMutablePointer? { + let page = verticalPage() + appendSection(L10n.shared.tr("settings.toolSelection"), to: page) + let columns = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 18) + let left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4) + let right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4) + gtk_box_pack_start(tc_gtk_box(columns), left, 1, 1, 0) + gtk_box_pack_start(tc_gtk_box(columns), right, 1, 1, 0) + for (index, option) in Self.toolOptions.enumerated() { + let button = gtk_check_button_new_with_label("\(option.emoji) \(option.name)") + gtk_box_pack_start(tc_gtk_box(index < 7 ? left : right), button, 0, 0, 0) + toolButtons[option.name] = button + } + gtk_box_pack_start(tc_gtk_box(page), columns, 0, 0, 0) + let hint = gtk_label_new( + localized( + zh: "关闭的工具不会参与扫描、表盘汇总与本地 API 输出。", + en: "Disabled tools are excluded from scans, the clock total, and local API output." + ) + ) + gtk_label_set_xalign(tc_gtk_label(hint), 0) + tc_gtk_add_class(hint, "tokenclock-settings-hint") + gtk_box_pack_start(tc_gtk_box(page), hint, 0, 0, 0) + return page + } + + private func pathsPage() -> UnsafeMutablePointer? { + guard let scroll = gtk_scrolled_window_new(nil, nil), + let page = verticalPage() else { return nil } + gtk_scrolled_window_set_policy(tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) + appendSection(L10n.shared.tr("settings.dataPaths"), to: page) + for option in Self.toolOptions { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + tc_gtk_add_class(row, "tokenclock-settings-row") + let label = gtk_label_new("\(option.emoji) \(option.name)") + gtk_label_set_xalign(tc_gtk_label(label), 0) + gtk_widget_set_size_request(label, 130, -1) + let entry = gtk_entry_new() + gtk_entry_set_placeholder_text(tc_gtk_entry(entry), L10n.shared.tr("settings.defaultPath")) + gtk_widget_set_hexpand(entry, 1) + let browse = gtk_button_new_with_label(L10n.shared.tr("settings.browse")) + gtk_widget_set_name(browse, "settings:browse:\(option.service)") + _ = tc_gtk_on_clicked(browse, linuxSettingsAction, opaque) + gtk_box_pack_start(tc_gtk_box(row), label, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(row), entry, 1, 1, 0) + gtk_box_pack_start(tc_gtk_box(row), browse, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(page), row, 0, 0, 0) + pathEntries[option.service] = entry + } + let hint = gtk_label_new(L10n.shared.tr("settings.hint.emptyPath")) + gtk_label_set_xalign(tc_gtk_label(hint), 0) + gtk_label_set_line_wrap(tc_gtk_label(hint), 1) + tc_gtk_add_class(hint, "tokenclock-settings-hint") + gtk_box_pack_start(tc_gtk_box(page), hint, 0, 0, 4) + gtk_container_add(tc_gtk_container(scroll), page) + return scroll + } + + private func usagePage() -> UnsafeMutablePointer? { + let page = verticalPage() + appendSection(L10n.shared.tr("rate.title"), to: page) + let periodRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + gtk_box_pack_start(tc_gtk_box(periodRow), gtk_label_new(L10n.shared.tr("rate.period")), 0, 0, 0) + for value in [10, 30, 60] { + let title = value == 60 ? L10n.shared.tr("rate.1hour") : L10n.shared.tr("rate.\(value)min") + let button = gtk_button_new_with_label(title) + gtk_widget_set_name(button, "settings:rate-window:\(value)") + _ = tc_gtk_on_clicked(button, linuxSettingsAction, opaque) + gtk_box_pack_start(tc_gtk_box(periodRow), button, 0, 0, 0) + rateWindowButtons[value] = button + } + gtk_box_pack_start(tc_gtk_box(page), periodRow, 0, 0, 0) + + let thresholds: [(SettingsKey, String, Int)] = [ + (.rateBurst, "💥 \(L10n.shared.tr("rate.burst"))", 500_000), + (.rateHot, "🔥 \(L10n.shared.tr("rate.hot"))", 100_000), + (.rateActive, "🏃 \(L10n.shared.tr("rate.active"))", 20_000), + (.rateCalm, "☕ \(L10n.shared.tr("rate.calm"))", 2_000), + ] + for (key, title, _) in thresholds { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + let label = gtk_label_new(title) + gtk_label_set_xalign(tc_gtk_label(label), 0) + gtk_widget_set_size_request(label, 160, -1) + let spin = gtk_entry_new() + gtk_entry_set_input_purpose(tc_gtk_entry(spin), GTK_INPUT_PURPOSE_NUMBER) + gtk_widget_set_size_request(spin, 150, -1) + gtk_box_pack_start(tc_gtk_box(row), label, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(row), spin, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(row), gtk_label_new("tokens / period"), 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(page), row, 0, 0, 0) + thresholdSpins[key] = spin + } + let hint = gtk_label_new(L10n.shared.tr("rate.rest")) + gtk_label_set_xalign(tc_gtk_label(hint), 0) + tc_gtk_add_class(hint, "tokenclock-settings-hint") + gtk_box_pack_start(tc_gtk_box(page), hint, 0, 0, 0) + return page + } + + private func themesPage() -> UnsafeMutablePointer? { + guard let scroll = gtk_scrolled_window_new(nil, nil), + let page = verticalPage() else { return nil } + gtk_scrolled_window_set_policy(tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) + appendSection(L10n.shared.tr("theme.title"), to: page) + let description = gtk_label_new( + localized( + zh: "使用与 macOS normal 相同的可视化表盘选择器预览并切换内置表盘。", + en: "Preview and switch built-in faces with the same visual picker model as macOS normal." + ) + ) + gtk_label_set_xalign(tc_gtk_label(description), 0) + gtk_label_set_line_wrap(tc_gtk_label(description), 1) + gtk_box_pack_start(tc_gtk_box(page), description, 0, 0, 0) + appendButton(L10n.shared.tr("menu.clockFace"), name: "settings:clock-face", to: page) + + appendSection(localized(zh: "自定义表盘", en: "Custom Clock Face"), to: page) + let nameRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + gtk_box_pack_start(tc_gtk_box(nameRow), gtk_label_new(localized(zh: "名称", en: "Name")), 0, 0, 0) + customNameEntry = gtk_entry_new() + gtk_widget_set_hexpand(customNameEntry, 1) + gtk_box_pack_start(tc_gtk_box(nameRow), customNameEntry, 1, 1, 0) + gtk_box_pack_start(tc_gtk_box(page), nameRow, 0, 0, 0) + + let colorFields: [(String, String)] = [ + ("dial", localized(zh: "表盘底色", en: "Dial")), + ("rim", localized(zh: "外圈颜色", en: "Rim")), + ("hour", localized(zh: "时针", en: "Hour hand")), + ("minute", localized(zh: "分针", en: "Minute hand")), + ("second", localized(zh: "秒针", en: "Second hand")), + ("centerOuter", localized(zh: "中心点外圈", en: "Center outer")), + ("centerInner", localized(zh: "中心点内圈", en: "Center inner")), + ("tick", localized(zh: "普通刻度", en: "Tick marks")), + ("majorTick", localized(zh: "主刻度", en: "Major ticks")), + ("number", localized(zh: "数字", en: "Numbers")), + ("primary", localized(zh: "主要文字", en: "Primary text")), + ("secondary", localized(zh: "次要文字", en: "Secondary text")), + ("panel", localized(zh: "详情面板", en: "Detail panel")), + ("panelText", localized(zh: "面板文字", en: "Panel text")), + ("panelSubtext", localized(zh: "面板次要文字", en: "Panel subtext")), + ("panelBorder", localized(zh: "面板边框", en: "Panel border")), + ("panelDivider", localized(zh: "面板分隔线", en: "Panel dividers")), + ] + for (key, title) in colorFields { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + let label = gtk_label_new(title) + gtk_label_set_xalign(tc_gtk_label(label), 0) + gtk_widget_set_size_request(label, 150, -1) + let entry = gtk_entry_new() + gtk_entry_set_placeholder_text(tc_gtk_entry(entry), "#RRGGBB or #RRGGBBAA") + gtk_widget_set_size_request(entry, 190, -1) + gtk_box_pack_start(tc_gtk_box(row), label, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(row), entry, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(page), row, 0, 0, 0) + customEntries[key] = entry + } + + let handRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + gtk_box_pack_start(tc_gtk_box(handRow), gtk_label_new(localized(zh: "指针样式", en: "Hand style")), 0, 0, 0) + for (key, title) in [("round", "Round"), ("tapered", "Tapered"), ("lance", "Lance"), ("sword", "Sword")] { + let button = gtk_button_new_with_label(title) + gtk_widget_set_name(button, "settings:custom-hand:\(key)") + _ = tc_gtk_on_clicked(button, linuxSettingsAction, opaque) + gtk_box_pack_start(tc_gtk_box(handRow), button, 0, 0, 0) + customHandButtons[key] = button + } + gtk_box_pack_start(tc_gtk_box(page), handRow, 0, 0, 0) + + for (key, title) in [ + ("rimWidth", localized(zh: "外圈宽度", en: "Rim width")), + ("hourWidth", localized(zh: "时针宽度", en: "Hour width")), + ("minuteWidth", localized(zh: "分针宽度", en: "Minute width")), + ("secondWidth", localized(zh: "秒针宽度", en: "Second width")), + ] { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + let label = gtk_label_new(title) + gtk_label_set_xalign(tc_gtk_label(label), 0) + gtk_widget_set_size_request(label, 150, -1) + let entry = gtk_entry_new() + gtk_entry_set_input_purpose(tc_gtk_entry(entry), GTK_INPUT_PURPOSE_NUMBER) + gtk_widget_set_size_request(entry, 100, -1) + gtk_box_pack_start(tc_gtk_box(row), label, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(row), entry, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(page), row, 0, 0, 0) + customNumericEntries[key] = entry + } + + let visibilityRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10) + for (key, title) in [ + ("ticks", localized(zh: "显示刻度", en: "Show ticks")), + ("numbers", localized(zh: "显示数字", en: "Show numbers")), + ("decoration", localized(zh: "天空装饰", en: "Sky decoration")), + ] { + let button = gtk_check_button_new_with_label(title) + gtk_box_pack_start(tc_gtk_box(visibilityRow), button, 0, 0, 0) + customToggleButtons[key] = button + } + gtk_box_pack_start(tc_gtk_box(page), visibilityRow, 0, 0, 0) + + let numberStyleRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + gtk_box_pack_start(tc_gtk_box(numberStyleRow), gtk_label_new(localized(zh: "数字样式", en: "Number style")), 0, 0, 0) + for (key, title) in [("arabic", "Arabic"), ("chinese", localized(zh: "中文", en: "Chinese"))] { + let button = gtk_button_new_with_label(title) + gtk_widget_set_name(button, "settings:custom-number-style:\(key)") + _ = tc_gtk_on_clicked(button, linuxSettingsAction, opaque) + gtk_box_pack_start(tc_gtk_box(numberStyleRow), button, 0, 0, 0) + customNumberStyleButtons[key] = button + } + gtk_box_pack_start(tc_gtk_box(page), numberStyleRow, 0, 0, 0) + + let fontRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + gtk_box_pack_start(tc_gtk_box(fontRow), gtk_label_new(localized(zh: "数字字体", en: "Number font")), 0, 0, 0) + for (key, title) in [("rounded", "Rounded"), ("serif", "Serif"), ("monospaced", "Mono"), ("default", "Default")] { + let button = gtk_button_new_with_label(title) + gtk_widget_set_name(button, "settings:custom-font:\(key)") + _ = tc_gtk_on_clicked(button, linuxSettingsAction, opaque) + gtk_box_pack_start(tc_gtk_box(fontRow), button, 0, 0, 0) + customFontButtons[key] = button + } + gtk_box_pack_start(tc_gtk_box(page), fontRow, 0, 0, 0) + + let actionRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + appendButton(localized(zh: "恢复经典默认", en: "Reset to Classic"), name: "settings:custom-reset", to: actionRow) + appendButton(localized(zh: "保存并应用", en: "Save & Apply"), name: "settings:custom-save", to: actionRow) + gtk_box_pack_start(tc_gtk_box(page), actionRow, 0, 0, 0) + + let savedThemes = LinuxCustomThemeStore.shared.themes + if !savedThemes.isEmpty { + appendSection(L10n.shared.tr("menu.myClockFaces"), to: page) + for saved in savedThemes { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + let label = gtk_label_new(saved.name) + gtk_label_set_xalign(tc_gtk_label(label), 0) + gtk_widget_set_hexpand(label, 1) + gtk_box_pack_start(tc_gtk_box(row), label, 1, 1, 0) + appendButton(localized(zh: "应用", en: "Apply"), name: "settings:custom-apply:\(saved.id.uuidString)", to: row) + appendButton(localized(zh: "删除", en: "Delete"), name: "settings:custom-delete:\(saved.id.uuidString)", to: row) + gtk_box_pack_start(tc_gtk_box(page), row, 0, 0, 0) + } + } + gtk_container_add(tc_gtk_container(scroll), page) + return scroll + } + + private func verticalPage() -> UnsafeMutablePointer? { + let page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12) + gtk_container_set_border_width(tc_gtk_container(page), 18) + return page + } + + private func appendPage( + _ page: UnsafeMutablePointer?, + title: String, + to notebook: UnsafeMutablePointer + ) { + guard let page else { return } + _ = gtk_notebook_append_page(tc_gtk_notebook(notebook), page, gtk_label_new(title)) + } + + private func appendSection(_ title: String, to page: UnsafeMutablePointer?) { + let label = gtk_label_new(title) + gtk_label_set_xalign(tc_gtk_label(label), 0) + tc_gtk_add_class(label, "tokenclock-settings-section") + gtk_box_pack_start(tc_gtk_box(page), label, 0, 0, 2) + } + + private func appendButton( + _ title: String, + name: String, + to box: UnsafeMutablePointer? + ) { + let button = gtk_button_new_with_label(title) + gtk_widget_set_name(button, name) + _ = tc_gtk_on_clicked(button, linuxSettingsAction, opaque) + gtk_box_pack_start(tc_gtk_box(box), button, 0, 0, 0) + } + + private func loadValues() { + let defaults = UserDefaults.standard + if let apiEnabledButton { + gtk_toggle_button_set_active( + tc_gtk_toggle_button(apiEnabledButton), + defaults.bool(for: .apiServerEnabled, default: true) ? 1 : 0 + ) + } + if let apiPortSpin { + let raw = defaults.int(for: .apiServerPort) + gtk_entry_set_text( + tc_gtk_entry(apiPortSpin), + "\(raw > 0 ? raw : Int(AppConfig.LocalServer.defaultPort))" + ) + } + if let cursorCloudButton { + gtk_toggle_button_set_active( + tc_gtk_toggle_button(cursorCloudButton), + defaults.bool(for: .cursorCloudFetchEnabled, default: true) ? 1 : 0 + ) + } + let enabled = model.enabledTools + for (name, button) in toolButtons { + gtk_toggle_button_set_active(tc_gtk_toggle_button(button), enabled.contains(name) ? 1 : 0) + } + for option in Self.toolOptions { + guard let entry = pathEntries[option.service] else { continue } + gtk_entry_set_text(tc_gtk_entry(entry), defaults.string(for: option.pathKey) ?? "") + } + selectedRateWindow = model.rateWindowMinutes + updateRateWindowButtons() + let thresholdDefaults: [SettingsKey: Int] = [ + .rateBurst: 500_000, .rateHot: 100_000, .rateActive: 20_000, .rateCalm: 2_000, + ] + for (key, spin) in thresholdSpins { + let saved = defaults.int(for: key) + gtk_entry_set_text(tc_gtk_entry(spin), "\(saved > 0 ? saved : thresholdDefaults[key] ?? 0)") + } + loadCustomThemeValues() + } + + private func loadCustomThemeValues() { + let store = LinuxCustomThemeStore.shared + let config = store.config + let activeID = UserDefaults.standard.string(for: .activeCustomThemeId).flatMap(UUID.init(uuidString:)) + let name = activeID.flatMap { id in store.themes.first(where: { $0.id == id })?.name } + ?? localized(zh: "我的表盘", en: "My Clock Face") + if let customNameEntry { gtk_entry_set_text(tc_gtk_entry(customNameEntry), name) } + let values: [String: LinuxThemeColor] = [ + "dial": config.dialColor, "rim": config.dialRimColor, + "hour": config.hourHandColor, "minute": config.minuteHandColor, + "second": config.secondHandColor, + "centerOuter": config.centerDotOuterColor, "centerInner": config.centerDotInnerColor, + "tick": config.tickMarkColor, "majorTick": config.majorTickMarkColor, + "number": config.numberColor, + "primary": config.textPrimaryColor, "secondary": config.textSecondaryColor, + "panel": config.dropdownBgColor, "panelText": config.dropdownTextColor, + "panelSubtext": config.dropdownSubtextColor, + "panelBorder": config.dropdownBorderColor, + "panelDivider": config.dropdownDividerColor, + ] + for (key, color) in values where customEntries[key] != nil { + gtk_entry_set_text(tc_gtk_entry(customEntries[key]), hexString(color)) + } + let numbers: [String: Double] = [ + "rimWidth": config.dialRimWidth, + "hourWidth": config.hourHandWidth, + "minuteWidth": config.minuteHandWidth, + "secondWidth": config.secondHandWidth, + ] + for (key, value) in numbers where customNumericEntries[key] != nil { + gtk_entry_set_text(tc_gtk_entry(customNumericEntries[key]), String(format: "%.1f", value)) + } + let toggles = [ + "ticks": config.hasTickMarks, + "numbers": config.showNumbers, + "decoration": config.hasDialDecoration, + ] + for (key, enabled) in toggles where customToggleButtons[key] != nil { + gtk_toggle_button_set_active(tc_gtk_toggle_button(customToggleButtons[key]), enabled ? 1 : 0) + } + selectedCustomHandStyle = config.handStyleRaw + selectedCustomNumberStyle = config.numberStyleRaw + selectedCustomFont = config.numberFontDesignRaw + updateCustomOptionButtons() + } + + private func updateCustomHandButtons() { + for (key, button) in customHandButtons { + let title = key.prefix(1).uppercased() + key.dropFirst() + gtk_button_set_label(tc_gtk_button(button), key == selectedCustomHandStyle ? "✓ \(title)" : title) + } + } + + private func updateCustomOptionButtons() { + updateCustomHandButtons() + for (key, button) in customNumberStyleButtons { + let title = key == "arabic" ? "Arabic" : localized(zh: "中文", en: "Chinese") + gtk_button_set_label(tc_gtk_button(button), key == selectedCustomNumberStyle ? "✓ \(title)" : title) + } + for (key, button) in customFontButtons { + let title: String + switch key { + case "rounded": title = "Rounded" + case "serif": title = "Serif" + case "monospaced": title = "Mono" + default: title = "Default" + } + gtk_button_set_label(tc_gtk_button(button), key == selectedCustomFont ? "✓ \(title)" : title) + } + } + + private func saveCustomTheme() { + var config = LinuxCustomThemeStore.shared.config + config.dialColor = colorValue("dial", fallback: config.dialColor) + config.dialRimColor = colorValue("rim", fallback: config.dialRimColor) + config.hourHandColor = colorValue("hour", fallback: config.hourHandColor) + config.minuteHandColor = colorValue("minute", fallback: config.minuteHandColor) + config.secondHandColor = colorValue("second", fallback: config.secondHandColor) + config.centerDotOuterColor = colorValue("centerOuter", fallback: config.centerDotOuterColor) + config.centerDotInnerColor = colorValue("centerInner", fallback: config.centerDotInnerColor) + config.numberColor = colorValue("number", fallback: config.numberColor) + config.tickMarkColor = colorValue("tick", fallback: config.tickMarkColor) + config.majorTickMarkColor = colorValue("majorTick", fallback: config.majorTickMarkColor) + config.textPrimaryColor = colorValue("primary", fallback: config.textPrimaryColor) + config.textSecondaryColor = colorValue("secondary", fallback: config.textSecondaryColor) + config.dropdownBgColor = colorValue("panel", fallback: config.dropdownBgColor) + config.dropdownTextColor = colorValue("panelText", fallback: config.dropdownTextColor) + config.dropdownSubtextColor = colorValue("panelSubtext", fallback: config.dropdownSubtextColor) + config.dropdownBorderColor = colorValue("panelBorder", fallback: config.dropdownBorderColor) + config.dropdownDividerColor = colorValue("panelDivider", fallback: config.dropdownDividerColor) + config.dialRimWidth = numericValue("rimWidth", fallback: config.dialRimWidth, range: 0...20) + config.hourHandWidth = numericValue("hourWidth", fallback: config.hourHandWidth, range: 1...10) + config.minuteHandWidth = numericValue("minuteWidth", fallback: config.minuteHandWidth, range: 1...8) + config.secondHandWidth = numericValue("secondWidth", fallback: config.secondHandWidth, range: 0.5...5) + config.hasTickMarks = toggleValue("ticks", fallback: config.hasTickMarks) + config.showNumbers = toggleValue("numbers", fallback: config.showNumbers) + config.hasDialDecoration = toggleValue("decoration", fallback: config.hasDialDecoration) + config.handStyleRaw = selectedCustomHandStyle + config.numberStyleRaw = selectedCustomNumberStyle + config.numberFontDesignRaw = selectedCustomFont + let name = customNameEntry.map { + String(cString: gtk_entry_get_text(tc_gtk_entry($0))) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + let saved = LinuxCustomThemeStore.shared.save( + config: config, + name: (name?.isEmpty == false ? name! : localized(zh: "我的表盘", en: "My Clock Face")) + ) + owner?.applyCustomTheme(id: saved.id) + hide() + } + + private func resetCustomTheme() { + _ = LinuxCustomThemeStore.shared.resetDraft() + UserDefaults.standard.remove(.activeCustomThemeId) + loadCustomThemeValues() + owner?.applyCustomTheme(id: nil) + } + + private func colorValue(_ key: String, fallback: LinuxThemeColor) -> LinuxThemeColor { + guard let entry = customEntries[key] else { return fallback } + return parseHex(String(cString: gtk_entry_get_text(tc_gtk_entry(entry)))) ?? fallback + } + + private func numericValue( + _ key: String, + fallback: Double, + range: ClosedRange + ) -> Double { + guard let entry = customNumericEntries[key], + let value = Double(String(cString: gtk_entry_get_text(tc_gtk_entry(entry)))) else { + return fallback + } + return min(range.upperBound, max(range.lowerBound, value)) + } + + private func toggleValue(_ key: String, fallback: Bool) -> Bool { + guard let button = customToggleButtons[key] else { return fallback } + return gtk_toggle_button_get_active(tc_gtk_toggle_button(button)) != 0 + } + + private func parseHex(_ raw: String) -> LinuxThemeColor? { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "#", with: "") + guard value.count == 6 || value.count == 8, + let number = UInt64(value, radix: 16) else { return nil } + if value.count == 6 { + return LinuxThemeColor( + red: Double((number >> 16) & 0xFF) / 255, + green: Double((number >> 8) & 0xFF) / 255, + blue: Double(number & 0xFF) / 255 + ) + } + return LinuxThemeColor( + red: Double((number >> 24) & 0xFF) / 255, + green: Double((number >> 16) & 0xFF) / 255, + blue: Double((number >> 8) & 0xFF) / 255, + opacity: Double(number & 0xFF) / 255 + ) + } + + private func hexString(_ color: LinuxThemeColor) -> String { + let red = Int(min(1, max(0, color.red)) * 255) + let green = Int(min(1, max(0, color.green)) * 255) + let blue = Int(min(1, max(0, color.blue)) * 255) + let alpha = Int(min(1, max(0, color.opacity)) * 255) + return alpha == 255 + ? String(format: "#%02X%02X%02X", red, green, blue) + : String(format: "#%02X%02X%02X%02X", red, green, blue, alpha) + } + + private func updateRateWindowButtons() { + for (value, button) in rateWindowButtons { + let title = value == 60 ? L10n.shared.tr("rate.1hour") : L10n.shared.tr("rate.\(value)min") + gtk_button_set_label( + tc_gtk_button(button), + value == selectedRateWindow ? "✓ \(title)" : title + ) + } + } + + private func runDetection() { + let summary = PathDetector.runFullDetection() + for result in summary.results where result.exists { + if let entry = pathEntries[result.service] { + gtk_entry_set_text(tc_gtk_entry(entry), result.detectedPath) + } + } + let time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + let message = summary.allFound + ? L10n.shared.tr("settings.detectAllFound", summary.totalCount, time) + : L10n.shared.tr("settings.detectPartial", summary.foundCount, summary.totalCount, time) + if let detectionLabel { gtk_label_set_text(tc_gtk_label(detectionLabel), message) } + } + + private func browse(service: String) { + guard let window, let entry = pathEntries[service] else { return } + let current = String(cString: gtk_entry_get_text(tc_gtk_entry(entry))) + let title = Self.toolOptions.first(where: { $0.service == service }) + .map { "\(L10n.shared.tr("settings.browse")) · \($0.name)" } + ?? L10n.shared.tr("settings.browse") + let selected = title.withCString { titlePointer in + current.withCString { currentPointer in + tc_gtk_choose_folder(window, titlePointer, currentPointer) + } + } + guard let selected else { return } + gtk_entry_set_text(tc_gtk_entry(entry), selected) + tc_g_free(selected) + } + + private func saveAndClose() { + let defaults = UserDefaults.standard + let oldAPIEnabled = defaults.bool(for: .apiServerEnabled, default: true) + let oldAPIPort = defaults.int(for: .apiServerPort, default: Int(AppConfig.LocalServer.defaultPort)) + let apiEnabled = apiEnabledButton.map { + gtk_toggle_button_get_active(tc_gtk_toggle_button($0)) != 0 + } ?? true + let apiPort = apiPortSpin.flatMap { + Int(String(cString: gtk_entry_get_text(tc_gtk_entry($0)))) + }.map { min(Int(UInt16.max), max(1_024, $0)) } ?? Int(AppConfig.LocalServer.defaultPort) + defaults.setBool(apiEnabled, for: .apiServerEnabled) + defaults.setInt(apiPort, for: .apiServerPort) + if let cursorCloudButton { + defaults.setBool( + gtk_toggle_button_get_active(tc_gtk_toggle_button(cursorCloudButton)) != 0, + for: .cursorCloudFetchEnabled + ) + } + + var enabled: Set = [] + for (name, button) in toolButtons where + gtk_toggle_button_get_active(tc_gtk_toggle_button(button)) != 0 { + enabled.insert(name) + } + for option in Self.toolOptions { + guard let entry = pathEntries[option.service] else { continue } + let value = String(cString: gtk_entry_get_text(tc_gtk_entry(entry))) + .trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { defaults.remove(option.pathKey) } + else { defaults.setString(value, for: option.pathKey) } + } + + var burst = thresholdValue(.rateBurst, fallback: 500_000) + var hot = thresholdValue(.rateHot, fallback: 100_000) + var active = thresholdValue(.rateActive, fallback: 20_000) + var calm = thresholdValue(.rateCalm, fallback: 2_000) + if hot >= burst { hot = max(0, burst - 1) } + if active >= hot { active = max(0, hot - 1) } + if calm >= active { calm = max(0, active - 1) } + if active <= calm { active = calm + 1 } + if hot <= active { hot = active + 1 } + if burst <= hot { burst = hot + 1 } + defaults.setInt(burst, for: .rateBurst) + defaults.setInt(hot, for: .rateHot) + defaults.setInt(active, for: .rateActive) + defaults.setInt(calm, for: .rateCalm) + model.applyPreferences(enabledTools: enabled, rateWindowMinutes: selectedRateWindow) + + owner?.settingsDidSave(apiChanged: oldAPIEnabled != apiEnabled || oldAPIPort != apiPort) + hide() + } + + private func thresholdValue(_ key: SettingsKey, fallback: Int) -> Int { + guard let spin = thresholdSpins[key] else { return fallback } + return Int(String(cString: gtk_entry_get_text(tc_gtk_entry(spin)))) ?? fallback + } + + private func clearWidgetReferences() { + apiEnabledButton = nil + apiPortSpin = nil + cursorCloudButton = nil + detectionLabel = nil + toolButtons.removeAll() + pathEntries.removeAll() + rateWindowButtons.removeAll() + thresholdSpins.removeAll() + customNameEntry = nil + customEntries.removeAll() + customNumericEntries.removeAll() + customToggleButtons.removeAll() + customHandButtons.removeAll() + customNumberStyleButtons.removeAll() + customFontButtons.removeAll() + } + + private func localized(zh: String, en: String) -> String { + L10n.shared.language == .en ? en : zh + } +} + +private func settings(from data: gpointer?) -> LinuxSettingsWindow? { + guard let data else { return nil } + return Unmanaged.fromOpaque(data).takeUnretainedValue() +} + +private func linuxSettingsAction( + _ widget: UnsafeMutablePointer?, + _ data: gpointer? +) { + guard let widget else { return } + settings(from: data)?.handleAction(widget: widget) +} diff --git a/Sources/TokenClock/Linux/LinuxThemePicker.swift b/Sources/TokenClock/Linux/LinuxThemePicker.swift new file mode 100644 index 0000000..b4dd264 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxThemePicker.swift @@ -0,0 +1,187 @@ +import Foundation +import CGtk + +/// GTK visual counterpart of macOS's clock-face picker popover. +final class LinuxThemePicker: @unchecked Sendable { + private let renderer = LinuxClockRenderer() + private weak var parentOwner: LinuxApp? + private var parent: UnsafeMutablePointer? + private var selected: LinuxClockTheme = .classic + private var buttons: [LinuxClockTheme: UnsafeMutablePointer] = [:] + private var labels: [LinuxClockTheme: UnsafeMutablePointer] = [:] + private var previewAreas: [LinuxClockTheme: UnsafeMutablePointer] = [:] + private var titleLabel: UnsafeMutablePointer? + + private(set) var window: UnsafeMutablePointer? + private lazy var opaque = Unmanaged.passUnretained(self).toOpaque() + + init(parent: UnsafeMutablePointer, owner: LinuxApp) { + self.parent = parent + parentOwner = owner + buildWindow(parent: parent) + } + + func show(selected: LinuxClockTheme) { + self.selected = selected + updateSelection() + guard let window else { return } + gtk_window_set_position(tc_gtk_window(window), GTK_WIN_POS_CENTER_ON_PARENT) + gtk_widget_show_all(window) + gtk_window_present(tc_gtk_window(window)) + } + + func hide() { + if let window { gtk_widget_hide(window) } + } + + func refreshLanguage() { + if let window { gtk_window_set_title(tc_gtk_window(window), L10n.shared.tr("menu.clockFace")) } + if let titleLabel { gtk_label_set_text(tc_gtk_label(titleLabel), L10n.shared.tr("menu.clockFace")) } + updateSelection() + } + + fileprivate func draw(widget: UnsafeMutablePointer, context: OpaquePointer) { + let name = String(cString: tc_gtk_widget_name(widget)) + guard name.hasPrefix("theme-preview:"), + let theme = LinuxClockTheme(rawValue: String(name.dropFirst("theme-preview:".count))) else { + return + } + renderer.drawPreview( + context, + width: Double(gtk_widget_get_allocated_width(widget)), + height: Double(gtk_widget_get_allocated_height(widget)), + theme: theme + ) + } + + fileprivate func activate(widget: UnsafeMutablePointer) { + let name = String(cString: tc_gtk_widget_name(widget)) + guard name.hasPrefix("theme-select:"), + let theme = LinuxClockTheme(rawValue: String(name.dropFirst("theme-select:".count))) else { + return + } + selected = theme + updateSelection() + parentOwner?.chooseThemeFromPicker(theme) + hide() + } + + private func buildWindow(parent: UnsafeMutablePointer) { + guard let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL), + let root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10) else { return } + window = createdWindow + gtk_window_set_title(tc_gtk_window(createdWindow), L10n.shared.tr("menu.clockFace")) + gtk_window_set_default_size(tc_gtk_window(createdWindow), 440, 280) + gtk_window_set_resizable(tc_gtk_window(createdWindow), 0) + gtk_window_set_transient_for(tc_gtk_window(createdWindow), tc_gtk_window(parent)) + gtk_window_set_skip_taskbar_hint(tc_gtk_window(createdWindow), 1) + gtk_window_set_skip_pager_hint(tc_gtk_window(createdWindow), 1) + gtk_window_set_keep_above(tc_gtk_window(createdWindow), 1) + gtk_container_set_border_width(tc_gtk_container(root), 14) + gtk_container_add(tc_gtk_container(createdWindow), root) + + let title = gtk_label_new(L10n.shared.tr("menu.clockFace")) + titleLabel = title + gtk_label_set_xalign(tc_gtk_label(title), 0) + tc_gtk_add_class(title, "tokenclock-picker-title") + gtk_box_pack_start(tc_gtk_box(root), title, 0, 0, 0) + + for rowIndex in 0..<2 { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10) + gtk_box_pack_start(tc_gtk_box(root), row, 1, 1, 0) + for column in 0..<4 { + let index = rowIndex * 4 + column + let theme = LinuxClockTheme.builtInCases[index] + appendTheme(theme, to: row) + } + } + + tc_gtk_apply_css(""" + .tokenclock-theme-tile { + background: rgba(120, 120, 120, 0.06); + border: 1px solid rgba(120, 120, 120, 0.20); + border-radius: 12px; + padding: 6px; + } + .tokenclock-theme-tile:hover { + background: rgba(73, 132, 255, 0.12); + border-color: rgba(73, 132, 255, 0.55); + } + .tokenclock-theme-selected { + background: rgba(73, 132, 255, 0.20); + border: 2px solid rgba(73, 132, 255, 0.95); + } + .tokenclock-picker-title { font-size: 16px; font-weight: 700; } + """) + _ = tc_gtk_on_destroy(createdWindow, linuxThemePickerDestroyed, opaque) + } + + private func appendTheme( + _ theme: LinuxClockTheme, + to row: UnsafeMutablePointer? + ) { + guard let button = gtk_button_new(), + let stack = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4), + let preview = gtk_drawing_area_new(), + let label = gtk_label_new(theme.displayName) else { return } + gtk_widget_set_name(button, "theme-select:\(theme.rawValue)") + gtk_widget_set_name(preview, "theme-preview:\(theme.rawValue)") + tc_gtk_add_class(button, "tokenclock-theme-tile") + gtk_button_set_relief(tc_gtk_button(button), GTK_RELIEF_NONE) + gtk_widget_set_size_request(button, 94, 105) + gtk_widget_set_size_request(preview, 76, 76) + gtk_container_add(tc_gtk_container(button), stack) + gtk_box_pack_start(tc_gtk_box(stack), preview, 1, 1, 0) + gtk_box_pack_start(tc_gtk_box(stack), label, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(row), button, 1, 1, 0) + _ = tc_gtk_on_draw(preview, linuxThemePreviewDraw, opaque) + _ = tc_gtk_on_clicked(button, linuxThemePickerAction, opaque) + buttons[theme] = button + labels[theme] = label + previewAreas[theme] = preview + } + + private func updateSelection() { + for theme in LinuxClockTheme.builtInCases { + guard let button = buttons[theme], let label = labels[theme] else { continue } + if theme == selected { + tc_gtk_add_class(button, "tokenclock-theme-selected") + gtk_label_set_text(tc_gtk_label(label), "✓ \(theme.displayName)") + } else { + tc_gtk_remove_class(button, "tokenclock-theme-selected") + gtk_label_set_text(tc_gtk_label(label), theme.displayName) + } + if let preview = previewAreas[theme] { gtk_widget_queue_draw(preview) } + } + } +} + +private func picker(from data: gpointer?) -> LinuxThemePicker? { + guard let data else { return nil } + return Unmanaged.fromOpaque(data).takeUnretainedValue() +} + +private func linuxThemePreviewDraw( + _ widget: UnsafeMutablePointer?, + _ context: OpaquePointer?, + _ data: gpointer? +) -> gboolean { + guard let widget, let context else { return 0 } + picker(from: data)?.draw(widget: widget, context: context) + return 0 +} + +private func linuxThemePickerAction( + _ widget: UnsafeMutablePointer?, + _ data: gpointer? +) { + guard let widget else { return } + picker(from: data)?.activate(widget: widget) +} + +private func linuxThemePickerDestroyed( + _ widget: UnsafeMutablePointer?, + _ data: gpointer? +) { + picker(from: data)?.hide() +} diff --git a/Sources/TokenClock/Linux/LinuxUsageModel.swift b/Sources/TokenClock/Linux/LinuxUsageModel.swift index fd6bcab..c7eab64 100644 --- a/Sources/TokenClock/Linux/LinuxUsageModel.swift +++ b/Sources/TokenClock/Linux/LinuxUsageModel.swift @@ -12,6 +12,8 @@ struct HourlyForecast: Sendable { final class LinuxUsageModel: @unchecked Sendable { private let lock = NSLock() private var storedTools: [ToolUsage] + private var storedEnabledTools: Set + private var storedRateWindowMinutes: Int private var scanning = false private let openclawService = OpenClawUsageService() @@ -29,21 +31,20 @@ final class LinuxUsageModel: @unchecked Sendable { private let continueService = ContinueUsageService() private let cursorAgentService = CursorAgentUsageService() - private static let allToolNames = Set([ + static let allToolNames = Set([ "OpenClaw", "Claude Code", "Gemini CLI", "Codex", "Hermes", "OpenCode", "Qwen Code", "Copilot", "Grok", "Aider", "Antigravity", "Cline", "Continue", "Cursor Agent", ]) - let enabledTools: Set - let rateWindowMinutes: Int - init() { let saved = UserDefaults.standard.stringArray(for: .enabledTools) - enabledTools = Set(saved ?? Array(Self.allToolNames)) + let enabled = Set(saved ?? Array(Self.allToolNames)) let savedWindow = UserDefaults.standard.int(for: .rateWindow) - rateWindowMinutes = savedWindow > 0 ? savedWindow : 10 - storedTools = MockUsageService.generateInitialData(enabledTools: enabledTools) + let rateWindow = savedWindow > 0 ? savedWindow : 10 + storedEnabledTools = enabled + storedRateWindowMinutes = rateWindow + storedTools = MockUsageService.generateInitialData(enabledTools: enabled) if !PathConfig.hasRunInitialDetection { PathConfig.hasRunInitialDetection = true @@ -56,10 +57,34 @@ final class LinuxUsageModel: @unchecked Sendable { lock.lock() defer { lock.unlock() } return storedTools - .filter { enabledTools.contains($0.name) } + .filter { storedEnabledTools.contains($0.name) } .sorted { $0.todayTokens > $1.todayTokens } } + var enabledTools: Set { + lock.lock() + defer { lock.unlock() } + return storedEnabledTools + } + + var rateWindowMinutes: Int { + lock.lock() + defer { lock.unlock() } + return storedRateWindowMinutes + } + + func applyPreferences(enabledTools: Set, rateWindowMinutes: Int) { + let normalizedTools = enabledTools.intersection(Self.allToolNames) + let normalizedWindow = [10, 30, 60].contains(rateWindowMinutes) + ? rateWindowMinutes : 10 + lock.lock() + storedEnabledTools = normalizedTools + storedRateWindowMinutes = normalizedWindow + lock.unlock() + UserDefaults.standard.setStringArray(Array(normalizedTools), for: .enabledTools) + UserDefaults.standard.setInt(normalizedWindow, for: .rateWindow) + } + @discardableResult func scan(incremental: Bool) -> Bool { lock.lock() @@ -68,6 +93,8 @@ final class LinuxUsageModel: @unchecked Sendable { return false } scanning = true + let enabled = storedEnabledTools + let rateWindow = storedRateWindowMinutes lock.unlock() defer { @@ -76,77 +103,77 @@ final class LinuxUsageModel: @unchecked Sendable { lock.unlock() } - if enabledTools.contains("OpenClaw") { incremental ? openclawService.incrementalScan() : openclawService.fullScan() } - if enabledTools.contains("Claude Code") { incremental ? claudeCodeService.incrementalScan() : claudeCodeService.fullScan() } - if enabledTools.contains("Gemini CLI") { incremental ? geminiService.incrementalScan() : geminiService.fullScan() } - if enabledTools.contains("Codex") { incremental ? codexService.incrementalScan() : codexService.fullScan() } - if enabledTools.contains("Hermes") { incremental ? hermesService.incrementalScan() : hermesService.fullScan() } - if enabledTools.contains("OpenCode") { incremental ? opencodeService.incrementalScan() : opencodeService.fullScan() } - if enabledTools.contains("Qwen Code") { incremental ? qwenService.incrementalScan() : qwenService.fullScan() } - if enabledTools.contains("Copilot") { incremental ? copilotService.incrementalScan() : copilotService.fullScan() } - if enabledTools.contains("Grok") { incremental ? grokService.incrementalScan() : grokService.fullScan() } - if enabledTools.contains("Aider") { incremental ? aiderService.incrementalScan() : aiderService.fullScan() } - if enabledTools.contains("Antigravity") { incremental ? antigravityService.incrementalScan() : antigravityService.fullScan() } - if enabledTools.contains("Cline") { incremental ? clineService.incrementalScan() : clineService.fullScan() } - if enabledTools.contains("Continue") { incremental ? continueService.incrementalScan() : continueService.fullScan() } - if enabledTools.contains("Cursor Agent") { incremental ? cursorAgentService.incrementalScan() : cursorAgentService.fullScan() } + if enabled.contains("OpenClaw") { incremental ? openclawService.incrementalScan() : openclawService.fullScan() } + if enabled.contains("Claude Code") { incremental ? claudeCodeService.incrementalScan() : claudeCodeService.fullScan() } + if enabled.contains("Gemini CLI") { incremental ? geminiService.incrementalScan() : geminiService.fullScan() } + if enabled.contains("Codex") { incremental ? codexService.incrementalScan() : codexService.fullScan() } + if enabled.contains("Hermes") { incremental ? hermesService.incrementalScan() : hermesService.fullScan() } + if enabled.contains("OpenCode") { incremental ? opencodeService.incrementalScan() : opencodeService.fullScan() } + if enabled.contains("Qwen Code") { incremental ? qwenService.incrementalScan() : qwenService.fullScan() } + if enabled.contains("Copilot") { incremental ? copilotService.incrementalScan() : copilotService.fullScan() } + if enabled.contains("Grok") { incremental ? grokService.incrementalScan() : grokService.fullScan() } + if enabled.contains("Aider") { incremental ? aiderService.incrementalScan() : aiderService.fullScan() } + if enabled.contains("Antigravity") { incremental ? antigravityService.incrementalScan() : antigravityService.fullScan() } + if enabled.contains("Cline") { incremental ? clineService.incrementalScan() : clineService.fullScan() } + if enabled.contains("Continue") { incremental ? continueService.incrementalScan() : continueService.fullScan() } + if enabled.contains("Cursor Agent") { incremental ? cursorAgentService.incrementalScan() : cursorAgentService.fullScan() } var results: [String: ScanSnapshot] = [:] - if enabledTools.contains("OpenClaw") { + if enabled.contains("OpenClaw") { let usage = openclawService.todayUsage() - results["OpenClaw"] = snapshot(usage, openclawService.recentUsage(minutes: rateWindowMinutes).tokens, openclawService.currentHourTokens(), openclawService.isActive(), openclawService.todaySessions()) + results["OpenClaw"] = snapshot(usage, openclawService.recentUsage(minutes: rateWindow).tokens, openclawService.currentHourTokens(), openclawService.isActive(), openclawService.todaySessions()) } - if enabledTools.contains("Claude Code") { + if enabled.contains("Claude Code") { let usage = claudeCodeService.todayUsage() - results["Claude Code"] = snapshot(usage, claudeCodeService.recentUsage(minutes: rateWindowMinutes).tokens, claudeCodeService.currentHourTokens(), claudeCodeService.isActive(), claudeCodeService.todaySessions()) + results["Claude Code"] = snapshot(usage, claudeCodeService.recentUsage(minutes: rateWindow).tokens, claudeCodeService.currentHourTokens(), claudeCodeService.isActive(), claudeCodeService.todaySessions()) } - if enabledTools.contains("Gemini CLI") { + if enabled.contains("Gemini CLI") { let usage = geminiService.todayUsage() - results["Gemini CLI"] = snapshot(usage, geminiService.recentUsage(minutes: rateWindowMinutes).tokens, geminiService.currentHourTokens(), geminiService.isActive(), geminiService.todaySessions()) + results["Gemini CLI"] = snapshot(usage, geminiService.recentUsage(minutes: rateWindow).tokens, geminiService.currentHourTokens(), geminiService.isActive(), geminiService.todaySessions()) } - if enabledTools.contains("Codex") { + if enabled.contains("Codex") { let usage = codexService.todayUsage() - results["Codex"] = snapshot(usage, codexService.recentUsage(minutes: rateWindowMinutes).tokens, codexService.currentHourTokens(), codexService.isActive(), codexService.todaySessions()) + results["Codex"] = snapshot(usage, codexService.recentUsage(minutes: rateWindow).tokens, codexService.currentHourTokens(), codexService.isActive(), codexService.todaySessions()) } - if enabledTools.contains("Hermes") { + if enabled.contains("Hermes") { let usage = hermesService.todayUsage() - results["Hermes"] = snapshot(usage, hermesService.recentUsage(minutes: rateWindowMinutes).tokens, hermesService.currentHourTokens(), hermesService.isActive(), hermesService.todaySessions()) + results["Hermes"] = snapshot(usage, hermesService.recentUsage(minutes: rateWindow).tokens, hermesService.currentHourTokens(), hermesService.isActive(), hermesService.todaySessions()) } - if enabledTools.contains("OpenCode") { + if enabled.contains("OpenCode") { let usage = opencodeService.todayUsage() - results["OpenCode"] = snapshot(usage, opencodeService.recentUsage(minutes: rateWindowMinutes).tokens, opencodeService.currentHourTokens(), opencodeService.isActive(), opencodeService.todaySessions()) + results["OpenCode"] = snapshot(usage, opencodeService.recentUsage(minutes: rateWindow).tokens, opencodeService.currentHourTokens(), opencodeService.isActive(), opencodeService.todaySessions()) } - if enabledTools.contains("Qwen Code") { + if enabled.contains("Qwen Code") { let usage = qwenService.todayUsage() - results["Qwen Code"] = snapshot(usage, qwenService.recentUsage(minutes: rateWindowMinutes).tokens, qwenService.currentHourTokens(), qwenService.isActive(), qwenService.todaySessions()) + results["Qwen Code"] = snapshot(usage, qwenService.recentUsage(minutes: rateWindow).tokens, qwenService.currentHourTokens(), qwenService.isActive(), qwenService.todaySessions()) } - if enabledTools.contains("Copilot") { + if enabled.contains("Copilot") { let usage = copilotService.todayUsage() - results["Copilot"] = snapshot(usage, copilotService.recentUsage(minutes: rateWindowMinutes).tokens, copilotService.currentHourTokens(), copilotService.isActive(), copilotService.todaySessions()) + results["Copilot"] = snapshot(usage, copilotService.recentUsage(minutes: rateWindow).tokens, copilotService.currentHourTokens(), copilotService.isActive(), copilotService.todaySessions()) } - if enabledTools.contains("Grok") { + if enabled.contains("Grok") { let usage = grokService.todayUsage() - results["Grok"] = snapshot(usage, grokService.recentUsage(minutes: rateWindowMinutes).tokens, grokService.currentHourTokens(), grokService.isActive(), grokService.todaySessions()) + results["Grok"] = snapshot(usage, grokService.recentUsage(minutes: rateWindow).tokens, grokService.currentHourTokens(), grokService.isActive(), grokService.todaySessions()) } - if enabledTools.contains("Aider") { + if enabled.contains("Aider") { let usage = aiderService.todayUsage() - results["Aider"] = snapshot(usage, aiderService.recentUsage(minutes: rateWindowMinutes).tokens, aiderService.currentHourTokens(), aiderService.isActive(), aiderService.todaySessions()) + results["Aider"] = snapshot(usage, aiderService.recentUsage(minutes: rateWindow).tokens, aiderService.currentHourTokens(), aiderService.isActive(), aiderService.todaySessions()) } - if enabledTools.contains("Antigravity") { + if enabled.contains("Antigravity") { let usage = antigravityService.todayUsage() - results["Antigravity"] = snapshot(usage, antigravityService.recentUsage(minutes: rateWindowMinutes).tokens, antigravityService.currentHourTokens(), antigravityService.isActive(), antigravityService.todaySessions()) + results["Antigravity"] = snapshot(usage, antigravityService.recentUsage(minutes: rateWindow).tokens, antigravityService.currentHourTokens(), antigravityService.isActive(), antigravityService.todaySessions()) } - if enabledTools.contains("Cline") { + if enabled.contains("Cline") { let usage = clineService.todayUsage() - results["Cline"] = snapshot(usage, clineService.recentUsage(minutes: rateWindowMinutes).tokens, clineService.currentHourTokens(), clineService.isActive(), clineService.todaySessions()) + results["Cline"] = snapshot(usage, clineService.recentUsage(minutes: rateWindow).tokens, clineService.currentHourTokens(), clineService.isActive(), clineService.todaySessions()) } - if enabledTools.contains("Continue") { + if enabled.contains("Continue") { let usage = continueService.todayUsage() - results["Continue"] = snapshot(usage, continueService.recentUsage(minutes: rateWindowMinutes).tokens, continueService.currentHourTokens(), continueService.isActive(), continueService.todaySessions()) + results["Continue"] = snapshot(usage, continueService.recentUsage(minutes: rateWindow).tokens, continueService.currentHourTokens(), continueService.isActive(), continueService.todaySessions()) } - if enabledTools.contains("Cursor Agent") { + if enabled.contains("Cursor Agent") { let usage = cursorAgentService.todayUsage() - results["Cursor Agent"] = snapshot(usage, cursorAgentService.recentUsage(minutes: rateWindowMinutes).tokens, cursorAgentService.currentHourTokens(), cursorAgentService.isActive(), cursorAgentService.todaySessions()) + results["Cursor Agent"] = snapshot(usage, cursorAgentService.recentUsage(minutes: rateWindow).tokens, cursorAgentService.currentHourTokens(), cursorAgentService.isActive(), cursorAgentService.todaySessions()) } lock.lock() diff --git a/Sources/TokenClock/Linux/LinuxWeatherService.swift b/Sources/TokenClock/Linux/LinuxWeatherService.swift new file mode 100644 index 0000000..cee33df --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxWeatherService.swift @@ -0,0 +1,107 @@ +import Foundation +import FoundationNetworking + +/// Linux weather adapter for normal mode. `wttr.in` supplies both selected-city +/// and IP-based automatic weather without platform location frameworks. +final class LinuxWeatherService: @unchecked Sendable { + private let lock = NSLock() + private var storedWeather = WeatherInfo() + private var generation = 0 + + var weather: WeatherInfo { + lock.lock() + defer { lock.unlock() } + return storedWeather + } + + func fetch(city: String, completion: @escaping @Sendable () -> Void) { + lock.lock() + generation &+= 1 + let requestGeneration = generation + lock.unlock() + + let location: String + if city == "auto" { + location = "" + } else { + location = city.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? city + } + guard let url = URL(string: "\(AppConfig.API.weatherBase)/\(location)?format=j1&m") else { return } + var request = URLRequest(url: url) + request.timeoutInterval = AppConfig.HTTP.requestTimeout + request.setValue("TokenClock/1.0 (Linux)", forHTTPHeaderField: "User-Agent") + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard let self else { return } + guard error == nil, + let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let data, + let parsed = Self.parse(data: data, fallbackCity: city == "auto" ? "" : city) else { + if let error { print("[Weather] \(error.localizedDescription)") } + return + } + self.lock.lock() + guard self.generation == requestGeneration else { + self.lock.unlock() + return + } + self.storedWeather = parsed + self.lock.unlock() + completion() + }.resume() + } + + private static func parse(data: Data, fallbackCity: String) -> WeatherInfo? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let current = (json["current_condition"] as? [[String: Any]])?.first ?? [:] + let temperature = Int(current["temp_C"] as? String ?? "") ?? 0 + let code = Int(current["weatherCode"] as? String ?? "") ?? 0 + + var cityName = fallbackCity + if cityName.isEmpty, + let nearest = (json["nearest_area"] as? [[String: Any]])?.first, + let names = nearest["areaName"] as? [[String: Any]], + let resolved = names.first?["value"] as? String { + cityName = resolved + } + + var forecast: [HourlyForecast] = [] + if let days = json["weather"] as? [[String: Any]] { + for day in days { + guard let hours = day["hourly"] as? [[String: Any]] else { continue } + for hour in hours { + let hourCode = Int(hour["weatherCode"] as? String ?? "") ?? 0 + forecast.append(HourlyForecast( + time: hour["time"] as? String ?? "", + tempC: Int(hour["tempC"] as? String ?? "") ?? 0, + emoji: emoji(for: hourCode), + description: (hour["weatherDesc"] as? [[String: Any]])?.first?["value"] as? String ?? "" + )) + } + } + } + return WeatherInfo( + emoji: emoji(for: code), + temperature: temperature, + cityName: cityName, + forecast: forecast + ) + } + + private static func emoji(for code: Int) -> String { + switch code { + case 113: return "☀️" + case 116: return "⛅" + case 119, 122: return "☁️" + case 143, 248, 260: return "🌫️" + case 176, 263, 266, 293, 353: return "🌦️" + case 200, 386, 389, 392, 395: return "⛈️" + case 227, 230, 323, 326, 329, 332, 335, 338, 368, 371: return "❄️" + case 179, 182, 185, 269, 281, 284, 311, 314, 317, 320, 350, 362, 365, 374, 377: return "🌨️" + case 296, 299, 302, 305, 308, 356, 359: return "🌧️" + default: return "🌤️" + } + } +} diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index e53f1c3..08d1ec3 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -34,7 +34,8 @@ step "Installing build deps (apt)" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y --no-install-recommends \ - libgtk-3-dev libsqlite3-dev libcurl4-openssl-dev \ + libgtk-3-dev libsqlite3-dev libcurl4-openssl-dev adwaita-icon-theme \ + fonts-noto-color-emoji fonts-noto-cjk librsvg2-common shared-mime-info \ pkg-config file ca-certificates wget libfuse2 \ >/dev/null ok "deps installed" @@ -51,6 +52,7 @@ step "Assembling AppDir" APPDIR="$ROOT/AppDir" rm -rf "$APPDIR" mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/applications" "$APPDIR/usr/share/icons/hicolor/256x256/apps" +mkdir -p "$APPDIR/usr/share/fonts/truetype/noto" cp "$BIN" "$APPDIR/usr/bin/$APP" chmod +x "$APPDIR/usr/bin/$APP" @@ -60,6 +62,12 @@ ICON_SRC="$ROOT/Sources/TokenClock/Resources/glass_disc.png" cp "$ICON_SRC" "$APPDIR/usr/share/icons/hicolor/256x256/apps/$ICON_NAME.png" ok "icon installed ($ICON_SRC)" +# Keep the dial's CJK labels and activity emoji available on minimal desktop installs. +find /usr/share/fonts -type f \ + \( -name 'NotoColorEmoji.ttf' -o -name 'NotoSansCJK-Regular.ttc' -o -name 'NotoSerifCJK-Regular.ttc' \) \ + -exec cp {} "$APPDIR/usr/share/fonts/truetype/noto/" \; +ok "CJK/emoji fallback fonts bundled" + cat > "$APPDIR/usr/share/applications/$APP.desktop" < Date: Wed, 5 Aug 2026 06:58:25 -0700 Subject: [PATCH 2/6] fix(linux): bundle clock resources in release artifacts --- .dockerignore | 4 ++++ Dockerfile.linux | 1 + scripts/build-appimage.sh | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/.dockerignore b/.dockerignore index fbb2881..0774f55 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ .build .git .swiftpm +.appimage-tools +AppDir +*.AppImage +*.AppImage.sha256 diff --git a/Dockerfile.linux b/Dockerfile.linux index ad65e00..9622acb 100644 --- a/Dockerfile.linux +++ b/Dockerfile.linux @@ -45,4 +45,5 @@ RUN runtime_mirror="$(printf '%s' "${UBUNTU_PORTS_MIRROR}" | sed 's@^https:@http && rm -rf /var/lib/apt/lists/* COPY --from=builder /src/.build/release/TokenClock /usr/local/bin/tokenclock-gui +COPY --from=builder /src/.build/release/TokenClock_TokenClock.resources /usr/local/bin/TokenClock_TokenClock.resources ENTRYPOINT ["/usr/local/bin/tokenclock-gui"] diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index 08d1ec3..e7373e0 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -57,6 +57,11 @@ mkdir -p "$APPDIR/usr/share/fonts/truetype/noto" cp "$BIN" "$APPDIR/usr/bin/$APP" chmod +x "$APPDIR/usr/bin/$APP" +RESOURCE_BUNDLE="$ROOT/.build/release/TokenClock_TokenClock.resources" +[ -d "$RESOURCE_BUNDLE" ] || die "resource bundle not found: $RESOURCE_BUNDLE" +cp -R "$RESOURCE_BUNDLE" "$APPDIR/usr/bin/" +ok "SwiftPM resource bundle installed" + ICON_SRC="$ROOT/Sources/TokenClock/Resources/glass_disc.png" [ -f "$ICON_SRC" ] || die "icon source not found: $ICON_SRC" cp "$ICON_SRC" "$APPDIR/usr/share/icons/hicolor/256x256/apps/$ICON_NAME.png" From a9b1b2bd11b59562402a6c68dce6cefc82ff7ce7 Mon Sep 17 00:00:00 2001 From: nxc8335 Date: Sat, 8 Aug 2026 01:21:36 -0700 Subject: [PATCH 3/6] Complete Linux normal experience --- Package.swift | 14 +- README.md | 34 + README.zh-CN.md | 34 + Sources/CGtk/include/shim.h | 67 +- Sources/TokenClock/Config/AppConfig.swift | 8 + Sources/TokenClock/L10n.swift | 19 +- Sources/TokenClock/Linux/LinuxApp.swift | 33 +- .../TokenClock/Linux/LinuxClockRenderer.swift | 12 + .../TokenClock/Linux/LinuxCustomTheme.swift | 16 +- .../TokenClock/Linux/LinuxDetailsPanel.swift | 310 ++++++- .../TokenClock/Linux/LinuxPathConfig.swift | 164 ++++ .../TokenClock/Linux/LinuxPathDetector.swift | 414 +++++++++ .../Linux/LinuxProviderCatalog.swift | 427 +++++++++ .../Linux/LinuxSettingsWindow.swift | 162 +++- .../TokenClock/Linux/LinuxThemePicker.swift | 181 ++-- .../TokenClock/Linux/LinuxUsageModel.swift | 31 +- .../Services/AiderUsageService.swift | 54 +- .../Services/ClaudeCodeUsageService.swift | 287 +++--- .../Services/CodexQuotaService.swift | 432 ++++++++++ .../Services/CodexUsageService.swift | 329 +++---- .../Services/ContinueUsageService.swift | 77 +- .../Services/CopilotUsageService.swift | 125 ++- .../Services/CursorAgentUsageService.swift | 15 + .../Services/GeminiUsageService.swift | 196 ++--- .../Services/GrokUsageService.swift | 115 ++- .../TokenClock/Services/JSONLLineReader.swift | 127 +++ .../Services/OpenClawUsageService.swift | 221 ++--- .../Services/OpenCodeUsageService.swift | 4 + .../Services/QwenCodeUsageService.swift | 106 +-- .../Services/UsageServiceProtocol.swift | 123 ++- .../CodexQuotaServiceTests.swift | 148 ++++ .../UsageServicePerformanceTests.swift | 816 ++++++++++++++++++ scripts/audit-linux-catalog.sh | 20 + scripts/benchmark-linux-providers.sh | 38 + scripts/linux-catalog-audit.swift | 29 + scripts/linux-catalog-smoke.swift | 227 +++++ scripts/linux-provider-benchmark.swift | 80 ++ scripts/test-linux-catalog.sh | 27 + scripts/test-linux-performance.sh | 12 + 39 files changed, 4538 insertions(+), 996 deletions(-) create mode 100644 Sources/TokenClock/Linux/LinuxPathConfig.swift create mode 100644 Sources/TokenClock/Linux/LinuxPathDetector.swift create mode 100644 Sources/TokenClock/Linux/LinuxProviderCatalog.swift create mode 100644 Sources/TokenClock/Services/CodexQuotaService.swift create mode 100644 Sources/TokenClock/Services/JSONLLineReader.swift create mode 100644 Tests/TokenClockTests/CodexQuotaServiceTests.swift create mode 100644 Tests/TokenClockTests/UsageServicePerformanceTests.swift create mode 100755 scripts/audit-linux-catalog.sh create mode 100755 scripts/benchmark-linux-providers.sh create mode 100644 scripts/linux-catalog-audit.swift create mode 100644 scripts/linux-catalog-smoke.swift create mode 100644 scripts/linux-provider-benchmark.swift create mode 100755 scripts/test-linux-catalog.sh create mode 100755 scripts/test-linux-performance.sh diff --git a/Package.swift b/Package.swift index 269c27e..65bc889 100644 --- a/Package.swift +++ b/Package.swift @@ -11,6 +11,7 @@ let linuxSources = [ "Services/AntigravityUsageService.swift", "Services/AppPaths.swift", "Services/ClaudeCodeUsageService.swift", + "Services/CodexQuotaService.swift", "Services/ClineUsageService.swift", "Services/CodexUsageService.swift", "Services/ContinueUsageService.swift", @@ -19,14 +20,13 @@ let linuxSources = [ "Services/GeminiUsageService.swift", "Services/GrokUsageService.swift", "Services/HermesUsageService.swift", + "Services/JSONLLineReader.swift", "Services/HistoryStore.swift", "Services/MockUsageService.swift", "Services/ModelEmoji.swift", "Services/ModelNormalizer.swift", "Services/OpenClawUsageService.swift", "Services/OpenCodeUsageService.swift", - "Services/PathConfig.swift", - "Services/PathDetector.swift", "Services/QwenCodeUsageService.swift", "Services/UsageAggregator.swift", "Services/UsageServiceProtocol.swift", @@ -41,6 +41,9 @@ let linuxSources = [ "Linux/LinuxThemePicker.swift", "Linux/LinuxWeatherService.swift", "Linux/LinuxMain.swift", + "Linux/LinuxPathConfig.swift", + "Linux/LinuxPathDetector.swift", + "Linux/LinuxProviderCatalog.swift", "Linux/LinuxUsageModel.swift", ] @@ -68,6 +71,8 @@ let package = Package( "Models/ClockSize.swift", "Models/CustomThemeConfig.swift", "Services/LaunchAgentHelper.swift", + "Services/PathConfig.swift", + "Services/PathDetector.swift", "Services/UsageAPIServer.swift", "Services/WeatherService.swift", "ViewModel.swift", @@ -78,6 +83,11 @@ let package = Package( resources: [.copy("Resources/glass_disc.png")], swiftSettings: [.unsafeFlags(["-parse-as-library"])] ), + .testTarget( + name: "TokenClockTests", + dependencies: ["TokenClock"], + path: "Tests/TokenClockTests" + ), ] ) #else diff --git a/README.md b/README.md index d6bd838..6ea2385 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,40 @@ curl -fsSL https://raw.githubusercontent.com/Neo-Isshin/TokenClock/main/cli/inst Linux ships the **normal dial experience** in GTK3/Cairo: a transparent circular widget with the same on-dial information layout, all 8 built-in normal faces (Glass, Classic, Glacier, Midnight, Luxe, Antique, Railgun, and Sky), their hand shapes, numerals, ticks, and decorations. Left-click opens the themed session/model detail panel with percentages and weather forecast; right-click opens the visual face picker plus size, opacity, always-on-top, temperature, city, timezone, language, settings, XDG autostart, API copy, and About controls. The GTK settings window covers auto-detection, all 14 tool switches and data paths, live API configuration, usage thresholds, and saved custom faces. Automatic Linux weather uses wttr.in's IP-based location fallback; manually selected cities behave like macOS normal. +The Linux detail panel also includes **Codex Quota** immediately to the left of **By Percent**, showing available weekly/short-window allowance, reset time, plan, balance, and reset credits when present. Quota is fetched only when that view is opened (with a short cache and bounded fallback); it does not keep an `app-server` process or quota polling loop resident. Normal-alignment work also provides a compact 3×3 base-face picker with saved custom faces below it, persistent custom save/apply/delete/reset behavior, a 520×548 overview-and-disclosure settings window, and a Linux-specific About dialog. These are GTK/Cairo adaptations targeting the same normal workflow and visual character, not a claim of pixel-for-pixel AppKit rendering equivalence. + +#### Linux provider catalog + +Linux uses its own path catalog; it never imports macOS `~/Library/Application Support` or Windows `%APPDATA%`/`%LOCALAPPDATA%` locations. Resolution order is a saved custom path, the environment candidates shown below, the Linux default, then Linux-only alternates. XDG variables are used only where the provider or host application uses the XDG base-directory layout; an unset (or invalid relative) XDG directory falls back to the path after `:-`. + +| Tool | Linux default / parser input | Environment candidates (in priority order) | +|------|------------------------------|--------------------------------------------| +| **OpenClaw** | `~/.openclaw/agents/*/sessions/*.jsonl` | `OPENCLAW_STATE_DIR`; `${OPENCLAW_HOME}/.openclaw` | +| **Claude Code** | `~/.claude/projects/**/*.jsonl` | `CLAUDE_CONFIG_DIR` | +| **Gemini CLI** | `~/.gemini/tmp/*/chats/session-*.(jsonl\|json)` | `${GEMINI_CLI_HOME}/.gemini`; `GEMINI_HOME`† | +| **Codex** | `~/.codex/sessions/**/rollout-*.jsonl` | `CODEX_HOME` | +| **Hermes** | `~/.hermes/state.db` (`sessions` table) | `HERMES_HOME` | +| **OpenCode** | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `OPENCODE_DB` (file); `OPENCODE_HOME`†; `XDG_DATA_HOME` | +| **Qwen Code** | `~/.qwen/projects/*/chats/*.jsonl` | `QWEN_RUNTIME_DIR`; `QWEN_HOME` | +| **GitHub Copilot CLI** | `~/.copilot/session-state/*/events.jsonl` and optional OTel JSONL | `COPILOT_HOME`; `COPILOT_OTEL_FILE_EXPORTER_PATH` (file) | +| **Grok CLI** | `~/.grok/sessions/*/*/updates.jsonl` | `GROK_HOME`† | +| **Aider** | `${XDG_STATE_HOME:-~/.local/state}/aider/analytics.jsonl` (TokenClock convention) | `AIDER_ANALYTICS_LOG` (file); `AIDER_HOME`†; `XDG_STATE_HOME` | +| **Antigravity** | `~/.gemini/{antigravity-cli,antigravity-ide,antigravity}/conversations/*.db` | `ANTIGRAVITY_HOME`† | +| **Cline** | `${XDG_CONFIG_HOME:-~/.config}/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/*/api_conversation.json` | `CLINE_HOME`†; `XDG_CONFIG_HOME` | +| **Continue** | `~/.continue/{dev_data,sessions}/*.jsonl` | `CONTINUE_HOME`† | +| **Cursor Agent** | `${XDG_CONFIG_HOME:-~/.config}/Cursor/User/globalStorage/state.vscdb`, then the authenticated Cursor usage API | `CURSOR_AGENT_HOME`†; `XDG_CONFIG_HOME` | + +† TokenClock compatibility override, not a provider-documented environment contract. Custom and environment paths expand `~`, `$VAR`, and `${VAR}`. Cline also probes VSCodium, Code OSS, Cursor, VS Code Remote, and Cursor Remote global storage under their Linux user-data roots. + +Detection reports three separate states internally: catalog entry declared, candidate path exists, and parser-readable source found. A path is counted as detected only after TokenClock can read a valid JSON/JSONL source or open the required SQLite table and columns. + +Known limitations: + +- Current OpenClaw releases can migrate transcripts to per-agent SQLite; TokenClock's OpenClaw parser still requires legacy JSONL transcripts. +- Aider does not create an analytics log by default. Start it with `--analytics-log ` or set `AIDER_ANALYTICS_LOG`; the XDG state path above is only TokenClock's Linux convention. +- Copilot session events may contain limited token detail. Full detail requires Copilot OTel file export; `COPILOT_OTEL_FILE_EXPORTER_PATH` is consumed directly. +- Cursor usage is not read from local token logs: TokenClock reads the local Cursor credential database and, when cloud fetching is enabled, calls Cursor's authenticated usage API. + **x86_64 — prebuilt AppImage (default):** the universal one-liner downloads a self-contained AppImage (GTK3 bundled, needs only glibc ≥ 2.35) — no Swift, no compilation, no dev headers: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 4b960d1..8dc4e17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -207,6 +207,40 @@ curl -fsSL https://raw.githubusercontent.com/Neo-Isshin/TokenClock/main/cli/inst Linux 使用 GTK3/Cairo 还原 **normal 表盘体验**:主窗口是透明的圆形小组件,盘面信息布局与 macOS normal 一致,并完整提供玻璃、经典、冰川、深夜、暗金、古风、超电磁炮、天空 8 套内置表盘及其指针形状、数字、刻度和装饰。左键展开与主题配套的详情面板,可按会话/模型分组、显示占比、展开来源并查看天气趋势;右键提供图形化表盘选择器、尺寸、透明度、置顶、温标、城市、时区、语言、设置、XDG 开机自启、复制 API 和关于。GTK 设置窗口包含自动探测、14 种工具开关与数据路径、API 即时配置、速率阈值和可保存的自定义表盘。Linux 自动天气使用 wttr.in 的 IP 定位回退,手选城市行为与 macOS normal 一致。 +Linux 详情面板还在 **By Percent 左侧**加入 **Codex Quota**:在有数据时展示周额度/短周期额度的剩余量、重置时间、套餐、余额和 reset credits。额度只在用户打开该视图时按需获取,并使用短缓存和有界回退;不会常驻 `app-server` 子进程,也没有额度轮询循环。normal 对齐还包括紧凑的 3×3 基础表盘选择器(已保存自定义表盘排列在其后)、自定义表盘保存/应用/删除/重置与持久化、520×548 的概览式折叠设置窗口,以及 Linux 专属 About 信息。这些是对 normal 工作流与视觉气质的 GTK/Cairo 适配,不代表与 AppKit 像素级完全相同。 + +#### Linux provider catalog + +Linux 使用独立的路径 catalog,不会导入 macOS 的 `~/Library/Application Support`,也不会导入 Windows 的 `%APPDATA%`/`%LOCALAPPDATA%`。解析顺序为:已保存的自定义路径、下表所列环境变量候选、Linux 默认路径、Linux 专属备选路径。只有 provider 或宿主应用遵循 XDG Base Directory 时才使用 XDG;XDG 变量未设置(或错误地设置成相对路径)时使用 `:-` 后的回退路径。 + +| 工具 | Linux 默认路径 / 解析器输入 | 环境变量候选(按优先级) | +|------|-----------------------------|--------------------------| +| **OpenClaw** | `~/.openclaw/agents/*/sessions/*.jsonl` | `OPENCLAW_STATE_DIR`;`${OPENCLAW_HOME}/.openclaw` | +| **Claude Code** | `~/.claude/projects/**/*.jsonl` | `CLAUDE_CONFIG_DIR` | +| **Gemini CLI** | `~/.gemini/tmp/*/chats/session-*.(jsonl\|json)` | `${GEMINI_CLI_HOME}/.gemini`;`GEMINI_HOME`† | +| **Codex** | `~/.codex/sessions/**/rollout-*.jsonl` | `CODEX_HOME` | +| **Hermes** | `~/.hermes/state.db`(`sessions` 表) | `HERMES_HOME` | +| **OpenCode** | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `OPENCODE_DB`(文件);`OPENCODE_HOME`†;`XDG_DATA_HOME` | +| **Qwen Code** | `~/.qwen/projects/*/chats/*.jsonl` | `QWEN_RUNTIME_DIR`;`QWEN_HOME` | +| **GitHub Copilot CLI** | `~/.copilot/session-state/*/events.jsonl` 与可选的 OTel JSONL | `COPILOT_HOME`;`COPILOT_OTEL_FILE_EXPORTER_PATH`(文件) | +| **Grok CLI** | `~/.grok/sessions/*/*/updates.jsonl` | `GROK_HOME`† | +| **Aider** | `${XDG_STATE_HOME:-~/.local/state}/aider/analytics.jsonl`(TokenClock 约定) | `AIDER_ANALYTICS_LOG`(文件);`AIDER_HOME`†;`XDG_STATE_HOME` | +| **Antigravity** | `~/.gemini/{antigravity-cli,antigravity-ide,antigravity}/conversations/*.db` | `ANTIGRAVITY_HOME`† | +| **Cline** | `${XDG_CONFIG_HOME:-~/.config}/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/*/api_conversation.json` | `CLINE_HOME`†;`XDG_CONFIG_HOME` | +| **Continue** | `~/.continue/{dev_data,sessions}/*.jsonl` | `CONTINUE_HOME`† | +| **Cursor Agent** | `${XDG_CONFIG_HOME:-~/.config}/Cursor/User/globalStorage/state.vscdb`,随后请求已认证的 Cursor usage API | `CURSOR_AGENT_HOME`†;`XDG_CONFIG_HOME` | + +† TokenClock 兼容覆盖变量,不是 provider 官方公布的环境变量契约。自定义路径和环境变量路径支持展开 `~`、`$VAR`、`${VAR}`。Cline 还会探测 Linux 用户数据目录下的 VSCodium、Code OSS、Cursor、VS Code Remote 和 Cursor Remote global storage。 + +自动探测在内部区分三个状态:catalog 已声明、候选路径存在、解析器可读取。只有 TokenClock 能读取有效 JSON/JSONL,或能打开所需 SQLite 表及字段时,才计为探测成功。 + +已知限制: + +- 当前 OpenClaw 可以把 transcript 迁移到每个 agent 的 SQLite 数据库;TokenClock 的 OpenClaw 解析器目前仍要求旧版 JSONL transcript。 +- Aider 默认不会创建 analytics 日志,必须通过 `--analytics-log ` 或 `AIDER_ANALYTICS_LOG` 开启;上面的 XDG state 路径只是 TokenClock 的 Linux 约定。 +- Copilot 的 session event 可能只包含有限的 token 明细;完整明细需要 Copilot OTel 文件,`COPILOT_OTEL_FILE_EXPORTER_PATH` 会被直接读取。 +- Cursor 用量并非来自本地 token 日志:TokenClock 读取本地 Cursor 凭据数据库,并在启用云端获取时调用 Cursor 的已认证 usage API。 + **x86_64 —— 预编译 AppImage(默认):** 通用一键命令会下载一个自带 GTK3 的 AppImage(仅要求 glibc ≥ 2.35),无需 Swift、无需编译、无需开发头文件: ```bash diff --git a/Sources/CGtk/include/shim.h b/Sources/CGtk/include/shim.h index 0b6d0dd..400f101 100644 --- a/Sources/CGtk/include/shim.h +++ b/Sources/CGtk/include/shim.h @@ -34,6 +34,14 @@ static inline GtkScrolledWindow *tc_gtk_scrolled_window(GtkWidget *widget) { return GTK_SCROLLED_WINDOW(widget); } +static inline GtkProgressBar *tc_gtk_progress_bar(GtkWidget *widget) { + return GTK_PROGRESS_BAR(widget); +} + +static inline GtkRevealer *tc_gtk_revealer(GtkWidget *widget) { + return GTK_REVEALER(widget); +} + static inline GtkEntry *tc_gtk_entry(GtkWidget *widget) { return GTK_ENTRY(widget); } @@ -169,6 +177,21 @@ static inline void tc_gtk_prepare_transparent_window(GtkWidget *widget) { gtk_widget_set_app_paintable(widget, TRUE); } +static inline void tc_gtk_set_fixed_window_size(GtkWidget *widget, gint size) { + GdkGeometry geometry = {0}; + geometry.min_width = size; + geometry.min_height = size; + geometry.max_width = size; + geometry.max_height = size; + gtk_window_set_geometry_hints( + GTK_WINDOW(widget), widget, &geometry, + GDK_HINT_MIN_SIZE | GDK_HINT_MAX_SIZE + ); + gtk_widget_set_size_request(widget, size, size); + gtk_window_resize(GTK_WINDOW(widget), size, size); + gtk_widget_queue_resize(widget); +} + static inline gint tc_gtk_primary_workarea_height(void) { GdkDisplay *display = gdk_display_get_default(); if (display == NULL) return 900; @@ -217,6 +240,7 @@ static inline void tc_gtk_position_adjacent_panel( gint x = parent_x + (parent_width - panel_width) / 2; gint y = parent_y + parent_height + margin; + gint actual_height = panel_height; GdkWindow *gdk_parent = gtk_widget_get_window(parent_widget); GdkDisplay *display = gdk_display_get_default(); GdkMonitor *monitor = (display != NULL && gdk_parent != NULL) @@ -224,12 +248,27 @@ static inline void tc_gtk_position_adjacent_panel( if (monitor != NULL) { GdkRectangle workarea; gdk_monitor_get_workarea(monitor, &workarea); - if (y + panel_height > workarea.y + workarea.height) { + const gint workarea_bottom = workarea.y + workarea.height; + const gint below_y = parent_y + parent_height + margin; + const gint below_space = MAX(0, workarea_bottom - below_y); + const gint above_space = MAX(0, parent_y - margin - workarea.y); + + if (below_space >= panel_height) { + y = below_y; + } else if (above_space >= panel_height) { y = parent_y - panel_height - margin; + } else if (below_space >= above_space) { + actual_height = MAX(1, below_space); + y = below_y; + } else { + actual_height = MAX(1, above_space); + y = parent_y - actual_height - margin; } x = MAX(workarea.x, MIN(x, workarea.x + workarea.width - panel_width)); - y = MAX(workarea.y, MIN(y, workarea.y + workarea.height - panel_height)); + y = MAX(workarea.y, MIN(y, workarea_bottom - actual_height)); } + gtk_widget_set_size_request(panel_widget, panel_width, actual_height); + gtk_window_resize(panel, panel_width, actual_height); gtk_window_move(panel, x, y); } @@ -249,18 +288,36 @@ static inline void tc_gtk_clipboard_set_text(const char *text) { } } -static inline void tc_gtk_show_about(GtkWidget *parent_widget, const char *version) { +static inline void tc_gtk_show_about( + GtkWidget *parent_widget, + const char *version, + const char *logo_path +) { GtkWidget *dialog = gtk_about_dialog_new(); gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), "TokenClock"); gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), version); + gtk_about_dialog_set_copyright( + GTK_ABOUT_DIALOG(dialog), "Copyright © 2026 Neo-Isshin" + ); + gtk_about_dialog_set_license_type(GTK_ABOUT_DIALOG(dialog), GTK_LICENSE_GPL_3_0); gtk_about_dialog_set_comments( GTK_ABOUT_DIALOG(dialog), - "A beautiful desktop clock for local AI token usage." + "normal · Linux\nA beautiful desktop clock for local AI token usage." ); gtk_about_dialog_set_website( GTK_ABOUT_DIALOG(dialog), - "https://github.com/Neo-Isshin/TokenClock" + "https://github.com/Neo-Isshin/TokenClock/issues" ); + gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "GitHub Issues"); + if (logo_path != NULL && logo_path[0] != '\0') { + GError *error = NULL; + GdkPixbuf *logo = gdk_pixbuf_new_from_file_at_scale(logo_path, 96, 96, TRUE, &error); + if (logo != NULL) { + gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), logo); + g_object_unref(logo); + } + if (error != NULL) g_error_free(error); + } gtk_window_set_transient_for(GTK_WINDOW(dialog), GTK_WINDOW(parent_widget)); gtk_window_set_keep_above(GTK_WINDOW(dialog), TRUE); gtk_dialog_run(GTK_DIALOG(dialog)); diff --git a/Sources/TokenClock/Config/AppConfig.swift b/Sources/TokenClock/Config/AppConfig.swift index d94f7e4..0f8c6ac 100644 --- a/Sources/TokenClock/Config/AppConfig.swift +++ b/Sources/TokenClock/Config/AppConfig.swift @@ -46,10 +46,18 @@ enum AppConfig { static let cursorPageSize = 200 /// JSONL 流式读取缓冲(64KB,平衡内存和 IO 效率) static let jsonlBufferSize = 65_536 + /// 仅扫描今天与昨天的 Codex rollout,覆盖跨日长会话同时限制冷启动成本。 + static let codexSessionLookbackDays = 2 + /// Gemini 面板同样只展示当日/最近用量;保留今天+昨天以覆盖跨日会话。 + static let geminiSessionLookbackDays = 2 /// "活跃工具" 判定窗口(10 分钟内有调用算活跃) static let activeThresholdSeconds: TimeInterval = 600 /// "最近 token" 默认窗口(分钟) static let defaultRecentWindowMinutes = 10 + static let codexQuotaCacheSeconds: TimeInterval = 60 + static let codexQuotaTimeoutSeconds: TimeInterval = 8 + static let codexQuotaFallbackTailBytes = 2_097_152 + static let codexQuotaFallbackFileLimit = 32 static let oneDaySeconds: TimeInterval = 86_400 } diff --git a/Sources/TokenClock/L10n.swift b/Sources/TokenClock/L10n.swift index f99aaa1..439458c 100644 --- a/Sources/TokenClock/L10n.swift +++ b/Sources/TokenClock/L10n.swift @@ -115,7 +115,7 @@ final class L10n: @unchecked Sendable { "menu.about": [.zhHans: "关于 TokenClock", .zhHant: "關於 TokenClock", .en: "About TokenClock"], // MARK: About - "about.license": [.zhHans: "许可证:MIT", .zhHant: "許可證:MIT", .en: "License: MIT"], + "about.license": [.zhHans: "许可证:GPLv3", .zhHant: "許可證:GPLv3", .en: "License: GPLv3"], "about.contact": [.zhHans: "联系方式", .zhHant: "聯絡方式", .en: "Contact"], "about.close": [.zhHans: "关闭", .zhHant: "關閉", .en: "Close"], @@ -156,6 +156,23 @@ final class L10n: @unchecked Sendable { "detail.model": [.zhHans: "模型", .zhHant: "模型", .en: "Model"], "detail.percent": [.zhHans: "按百分比", .zhHant: "按百分比", .en: "By Percent"], "detail.share": [.zhHans: "占比", .zhHant: "佔比", .en: "Share"], + "detail.codexQuota": [.zhHans: "Codex 额度", .zhHant: "Codex 額度", .en: "Codex Quota"], + "quota.loading": [.zhHans: "正在读取 Codex 额度…", .zhHant: "正在讀取 Codex 額度…", .en: "Loading Codex quota…"], + "quota.unavailable": [.zhHans: "暂时无法读取额度", .zhHant: "暫時無法讀取額度", .en: "Quota is temporarily unavailable"], + "quota.retry": [.zhHans: "重试", .zhHant: "重試", .en: "Retry"], + "quota.remaining": [.zhHans: "%.0f%% 剩余", .zhHant: "%.0f%% 剩餘", .en: "%.0f%% remaining"], + "quota.weekly": [.zhHans: "每周额度", .zhHant: "每週額度", .en: "Weekly quota"], + "quota.days": [.zhHans: "%d 天额度", .zhHant: "%d 天額度", .en: "%d-day quota"], + "quota.hours": [.zhHans: "%d 小时额度", .zhHant: "%d 小時額度", .en: "%d-hour quota"], + "quota.minutes": [.zhHans: "%d 分钟额度", .zhHant: "%d 分鐘額度", .en: "%d-minute quota"], + "quota.resets": [.zhHans: "%@重置 · %@", .zhHant: "%@重置 · %@", .en: "Resets %@ · %@"], + "quota.plan": [.zhHans: "方案 %@", .zhHant: "方案 %@", .en: "%@ plan"], + "quota.resetCredits": [.zhHans: "%d 次完整重置", .zhHant: "%d 次完整重置", .en: "%d full reset(s)"], + "quota.creditBalance": [.zhHans: "余额 %@", .zhHant: "餘額 %@", .en: "%@ credits"], + "quota.unlimited": [.zhHans: "无限额", .zhHant: "無限額", .en: "Unlimited"], + "quota.liveSource": [.zhHans: "实时", .zhHant: "即時", .en: "Live"], + "quota.logSource": [.zhHans: "本地记录", .zhHant: "本機記錄", .en: "Local log"], + "quota.updated": [.zhHans: "更新于 %@", .zhHant: "更新於 %@", .en: "Updated %@"], // MARK: Theme picker "themePicker.title": [.zhHans: "选择表盘", .zhHant: "選擇錶盤", .en: "Select Clock Face"], diff --git a/Sources/TokenClock/Linux/LinuxApp.swift b/Sources/TokenClock/Linux/LinuxApp.swift index 84c5af8..e37dcac 100644 --- a/Sources/TokenClock/Linux/LinuxApp.swift +++ b/Sources/TokenClock/Linux/LinuxApp.swift @@ -150,6 +150,7 @@ final class LinuxApp: @unchecked Sendable { ) ) gtk_container_add(tc_gtk_container(createdWindow), createdDial) + tc_gtk_set_fixed_window_size(createdWindow, diameter) _ = tc_gtk_on_destroy(createdWindow, linuxDestroy, opaque) _ = tc_gtk_on_button_press(createdDial, linuxButtonPress, opaque) @@ -475,7 +476,13 @@ final class LinuxApp: @unchecked Sendable { case "settings": showSettings() case "launch-at-login": toggleAutostart() case "about": - if let window { tc_gtk_show_about(window, "normal · Linux") } + if let window { + if let path = Bundle.module.path(forResource: "glass_disc", ofType: "png") { + path.withCString { tc_gtk_show_about(window, "v1.3.8", $0) } + } else { + tc_gtk_show_about(window, "v1.3.8", nil) + } + } case "quit": shutdown() gtk_main_quit() @@ -498,7 +505,11 @@ final class LinuxApp: @unchecked Sendable { } func chooseThemeFromPicker(_ theme: LinuxClockTheme) { - selectTheme(theme) + if theme == .custom { + applyCustomTheme(id: nil) + } else { + selectTheme(theme) + } } func applyCustomTheme(id: UUID?) { @@ -512,14 +523,22 @@ final class LinuxApp: @unchecked Sendable { } func customThemeListChanged() { - if selectedTheme == .custom, - let active = UserDefaults.standard.string(for: .activeCustomThemeId), - !LinuxCustomThemeStore.shared.themes.contains(where: { $0.id.uuidString == active }) { - selectTheme(.classic) + if selectedTheme == .custom { + let active = UserDefaults.standard.string(for: .activeCustomThemeId) + if active == nil || !LinuxCustomThemeStore.shared.themes.contains(where: { $0.id.uuidString == active }) { + UserDefaults.standard.remove(.activeCustomThemeId) + selectTheme(.classic) + } } rebuildContextMenu() } + func resetCustomThemeToClassic() { + UserDefaults.standard.remove(.activeCustomThemeId) + selectTheme(.classic) + rebuildContextMenu() + } + private func selectSize(_ size: LinuxClockSize) { selectedSize = size UserDefaults.standard.setString(size.rawValue, for: .clockSize) @@ -533,7 +552,7 @@ final class LinuxApp: @unchecked Sendable { guard let window, let dial else { return } let diameter = gint(size.diameter) gtk_widget_set_size_request(dial, diameter, diameter) - gtk_window_resize(tc_gtk_window(window), diameter, diameter) + tc_gtk_set_fixed_window_size(window, diameter) detailsPanel?.hide() updateDetailsPanel() _ = tc_gtk_idle_add(linuxShapeFinished, opaque) diff --git a/Sources/TokenClock/Linux/LinuxClockRenderer.swift b/Sources/TokenClock/Linux/LinuxClockRenderer.swift index e71999e..8bb4ccd 100644 --- a/Sources/TokenClock/Linux/LinuxClockRenderer.swift +++ b/Sources/TokenClock/Linux/LinuxClockRenderer.swift @@ -78,6 +78,18 @@ final class LinuxClockRenderer: @unchecked Sendable { drawCenterDot(context, centerX, centerY, theme) } + func drawPreview( + _ context: OpaquePointer, + width: Double, + height: Double, + customConfig: LinuxCustomThemeConfig, + date: Date = Date() + ) { + LinuxCustomThemeStore.shared.withPreviewConfig(customConfig) { + drawPreview(context, width: width, height: height, theme: .custom, date: date) + } + } + private func drawDial( _ context: OpaquePointer, _ centerX: Double, diff --git a/Sources/TokenClock/Linux/LinuxCustomTheme.swift b/Sources/TokenClock/Linux/LinuxCustomTheme.swift index 96e2116..50a49b9 100644 --- a/Sources/TokenClock/Linux/LinuxCustomTheme.swift +++ b/Sources/TokenClock/Linux/LinuxCustomTheme.swift @@ -100,7 +100,7 @@ struct LinuxSavedCustomTheme: Codable, Equatable, Sendable { final class LinuxCustomThemeStore: @unchecked Sendable { static let shared = LinuxCustomThemeStore() - private let lock = NSLock() + private let lock = NSRecursiveLock() private var storedConfig: LinuxCustomThemeConfig private var storedThemes: [LinuxSavedCustomTheme] @@ -124,6 +124,20 @@ final class LinuxCustomThemeStore: @unchecked Sendable { return storedThemes } + /// Renders a saved face without changing the active draft or persisted defaults. + /// GTK preview drawing is synchronous; the recursive lock lets `.custom` + /// properties read the temporary config while excluding other readers. + func withPreviewConfig(_ config: LinuxCustomThemeConfig, _ body: () -> T) -> T { + lock.lock() + let previous = storedConfig + storedConfig = config + defer { + storedConfig = previous + lock.unlock() + } + return body() + } + @discardableResult func save(config: LinuxCustomThemeConfig, name: String) -> LinuxSavedCustomTheme { lock.lock() diff --git a/Sources/TokenClock/Linux/LinuxDetailsPanel.swift b/Sources/TokenClock/Linux/LinuxDetailsPanel.swift index 12bfca0..19d299a 100644 --- a/Sources/TokenClock/Linux/LinuxDetailsPanel.swift +++ b/Sources/TokenClock/Linux/LinuxDetailsPanel.swift @@ -8,6 +8,9 @@ private enum LinuxGroupingMode: Int { /// Theme-aware Linux counterpart of macOS `DetailDropdownView`. final class LinuxDetailsPanel: @unchecked Sendable { + private static let panelWidth = 320 + private static let panelHeight = 547 + private(set) var window: UnsafeMutablePointer? private var card: UnsafeMutablePointer? private var parent: UnsafeMutablePointer? @@ -26,6 +29,13 @@ final class LinuxDetailsPanel: @unchecked Sendable { private var expandedTools: Set = [] private var expandedModels: Set = [] private var appliedTheme: LinuxClockTheme? + private var showsCodexQuota = false + private var codexQuota = CodexQuotaSnapshot.idle + private let quotaService = CodexQuotaService() + private let quotaLock = NSLock() + private var pendingQuota: CodexQuotaSnapshot? + private var quotaFetchInFlight = false + private var rebuildScheduled = false private lazy var opaque = Unmanaged.passUnretained(self).toOpaque() @@ -51,9 +61,12 @@ final class LinuxDetailsPanel: @unchecked Sendable { self.useFahrenheit = useFahrenheit self.theme = theme self.size = size - rebuild() - applyThemeIfNeeded(force: theme == .custom) - if isVisible { resizeAndPosition() } + if isVisible { + scheduleRebuild() + } else { + rebuild() + applyThemeIfNeeded(force: theme == .custom) + } } func toggle() { @@ -99,6 +112,14 @@ final class LinuxDetailsPanel: @unchecked Sendable { case "details:percentage": showPercentage.toggle() UserDefaults.standard.setBool(showPercentage, for: .dropdownShowPercentage) + case "details:quota": + showsCodexQuota.toggle() + if showsCodexQuota, codexQuota.status == .idle || codexQuota.isStale { + refreshQuota() + } + case "details:quota-refresh", "details:quota-retry": + showsCodexQuota = true + refreshQuota(force: true) default: if name.hasPrefix("details:tool:") { let value = String(name.dropFirst("details:tool:".count)) @@ -109,10 +130,7 @@ final class LinuxDetailsPanel: @unchecked Sendable { } } UserDefaults.standard.synchronize() - rebuild() - applyThemeIfNeeded(force: true) - resizeAndPosition() - if let window { gtk_widget_show_all(window) } + scheduleRebuild() } private func buildWindow(parent: UnsafeMutablePointer) { @@ -122,7 +140,7 @@ final class LinuxDetailsPanel: @unchecked Sendable { card = createdCard gtk_window_set_title(tc_gtk_window(createdWindow), "TokenClock Details") gtk_window_set_decorated(tc_gtk_window(createdWindow), 0) - gtk_window_set_resizable(tc_gtk_window(createdWindow), 1) + gtk_window_set_resizable(tc_gtk_window(createdWindow), 0) gtk_window_set_skip_taskbar_hint(tc_gtk_window(createdWindow), 1) gtk_window_set_skip_pager_hint(tc_gtk_window(createdWindow), 1) gtk_window_set_transient_for(tc_gtk_window(createdWindow), tc_gtk_window(parent)) @@ -147,26 +165,38 @@ final class LinuxDetailsPanel: @unchecked Sendable { gtk_box_pack_start(tc_gtk_box(card), separator(), 0, 0, 0) } - let controls = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + let controls = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6) gtk_container_set_border_width(tc_gtk_container(controls), 10) gtk_box_pack_start(tc_gtk_box(card), controls, 0, 0, 0) + let groupingRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + gtk_box_pack_start(tc_gtk_box(controls), groupingRow, 0, 0, 0) appendControl( grouping == .session ? "✓ \(tr("detail.groupBySession"))" : tr("detail.groupBySession"), - name: "details:session", to: controls + name: "details:session", expands: true, to: groupingRow ) appendControl( grouping == .model ? "✓ \(tr("detail.groupByModel"))" : tr("detail.groupByModel"), - name: "details:model", to: controls + name: "details:model", expands: true, to: groupingRow ) + let displayRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + gtk_box_pack_start(tc_gtk_box(controls), displayRow, 0, 0, 0) let spacer = gtk_label_new("") gtk_widget_set_hexpand(spacer, 1) - gtk_box_pack_start(tc_gtk_box(controls), spacer, 1, 1, 0) + gtk_box_pack_start(tc_gtk_box(displayRow), spacer, 1, 1, 0) + appendControl( + showsCodexQuota ? "✓ \(tr("detail.codexQuota"))" : "◔ \(tr("detail.codexQuota"))", + name: "details:quota", to: displayRow + ) appendControl( showPercentage ? "✓ \(tr("detail.percent"))" : "% \(tr("detail.percent"))", - name: "details:percentage", to: controls + name: "details:percentage", to: displayRow ) gtk_box_pack_start(tc_gtk_box(card), separator(), 0, 0, 0) + if showsCodexQuota { + buildQuotaContent(in: card) + return + } gtk_box_pack_start(tc_gtk_box(card), headerRow(), 0, 0, 0) gtk_box_pack_start(tc_gtk_box(card), separator(), 0, 0, 0) @@ -237,6 +267,7 @@ final class LinuxDetailsPanel: @unchecked Sendable { private func appendControl( _ title: String, name: String, + expands: Bool = false, to box: UnsafeMutablePointer? ) { guard let button = gtk_button_new_with_label(title) else { return } @@ -244,7 +275,8 @@ final class LinuxDetailsPanel: @unchecked Sendable { tc_gtk_add_class(button, "tokenclock-detail-chip") gtk_button_set_relief(tc_gtk_button(button), GTK_RELIEF_NONE) _ = tc_gtk_on_clicked(button, linuxDetailsAction, opaque) - gtk_box_pack_start(tc_gtk_box(box), button, 0, 0, 0) + if expands { gtk_widget_set_hexpand(button, 1) } + gtk_box_pack_start(tc_gtk_box(box), button, expands ? 1 : 0, expands ? 1 : 0, 0) } private func headerRow() -> UnsafeMutablePointer? { @@ -394,6 +426,208 @@ final class LinuxDetailsPanel: @unchecked Sendable { gtk_box_pack_start(tc_gtk_box(list), label, 1, 1, 24) } + private func buildQuotaContent(in card: UnsafeMutablePointer) { + guard let scroll = gtk_scrolled_window_new(nil, nil), + let content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8) else { return } + gtk_scrolled_window_set_policy( + tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC + ) + gtk_container_set_border_width(tc_gtk_container(content), 12) + gtk_container_add(tc_gtk_container(scroll), content) + gtk_box_pack_start(tc_gtk_box(card), scroll, 1, 1, 0) + + if codexQuota.status == .loading, codexQuota.buckets.isEmpty { + let label = gtk_label_new("◌ \(tr("quota.loading"))") + tc_gtk_add_class(label, "tokenclock-detail-subtext") + gtk_widget_set_vexpand(label, 1) + gtk_box_pack_start(tc_gtk_box(content), label, 1, 1, 80) + return + } + + if codexQuota.status == .unavailable || codexQuota.status == .idle { + let label = gtk_label_new("◔\n\n\(tr("quota.unavailable"))") + gtk_label_set_justify(tc_gtk_label(label), GTK_JUSTIFY_CENTER) + tc_gtk_add_class(label, "tokenclock-detail-text") + gtk_widget_set_vexpand(label, 1) + gtk_box_pack_start(tc_gtk_box(content), label, 1, 1, 52) + appendControl(tr("quota.retry"), name: "details:quota-retry", to: content) + return + } + + let heading = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + let title = gtk_label_new(codexQuota.status == .loading ? "◌ CODEX" : "CODEX") + gtk_label_set_xalign(tc_gtk_label(title), 0) + tc_gtk_add_class(title, "tokenclock-quota-heading") + gtk_box_pack_start(tc_gtk_box(heading), title, 1, 1, 0) + appendControl("↻", name: "details:quota-refresh", to: heading) + gtk_box_pack_start(tc_gtk_box(content), heading, 0, 0, 0) + + for bucket in codexQuota.buckets { + appendQuotaCard(bucket, to: content) + } + + let metadata = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5) + if let plan = codexQuota.planType, !plan.isEmpty { + appendQuotaChip(tr("quota.plan", displayPlan(plan)), to: metadata) + } + if codexQuota.hasUnlimitedCredits { + appendQuotaChip(tr("quota.unlimited"), to: metadata) + } else if let balance = codexQuota.creditBalance, balance != "0" { + appendQuotaChip(tr("quota.creditBalance", balance), to: metadata) + } + if codexQuota.resetCreditCount > 0 { + appendQuotaChip(tr("quota.resetCredits", codexQuota.resetCreditCount), to: metadata) + } + gtk_box_pack_start(tc_gtk_box(content), metadata, 0, 0, 0) + + var source = tr(codexQuota.source == .appServer ? "quota.liveSource" : "quota.logSource") + if let refreshedAt = codexQuota.refreshedAt { + source += " · " + tr("quota.updated", quotaUpdatedLabel(refreshedAt)) + } + let sourceLabel = gtk_label_new("● \(source)") + gtk_label_set_xalign(tc_gtk_label(sourceLabel), 0) + tc_gtk_add_class(sourceLabel, "tokenclock-quota-source") + gtk_box_pack_start(tc_gtk_box(content), sourceLabel, 0, 0, 2) + } + + private func appendQuotaCard( + _ bucket: CodexQuotaBucket, + to content: UnsafeMutablePointer + ) { + let card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 7) + gtk_container_set_border_width(tc_gtk_container(card), 10) + tc_gtk_add_class(card, "tokenclock-quota-card") + + let heading = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6) + let nameBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 1) + let name = gtk_label_new(bucket.name) + gtk_label_set_xalign(tc_gtk_label(name), 0) + tc_gtk_add_class(name, "tokenclock-detail-text") + gtk_box_pack_start(tc_gtk_box(nameBox), name, 0, 0, 0) + let window = gtk_label_new(quotaWindowLabel(minutes: bucket.windowMinutes)) + gtk_label_set_xalign(tc_gtk_label(window), 0) + tc_gtk_add_class(window, "tokenclock-detail-subtext") + gtk_box_pack_start(tc_gtk_box(nameBox), window, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(heading), nameBox, 1, 1, 0) + let remaining = gtk_label_new(tr("quota.remaining", bucket.remainingPercent)) + tc_gtk_add_class(remaining, quotaAccentClass(bucket.remainingPercent)) + gtk_box_pack_start(tc_gtk_box(heading), remaining, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(card), heading, 0, 0, 0) + + let progress = gtk_progress_bar_new() + gtk_progress_bar_set_fraction( + tc_gtk_progress_bar(progress), min(1, max(0, bucket.remainingPercent / 100)) + ) + tc_gtk_add_class(progress, quotaAccentClass(bucket.remainingPercent)) + gtk_box_pack_start(tc_gtk_box(card), progress, 0, 0, 0) + + if let resetsAt = bucket.resetsAt { + let reset = gtk_label_new(quotaResetLabel(resetsAt)) + gtk_label_set_xalign(tc_gtk_label(reset), 0) + gtk_label_set_ellipsize(tc_gtk_label(reset), PANGO_ELLIPSIZE_END) + tc_gtk_add_class(reset, "tokenclock-detail-subtext") + gtk_box_pack_start(tc_gtk_box(card), reset, 0, 0, 0) + } + gtk_box_pack_start(tc_gtk_box(content), card, 0, 0, 0) + } + + private func appendQuotaChip(_ text: String, to row: UnsafeMutablePointer?) { + let label = gtk_label_new(text) + tc_gtk_add_class(label, "tokenclock-quota-chip") + gtk_box_pack_start(tc_gtk_box(row), label, 0, 0, 0) + } + + private func quotaAccentClass(_ remaining: Double) -> String { + if remaining <= 15 { return "tokenclock-quota-danger" } + if remaining <= 35 { return "tokenclock-quota-warning" } + return "tokenclock-quota-good" + } + + private func quotaWindowLabel(minutes: Int) -> String { + if minutes == 10_080 { return tr("quota.weekly") } + if minutes >= 1_440, minutes.isMultiple(of: 1_440) { + return tr("quota.days", minutes / 1_440) + } + if minutes >= 60, minutes.isMultiple(of: 60) { + return tr("quota.hours", minutes / 60) + } + return tr("quota.minutes", minutes) + } + + private func quotaResetLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: L10n.shared.language.rawValue) + formatter.dateStyle = .medium + formatter.timeStyle = .short + let seconds = max(0, date.timeIntervalSinceNow) + let relative: String + if seconds >= 86_400 { + relative = localized(zh: "\(Int(ceil(seconds / 86_400))) 天后", en: "in \(Int(ceil(seconds / 86_400)))d") + } else if seconds >= 3_600 { + relative = localized(zh: "\(Int(ceil(seconds / 3_600))) 小时后", en: "in \(Int(ceil(seconds / 3_600)))h") + } else { + relative = localized(zh: "\(Int(ceil(seconds / 60))) 分钟后", en: "in \(Int(ceil(seconds / 60)))m") + } + return tr("quota.resets", formatter.string(from: date), relative) + } + + private func displayPlan(_ raw: String) -> String { + if raw.lowercased() == "prolite" { return "Pro" } + return raw.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func quotaUpdatedLabel(_ date: Date) -> String { + let seconds = max(0, Date().timeIntervalSince(date)) + if seconds < 60 { return localized(zh: "刚刚", en: "just now") } + if seconds < 3_600 { + return localized(zh: "\(Int(seconds / 60)) 分钟前", en: "\(Int(seconds / 60))m ago") + } + return localized(zh: "\(Int(seconds / 3_600)) 小时前", en: "\(Int(seconds / 3_600))h ago") + } + + private func refreshQuota(force: Bool = false) { + guard !quotaFetchInFlight else { return } + if !force, codexQuota.status == .available, !codexQuota.isStale { return } + quotaFetchInFlight = true + codexQuota = .loading(previous: codexQuota) + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self else { return } + let snapshot = self.quotaService.fetch() + self.quotaLock.lock() + self.pendingQuota = snapshot + self.quotaLock.unlock() + _ = tc_gtk_idle_add(linuxDetailsQuotaReady, self.opaque) + } + } + + fileprivate func applyPendingQuota() { + quotaLock.lock() + let snapshot = pendingQuota + pendingQuota = nil + quotaLock.unlock() + quotaFetchInFlight = false + if let snapshot { codexQuota = snapshot } + scheduleRebuild() + } + + private func scheduleRebuild() { + guard !rebuildScheduled else { return } + rebuildScheduled = true + _ = tc_gtk_idle_add(linuxDetailsRebuildIdle, opaque) + } + + fileprivate func performScheduledRebuild() { + rebuildScheduled = false + let visible = isVisible + if visible, let window { gtk_widget_hide(window) } + rebuild() + applyThemeIfNeeded(force: true) + if visible { + resizeAndPosition() + if let window { gtk_widget_show_all(window) } + } + } + private func separator() -> UnsafeMutablePointer? { let separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL) tc_gtk_add_class(separator, "tokenclock-detail-separator") @@ -415,19 +649,9 @@ final class LinuxDetailsPanel: @unchecked Sendable { private func resizeAndPosition() { guard let parent, let window else { return } - let width = size.diameter + 80 - let rowCount: Int - switch grouping { - case .session: - rowCount = tools.filter { $0.todayTokens > 0 || $0.todayMessages > 0 }.reduce(0) { - $0 + 1 + (expandedTools.contains($1.name) ? $1.sessions.count : 0) - } - case .model: - rowCount = UsageAggregator.groupedByModel( - tools, unknownLabel: tr("detail.unknownModel") - ).reduce(0) { $0 + 1 + (expandedModels.contains($1.name) ? $1.contributions.count : 0) } - } - let height = min(430, max(170, 100 + rowCount * 34)) + let width = Self.panelWidth + let height = Self.panelHeight + gtk_widget_set_size_request(window, gint(width), gint(height)) gtk_window_resize(tc_gtk_window(window), gint(width), gint(height)) tc_gtk_position_adjacent_panel( parent, window, gint(width), gint(height), 8 @@ -464,6 +688,23 @@ final class LinuxDetailsPanel: @unchecked Sendable { .tokenclock-detail-row-button { background: transparent; border: 0; padding: 0; } .tokenclock-detail-row-button:hover { background: alpha(\(text), 0.07); } .tokenclock-detail-child-row { background: alpha(\(text), 0.025); } + .tokenclock-quota-heading { color: \(header); font: 700 10px Sans; letter-spacing: 1.2px; } + .tokenclock-quota-card { + background: alpha(\(text), 0.055); border: 1px solid alpha(\(text), 0.10); + border-radius: 9px; + } + .tokenclock-quota-chip { + color: \(subtext); background: alpha(\(text), 0.07); + border-radius: 9px; padding: 3px 6px; font: 600 9px Sans; + } + .tokenclock-quota-source { color: \(subtext); font: 9px Sans; } + .tokenclock-quota-good { color: #3ac56c; font: 700 13px Sans; } + .tokenclock-quota-warning { color: #f0a32f; font: 700 13px Sans; } + .tokenclock-quota-danger { color: #ef4b4b; font: 700 13px Sans; } + progressbar trough { background: alpha(\(text), 0.09); min-height: 6px; border-radius: 3px; } + progressbar.tokenclock-quota-good progress { background: #3ac56c; min-height: 6px; border-radius: 3px; } + progressbar.tokenclock-quota-warning progress { background: #f0a32f; min-height: 6px; border-radius: 3px; } + progressbar.tokenclock-quota-danger progress { background: #ef4b4b; min-height: 6px; border-radius: 3px; } scrollbar slider { background: alpha(\(text), 0.25); min-width: 6px; border-radius: 3px; } """ ) @@ -478,6 +719,11 @@ final class LinuxDetailsPanel: @unchecked Sendable { private func tr(_ key: String) -> String { L10n.shared.tr(key) } + private func tr(_ key: String, _ args: CVarArg...) -> String { + let format = L10n.shared.tr(key) + return String(format: format, arguments: args) + } + private func localized(zh: String, en: String) -> String { L10n.shared.language == .en ? en : zh } @@ -495,3 +741,13 @@ private func linuxDetailsAction( guard let widget else { return } detailsPanel(from: data)?.handleAction(widget: widget) } + +private func linuxDetailsRebuildIdle(_ data: gpointer?) -> gboolean { + detailsPanel(from: data)?.performScheduledRebuild() + return 0 +} + +private func linuxDetailsQuotaReady(_ data: gpointer?) -> gboolean { + detailsPanel(from: data)?.applyPendingQuota() + return 0 +} diff --git a/Sources/TokenClock/Linux/LinuxPathConfig.swift b/Sources/TokenClock/Linux/LinuxPathConfig.swift new file mode 100644 index 0000000..e21bde6 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxPathConfig.swift @@ -0,0 +1,164 @@ +#if os(Linux) +import Foundation + +/// Linux path configuration facade used by the shared parsers. +/// +/// Resolution order is: saved custom path, provider environment variables, +/// Linux catalog default. Paths support `~`, `$VAR`, and `${VAR}` expansion. +enum PathConfig { + private static let prefix = "TC_" + + static func openclawHome() -> String { home(.openclaw, key: "openclawPath") } + static func claudeCodeHome() -> String { home(.claudeCode, key: "claudeCodePath") } + static func geminiHome() -> String { home(.gemini, key: "geminiPath") } + static func codexHome() -> String { home(.codex, key: "codexPath") } + static func hermesHome() -> String { home(.hermes, key: "hermesPath") } + static func opencodeHome() -> String { home(.opencode, key: "opencodePath") } + static func qwenHome() -> String { home(.qwen, key: "qwenPath") } + static func copilotHome() -> String { home(.copilot, key: "copilotPath") } + static func grokHome() -> String { home(.grok, key: "grokPath") } + static func aiderHome() -> String { home(.aider, key: "aiderPath") } + static func antigravityHome() -> String { home(.antigravity, key: "antigravityPath") } + static func clineHome() -> String { home(.cline, key: "clinePath") } + static func continueHome() -> String { home(.continue, key: "continuePath") } + static func cursorAgentHome() -> String { home(.cursorAgent, key: "cursorAgentPath") } + + static func opencodeDatabasePath() -> String { + if let custom = customPath(forKey: "opencodePath") { + return custom.hasSuffix(".db") ? custom : custom + "/opencode.db" + } + if let direct = firstDirectEnvironmentFile(.opencode) { return direct } + return home(.opencode, key: "opencodePath") + "/opencode.db" + } + + static func copilotOtelFile() -> String? { + if let custom = customPath(forKey: "copilotPath"), custom.hasSuffix(".jsonl") { + return custom + } + return firstDirectEnvironmentFile(.copilot) + } + + static func defaultOpenclawHome() -> String { defaultPath(.openclaw) } + static func defaultClaudeCodeHome() -> String { defaultPath(.claudeCode) } + static func defaultGeminiHome() -> String { defaultPath(.gemini) } + static func defaultCodexHome() -> String { defaultPath(.codex) } + static func defaultHermesHome() -> String { defaultPath(.hermes) } + static func defaultOpenCodeHome() -> String { defaultPath(.opencode) } + static func defaultQwenHome() -> String { defaultPath(.qwen) } + static func defaultCopilotHome() -> String { defaultPath(.copilot) } + static func defaultGrokHome() -> String { defaultPath(.grok) } + static func defaultAiderHome() -> String { defaultPath(.aider) } + static func defaultAntigravityHome() -> String { defaultPath(.antigravity) } + static func defaultClineHome() -> String { defaultPath(.cline) } + static func defaultContinueHome() -> String { defaultPath(.continue) } + static func defaultCursorAgentHome() -> String { defaultPath(.cursorAgent) } + + static func openclawCandidates() -> [String] { candidates(.openclaw) } + static func claudeCodeCandidates() -> [String] { candidates(.claudeCode) } + static func geminiCandidates() -> [String] { candidates(.gemini) } + static func codexCandidates() -> [String] { candidates(.codex) } + static func hermesCandidates() -> [String] { candidates(.hermes) } + static func opencodeCandidates() -> [String] { candidates(.opencode) } + static func qwenCandidates() -> [String] { candidates(.qwen) } + static func copilotCandidates() -> [String] { candidates(.copilot) } + static func grokCandidates() -> [String] { candidates(.grok) } + static func aiderCandidates() -> [String] { candidates(.aider) } + static func antigravityCandidates() -> [String] { candidates(.antigravity) } + static func clineCandidates() -> [String] { candidates(.cline) } + static func continueCandidates() -> [String] { candidates(.continue) } + static func cursorAgentCandidates() -> [String] { candidates(.cursorAgent) } + + static func antigravityConversationDirs() -> [String] { + var roots = [ + defaultPath(.antigravity), + LinuxProviderCatalog.homeDirectory + "/.gemini/antigravity-ide", + LinuxProviderCatalog.homeDirectory + "/.gemini/antigravity", + ] + if let override = customPath(forKey: "antigravityPath") + ?? firstEnvironmentPath(.antigravity) { + roots.insert(override, at: 0) + } + var seen = Set() + return roots.compactMap { root in + let directory = LinuxProviderCatalog.normalizedPath(root) + "/conversations" + guard seen.insert(directory).inserted else { return nil } + return directory + } + } + + static func setOpenclawPath(_ path: String) { setCustomPath(path, forKey: "openclawPath") } + static func setClaudeCodePath(_ path: String) { setCustomPath(path, forKey: "claudeCodePath") } + static func setGeminiPath(_ path: String) { setCustomPath(path, forKey: "geminiPath") } + static func setCodexPath(_ path: String) { setCustomPath(path, forKey: "codexPath") } + static func setHermesPath(_ path: String) { setCustomPath(path, forKey: "hermesPath") } + static func setOpenCodePath(_ path: String) { setCustomPath(path, forKey: "opencodePath") } + static func setQwenPath(_ path: String) { setCustomPath(path, forKey: "qwenPath") } + static func setCopilotPath(_ path: String) { setCustomPath(path, forKey: "copilotPath") } + static func setGrokPath(_ path: String) { setCustomPath(path, forKey: "grokPath") } + static func setAiderPath(_ path: String) { setCustomPath(path, forKey: "aiderPath") } + static func setAntigravityPath(_ path: String) { setCustomPath(path, forKey: "antigravityPath") } + static func setClinePath(_ path: String) { setCustomPath(path, forKey: "clinePath") } + static func setContinuePath(_ path: String) { setCustomPath(path, forKey: "continuePath") } + static func setCursorAgentPath(_ path: String) { setCustomPath(path, forKey: "cursorAgentPath") } + + static var hasRunInitialDetection: Bool { + get { UserDefaults.standard.bool(forKey: prefix + "hasRunInitialDetection") } + set { UserDefaults.standard.set(newValue, forKey: prefix + "hasRunInitialDetection") } + } + + private static func home(_ provider: LinuxProviderCatalog.Provider, key: String) -> String { + customPath(forKey: key) + ?? firstEnvironmentPath(provider) + ?? defaultPath(provider) + } + + private static func defaultPath(_ provider: LinuxProviderCatalog.Provider) -> String { + LinuxProviderCatalog.entry(for: provider).defaultPath + } + + private static func candidates(_ provider: LinuxProviderCatalog.Provider) -> [String] { + LinuxProviderCatalog.candidates(for: provider).map(\.path) + } + + private static func firstEnvironmentPath(_ provider: LinuxProviderCatalog.Provider) -> String? { + LinuxProviderCatalog.candidates(for: provider).first { + guard !$0.isDirectFile else { return false } + switch $0.origin { + case .officialEnvironment, .compatibilityEnvironment: return true + default: return false + } + }?.path + } + + private static func firstDirectEnvironmentFile( + _ provider: LinuxProviderCatalog.Provider + ) -> String? { + LinuxProviderCatalog.candidates(for: provider).first { + guard $0.isDirectFile else { return false } + switch $0.origin { + case .officialEnvironment, .compatibilityEnvironment: return true + default: return false + } + }?.path + } + + private static func customPath(forKey key: String) -> String? { + guard let value = UserDefaults.standard.string(forKey: prefix + key), !value.isEmpty else { + return nil + } + return LinuxProviderCatalog.normalizedPath(value) + } + + private static func setCustomPath(_ path: String, forKey key: String) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + UserDefaults.standard.removeObject(forKey: prefix + key) + } else { + UserDefaults.standard.set( + LinuxProviderCatalog.normalizedPath(trimmed), + forKey: prefix + key + ) + } + } +} +#endif diff --git a/Sources/TokenClock/Linux/LinuxPathDetector.swift b/Sources/TokenClock/Linux/LinuxPathDetector.swift new file mode 100644 index 0000000..49a73e3 --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxPathDetector.swift @@ -0,0 +1,414 @@ +#if os(Linux) +import Foundation +import CSQLite + +/// Linux-only provider detection. A declared catalog entry, an existing path, +/// and a parser-readable data source are deliberately reported separately. +enum PathDetector { + private static let maximumDetectionEntries = 8_000 + private static let maximumDetectionDepth = 8 + private static let maximumDetectionSeconds: TimeInterval = 0.25 + + struct DetectionResult { + let service: String + let emoji: String + let detectedPath: String + let isDefault: Bool + let catalogDeclared: Bool + let pathExists: Bool + let parserReadable: Bool + let source: PathSource + let detail: String + + /// Compatibility with the existing settings/model call sites: "found" + /// means that the parser can actually consume the source, not merely + /// that a directory happens to exist. + var exists: Bool { parserReadable } + + enum PathSource: String { + case userDefaults = "pathSource.userDefaults" + case envVariable = "pathSource.envVar" + case officialDefault = "pathSource.official" + case alternate = "pathSource.alternate" + case notFound = "pathSource.notFound" + } + + var localizedSource: String { L10n.shared.tr(source.rawValue) } + } + + struct DetectionSummary { + let results: [DetectionResult] + let declaredCount: Int + let existingPathCount: Int + let foundCount: Int + let totalCount: Int + var allFound: Bool { foundCount == totalCount } + } + + private struct Candidate { + let path: String + let source: DetectionResult.PathSource + let isDirectFile: Bool + } + + static func runFullDetection() -> DetectionSummary { + let results = LinuxProviderCatalog.Provider.allCases.map(detect) + return DetectionSummary( + results: results, + declaredCount: results.filter(\.catalogDeclared).count, + existingPathCount: results.filter(\.pathExists).count, + foundCount: results.filter(\.parserReadable).count, + totalCount: results.count + ) + } + + static func detectAll() -> [DetectionResult] { + runFullDetection().results + } + + private static func detect(_ provider: LinuxProviderCatalog.Provider) -> DetectionResult { + let entry = LinuxProviderCatalog.entry(for: provider) + let custom = customPath(for: provider) + var candidates: [Candidate] = [] + var seen = Set() + + func append( + _ rawPath: String, + source: DetectionResult.PathSource, + isDirectFile: Bool = false + ) { + let path = LinuxProviderCatalog.normalizedPath(rawPath) + guard seen.insert(path).inserted else { return } + candidates.append(Candidate(path: path, source: source, isDirectFile: isDirectFile)) + } + + if let custom { + let directFile = (provider == .opencode && custom.hasSuffix(".db")) + || (provider == .copilot && custom.hasSuffix(".jsonl")) + append(custom, source: .userDefaults, isDirectFile: directFile) + } + for catalogCandidate in LinuxProviderCatalog.candidates(for: provider) { + let source: DetectionResult.PathSource + switch catalogCandidate.origin { + case .officialEnvironment: + source = .envVariable + case .compatibilityEnvironment, .linuxConvention, .alternate: + source = .alternate + case .officialDefault: + source = .officialDefault + } + append( + catalogCandidate.path, + source: source, + isDirectFile: catalogCandidate.isDirectFile + ) + } + + let readable = candidates.first { + parserCanRead(provider, path: $0.path, isDirectFile: $0.isDirectFile) + } + let existing = candidates.first { pathExists($0.path) } + let selected = readable ?? existing + let selectedPath = selected?.path ?? entry.defaultPath + let selectedSource = selected?.source ?? .notFound + let isReadable = readable != nil + let doesExist = existing != nil + + let detail: String + if isReadable { + switch selectedSource { + case .userDefaults: detail = L10n.shared.tr("pathDetail.userCustom") + case .envVariable: detail = L10n.shared.tr("pathDetail.envDetected") + case .officialDefault: detail = L10n.shared.tr("pathDetail.official") + case .alternate: detail = L10n.shared.tr("pathDetail.alternate") + case .notFound: detail = L10n.shared.tr("pathDetail.notFound") + } + } else { + detail = L10n.shared.tr("pathDetail.notFoundDefault") + } + + return DetectionResult( + service: entry.service, + emoji: entry.emoji, + detectedPath: selectedPath, + isDefault: custom == nil && selectedSource != .userDefaults, + catalogDeclared: true, + pathExists: doesExist, + parserReadable: isReadable, + source: selectedSource, + detail: detail + ) + } + + private static func customPath(for provider: LinuxProviderCatalog.Provider) -> String? { + let key: SettingsKey + switch provider { + case .openclaw: key = .openclawPath + case .claudeCode: key = .claudeCodePath + case .gemini: key = .geminiPath + case .codex: key = .codexPath + case .hermes: key = .hermesPath + case .opencode: key = .opencodePath + case .qwen: key = .qwenPath + case .copilot: key = .copilotPath + case .grok: key = .grokPath + case .aider: key = .aiderPath + case .antigravity: key = .antigravityPath + case .cline: key = .clinePath + case .continue: key = .continuePath + case .cursorAgent: key = .cursorAgentPath + } + guard let rawValue = UserDefaults.standard.string(for: key), !rawValue.isEmpty else { return nil } + return LinuxProviderCatalog.normalizedPath(rawValue) + } + + private static func parserCanRead( + _ provider: LinuxProviderCatalog.Provider, + path home: String, + isDirectFile: Bool + ) -> Bool { + switch provider { + case .openclaw: + return containsReadableJSON(in: home + "/agents", extensions: ["jsonl"]) + || containsReadableJSON(in: home + "/sessions", extensions: ["jsonl"]) + case .claudeCode: + return containsReadableJSON(in: home + "/projects", extensions: ["jsonl"]) + case .gemini: + return containsReadableJSON(in: home + "/tmp", extensions: ["jsonl", "json"]) + case .codex: + return containsReadableJSON( + in: home + "/sessions", + extensions: ["jsonl"], + filenamePrefix: "rollout-" + ) + case .hermes: + return sqliteHasColumns( + path: home + "/state.db", + table: "sessions", + columns: [ + "started_at", "input_tokens", "output_tokens", "cache_read_tokens", + "cache_write_tokens", "message_count", + ] + ) + case .opencode: + return sqliteHasColumns( + path: isDirectFile ? home : home + "/opencode.db", + table: "session", + columns: [ + "tokens_input", "tokens_output", "tokens_reasoning", "tokens_cache_read", + "tokens_cache_write", "time_created", + ] + ) + case .qwen: + return containsReadableJSON(in: home + "/projects", extensions: ["jsonl"]) + case .copilot: + return (isDirectFile && readableJSONFile(home)) + || containsReadableJSON(in: home + "/session-state", extensions: ["jsonl"]) + || containsReadableJSON(in: home + "/otel", extensions: ["jsonl"]) + case .grok: + return containsReadableJSON( + in: home + "/sessions", + extensions: ["jsonl"], + filename: "updates.jsonl" + ) + case .aider: + return readableJSONFile(home + "/analytics.jsonl") + case .antigravity: + return containsReadableSQLite( + in: home + "/conversations", + table: "steps", + columns: ["step_payload", "metadata"] + ) + case .cline: + return containsReadableFile( + in: home + "/tasks", + filename: "api_conversation.json", + validator: readableJSONFile + ) + case .continue: + return containsReadableJSON(in: home + "/dev_data", extensions: ["jsonl"]) + || containsReadableJSON(in: home + "/sessions", extensions: ["jsonl"]) + case .cursorAgent: + return cursorCredentialsAreReadable(at: home) + } + } + + private static func pathExists(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: path) + } + + private static func containsReadableJSON( + in directory: String, + extensions: Set, + filenamePrefix: String? = nil, + filename: String? = nil + ) -> Bool { + containsReadableFile(in: directory, filename: filename) { path in + let name = URL(fileURLWithPath: path).lastPathComponent + let fileExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + guard extensions.contains(fileExtension) else { return false } + if let filenamePrefix, !name.hasPrefix(filenamePrefix) { return false } + return readableJSONFile(path) + } + } + + private static func containsReadableFile( + in directory: String, + filename: String? = nil, + validator: (String) -> Bool + ) -> Bool { + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: directory, isDirectory: &isDirectory), isDirectory.boolValue, + let enumerator = fm.enumerator( + at: URL(fileURLWithPath: directory, isDirectory: true), + includingPropertiesForKeys: [.isRegularFileKey, .isReadableKey], + options: [.skipsHiddenFiles] + ) else { return false } + let startedAt = Date() + var visitedEntries = 0 + for case let url as URL in enumerator { + visitedEntries += 1 + if visitedEntries > maximumDetectionEntries + || Date().timeIntervalSince(startedAt) > maximumDetectionSeconds { + return false + } + if enumerator.level > maximumDetectionDepth { + enumerator.skipDescendants() + continue + } + if let filename, url.lastPathComponent != filename { continue } + guard validator(url.path) else { continue } + return true + } + return false + } + + private static func readableJSONFile(_ path: String) -> Bool { + let fm = FileManager.default + guard fm.isReadableFile(atPath: path), + let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return false } + defer { try? handle.close() } + guard let prefix = try? handle.read(upToCount: 256 * 1_024), !prefix.isEmpty else { + return false + } + if URL(fileURLWithPath: path).pathExtension.lowercased() == "json" { + if let attributes = try? fm.attributesOfItem(atPath: path), + let size = attributes[.size] as? NSNumber, + size.intValue > prefix.count, + let fullData = try? Data(contentsOf: URL(fileURLWithPath: path)) { + return (try? JSONSerialization.jsonObject(with: fullData)) != nil + } + return (try? JSONSerialization.jsonObject(with: prefix)) != nil + } + for line in prefix.split(separator: 0x0A) where !line.isEmpty { + if (try? JSONSerialization.jsonObject(with: Data(line))) != nil { return true } + } + return false + } + + private static func containsReadableSQLite( + in directory: String, + table: String, + columns: Set + ) -> Bool { + containsReadableFile(in: directory) { path in + guard URL(fileURLWithPath: path).pathExtension.lowercased() == "db" else { return false } + return sqliteHasColumns(path: path, table: table, columns: columns) + } + } + + private static func sqliteHasColumns( + path: String, + table: String, + columns: Set + ) -> Bool { + guard FileManager.default.isReadableFile(atPath: path) else { return false } + var database: OpaquePointer? + guard sqlite3_open_v2(path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, + let database else { + if database != nil { sqlite3_close(database) } + return false + } + defer { sqlite3_close(database) } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "PRAGMA table_info(\(table))", -1, &statement, nil) == SQLITE_OK, + let statement else { return false } + defer { sqlite3_finalize(statement) } + var found = Set() + while sqlite3_step(statement) == SQLITE_ROW { + if let pointer = sqlite3_column_text(statement, 1) { + found.insert(String(cString: pointer)) + } + } + return columns.isSubset(of: found) + } + + private static func cursorCredentialsAreReadable(at home: String) -> Bool { + let possiblePaths: [String] + if home.hasSuffix(".vscdb") { + possiblePaths = [home] + } else { + possiblePaths = [ + home + "/state.vscdb", + home + "/User/globalStorage/state.vscdb", + ] + } + for path in possiblePaths where FileManager.default.isReadableFile(atPath: path) { + var database: OpaquePointer? + guard sqlite3_open_v2(path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, + let database else { + if database != nil { sqlite3_close(database) } + continue + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + let query = "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken' LIMIT 1" + guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK, + let statement else { continue } + defer { sqlite3_finalize(statement) } + if sqlite3_step(statement) == SQLITE_ROW, + let pointer = sqlite3_column_text(statement, 0), + !String(cString: pointer).isEmpty { + return true + } + } + return false + } + + // Compatibility helpers retained for code outside the Linux target. + static func findJSONLFiles(in basePath: String, subpath: String? = nil, recursive: Bool = false) -> Bool { + let directory = subpath.map { basePath + "/" + $0 } ?? basePath + if recursive { return containsReadableJSON(in: directory, extensions: ["jsonl"]) } + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return false } + return contents.contains { readableJSONFile(directory + "/" + $0) && $0.hasSuffix(".jsonl") } + } + + static func findJSONFiles(in basePath: String, subpath: String? = nil, recursive: Bool = false) -> Bool { + let directory = subpath.map { basePath + "/" + $0 } ?? basePath + if recursive { return containsReadableJSON(in: directory, extensions: ["json"]) } + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return false } + return contents.contains { readableJSONFile(directory + "/" + $0) && $0.hasSuffix(".json") } + } + + static func findRolloutJSONLFiles(in basePath: String, subpath: String) -> Bool { + containsReadableJSON( + in: basePath + "/" + subpath, + extensions: ["jsonl"], + filenamePrefix: "rollout-" + ) + } + + static func checkSSHConfig(for host: String) -> Bool { + let path = LinuxProviderCatalog.homeDirectory + "/.ssh/config" + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return false } + return content.components(separatedBy: .newlines).contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.lowercased().hasPrefix("host ") else { return false } + let value = trimmed.dropFirst(5).trimmingCharacters(in: .whitespaces) + return value == host || value == "*" + } + } +} +#endif diff --git a/Sources/TokenClock/Linux/LinuxProviderCatalog.swift b/Sources/TokenClock/Linux/LinuxProviderCatalog.swift new file mode 100644 index 0000000..2a4e23e --- /dev/null +++ b/Sources/TokenClock/Linux/LinuxProviderCatalog.swift @@ -0,0 +1,427 @@ +#if os(Linux) +import Foundation + +/// Linux owns its provider paths. This catalog deliberately does not inherit +/// macOS Application Support paths or Windows AppData paths. +enum LinuxProviderCatalog { + enum Provider: String, CaseIterable, Sendable { + case openclaw + case claudeCode + case gemini + case codex + case hermes + case opencode + case qwen + case copilot + case grok + case aider + case antigravity + case cline + case `continue` + case cursorAgent + } + + enum CandidateOrigin: Sendable { + case officialEnvironment + case compatibilityEnvironment + case officialDefault + case linuxConvention + case alternate + } + + struct Candidate: Sendable { + let path: String + let origin: CandidateOrigin + let environmentVariable: String? + let isDirectFile: Bool + } + + struct Entry: Sendable { + let provider: Provider + let service: String + let displayName: String + let emoji: String + let defaultPath: String + let defaultOrigin: CandidateOrigin + let environmentVariables: [String] + let compatibilityEnvironmentVariables: [String] + let parserInput: String + let limitation: String? + } + + private enum EnvironmentTransform: Sendable { + case directory + case child(String) + case file + case fileParent + } + + private struct EnvironmentRule: Sendable { + let name: String + let transform: EnvironmentTransform + let official: Bool + } + + static var homeDirectory: String { + if let value = ProcessInfo.processInfo.environment["HOME"], !value.isEmpty { + let expanded = expandEnvironmentVariables(in: value) + if expanded.hasPrefix("/") { + return (expanded as NSString).standardizingPath + } + } + return (NSHomeDirectory() as NSString).standardizingPath + } + + static var configHome: String { + xdgDirectory(variable: "XDG_CONFIG_HOME", fallbackComponents: [".config"]) + } + + static var dataHome: String { + xdgDirectory(variable: "XDG_DATA_HOME", fallbackComponents: [".local", "share"]) + } + + static var stateHome: String { + xdgDirectory(variable: "XDG_STATE_HOME", fallbackComponents: [".local", "state"]) + } + + static func entry(for provider: Provider) -> Entry { + switch provider { + case .openclaw: + return Entry( + provider: provider, service: "openclaw", displayName: "OpenClaw", emoji: "⚡", + defaultPath: home(".openclaw"), defaultOrigin: .officialDefault, + environmentVariables: ["OPENCLAW_STATE_DIR", "OPENCLAW_HOME"], + compatibilityEnvironmentVariables: [], + parserInput: "agents/*/sessions/*.jsonl", + limitation: "Current SQLite-only OpenClaw transcripts are not parsed; legacy JSONL transcripts are required." + ) + case .claudeCode: + return Entry( + provider: provider, service: "claudeCode", displayName: "Claude Code", emoji: "🧠", + defaultPath: home(".claude"), defaultOrigin: .officialDefault, + environmentVariables: ["CLAUDE_CONFIG_DIR"], compatibilityEnvironmentVariables: [], + parserInput: "projects/**/*.jsonl", limitation: nil + ) + case .gemini: + return Entry( + provider: provider, service: "gemini", displayName: "Gemini CLI", emoji: "💎", + defaultPath: home(".gemini"), defaultOrigin: .officialDefault, + environmentVariables: ["GEMINI_CLI_HOME"], compatibilityEnvironmentVariables: ["GEMINI_HOME"], + parserInput: "tmp/*/chats/session-*.jsonl (or legacy JSON)", + limitation: "GEMINI_CLI_HOME is a parent directory; Gemini stores data in its .gemini child." + ) + case .codex: + return Entry( + provider: provider, service: "codex", displayName: "Codex", emoji: "🤖", + defaultPath: home(".codex"), defaultOrigin: .officialDefault, + environmentVariables: ["CODEX_HOME"], compatibilityEnvironmentVariables: [], + parserInput: "sessions/YYYY/MM/DD/rollout-*.jsonl", limitation: nil + ) + case .hermes: + return Entry( + provider: provider, service: "hermes", displayName: "Hermes", emoji: "🏔️", + defaultPath: home(".hermes"), defaultOrigin: .officialDefault, + environmentVariables: ["HERMES_HOME"], compatibilityEnvironmentVariables: [], + parserInput: "state.db (sessions table)", limitation: nil + ) + case .opencode: + return Entry( + provider: provider, service: "opencode", displayName: "OpenCode", emoji: "🐙", + defaultPath: data("opencode"), defaultOrigin: .officialDefault, + environmentVariables: ["OPENCODE_DB", "XDG_DATA_HOME"], compatibilityEnvironmentVariables: ["OPENCODE_HOME"], + parserInput: "opencode.db (session table)", + limitation: "OPENCODE_HOME is retained only as a TokenClock compatibility override." + ) + case .qwen: + return Entry( + provider: provider, service: "qwen", displayName: "Qwen Code", emoji: "🟣", + defaultPath: home(".qwen"), defaultOrigin: .officialDefault, + environmentVariables: ["QWEN_RUNTIME_DIR", "QWEN_HOME"], compatibilityEnvironmentVariables: [], + parserInput: "projects/*/chats/*.jsonl", limitation: nil + ) + case .copilot: + return Entry( + provider: provider, service: "copilot", displayName: "GitHub Copilot CLI", emoji: "🐙", + defaultPath: home(".copilot"), defaultOrigin: .officialDefault, + environmentVariables: ["COPILOT_HOME", "COPILOT_OTEL_FILE_EXPORTER_PATH"], compatibilityEnvironmentVariables: [], + parserInput: "session-state/*/events.jsonl and optional OTel JSONL", + limitation: "Detailed token fields require Copilot OTel file export; session events can contain less detail." + ) + case .grok: + return Entry( + provider: provider, service: "grok", displayName: "Grok CLI", emoji: "⚡", + defaultPath: home(".grok"), defaultOrigin: .officialDefault, + environmentVariables: [], compatibilityEnvironmentVariables: ["GROK_HOME"], + parserInput: "sessions/*/*/updates.jsonl", + limitation: "GROK_HOME is a TokenClock compatibility override; no official Grok home override is documented." + ) + case .aider: + return Entry( + provider: provider, service: "aider", displayName: "Aider", emoji: "🤝", + defaultPath: state("aider"), defaultOrigin: .linuxConvention, + environmentVariables: ["AIDER_ANALYTICS_LOG", "XDG_STATE_HOME"], + compatibilityEnvironmentVariables: ["AIDER_HOME"], + parserInput: "analytics.jsonl", + limitation: "Aider does not create a default analytics log; pass --analytics-log or set AIDER_ANALYTICS_LOG." + ) + case .antigravity: + return Entry( + provider: provider, service: "antigravity", displayName: "Antigravity", emoji: "🛡️", + defaultPath: home(".gemini", "antigravity-cli"), defaultOrigin: .officialDefault, + environmentVariables: [], compatibilityEnvironmentVariables: ["ANTIGRAVITY_HOME"], + parserInput: "conversations/*.db (steps protobuf telemetry)", + limitation: "ANTIGRAVITY_HOME is a TokenClock compatibility override." + ) + case .cline: + return Entry( + provider: provider, service: "cline", displayName: "Cline", emoji: "🤖", + defaultPath: config("Code", "User", "globalStorage", "saoudrizwan.claude-dev"), + defaultOrigin: .officialDefault, + environmentVariables: ["XDG_CONFIG_HOME"], compatibilityEnvironmentVariables: ["CLINE_HOME"], + parserInput: "tasks/*/api_conversation.json", + limitation: "Portable and remote VS Code installations are probed as alternates; CLINE_HOME remains a compatibility override." + ) + case .continue: + return Entry( + provider: provider, service: "continue", displayName: "Continue", emoji: "▶️", + defaultPath: home(".continue"), defaultOrigin: .officialDefault, + environmentVariables: [], compatibilityEnvironmentVariables: ["CONTINUE_HOME"], + parserInput: "dev_data/*.jsonl and sessions/*.jsonl", + limitation: "CONTINUE_HOME is a TokenClock compatibility override." + ) + case .cursorAgent: + return Entry( + provider: provider, service: "cursorAgent", displayName: "Cursor Agent", emoji: "🖱️", + defaultPath: config("Cursor", "User", "globalStorage"), defaultOrigin: .officialDefault, + environmentVariables: ["XDG_CONFIG_HOME"], compatibilityEnvironmentVariables: ["CURSOR_AGENT_HOME"], + parserInput: "state.vscdb (cursorAuth/accessToken), then Cursor usage API", + limitation: "Local detection only verifies credentials; usage still comes from Cursor's authenticated network API." + ) + } + } + + static func candidates(for provider: Provider) -> [Candidate] { + let entry = entry(for: provider) + var result: [Candidate] = [] + var seen = Set() + + func append( + _ rawPath: String, + origin: CandidateOrigin, + variable: String? = nil, + isDirectFile: Bool = false + ) { + let path = normalizedPath(rawPath) + guard !path.isEmpty, !seen.contains(path) else { return } + seen.insert(path) + result.append(Candidate( + path: path, + origin: origin, + environmentVariable: variable, + isDirectFile: isDirectFile + )) + } + + for rule in environmentRules(for: provider) { + guard let rawValue = ProcessInfo.processInfo.environment[rule.name], !rawValue.isEmpty else { + continue + } + if rule.name.hasPrefix("XDG_") { + let expanded = expandEnvironmentVariables(in: rawValue) + guard expanded.hasPrefix("/") else { continue } + } + guard + let resolved = environmentPath(rawValue, transform: rule.transform) else { continue } + append( + resolved.path, + origin: rule.official ? .officialEnvironment : .compatibilityEnvironment, + variable: rule.name, + isDirectFile: resolved.isDirectFile + ) + } + + append(entry.defaultPath, origin: entry.defaultOrigin) + for alternate in alternatePaths(for: provider) { + append(alternate, origin: .alternate) + } + return result + } + + static func normalizedPath(_ rawPath: String) -> String { + var expanded = expandEnvironmentVariables(in: rawPath) + if expanded == "~" { + expanded = homeDirectory + } else if expanded.hasPrefix("~/") { + expanded = homeDirectory + String(expanded.dropFirst()) + } else { + expanded = (expanded as NSString).expandingTildeInPath + } + if !expanded.hasPrefix("/") { + expanded = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + .appendingPathComponent(expanded).path + } + return (expanded as NSString).standardizingPath + } + + static func expandEnvironmentVariables(in rawValue: String) -> String { + let expression = try? NSRegularExpression( + pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"# + ) + guard let expression else { return rawValue } + let environment = ProcessInfo.processInfo.environment + let range = NSRange(rawValue.startIndex.. [EnvironmentRule] { + switch provider { + case .openclaw: + return [ + EnvironmentRule(name: "OPENCLAW_STATE_DIR", transform: .directory, official: true), + EnvironmentRule(name: "OPENCLAW_HOME", transform: .child(".openclaw"), official: true), + ] + case .claudeCode: + return [EnvironmentRule(name: "CLAUDE_CONFIG_DIR", transform: .directory, official: true)] + case .gemini: + return [ + EnvironmentRule(name: "GEMINI_CLI_HOME", transform: .child(".gemini"), official: true), + EnvironmentRule(name: "GEMINI_HOME", transform: .directory, official: false), + ] + case .codex: + return [EnvironmentRule(name: "CODEX_HOME", transform: .directory, official: true)] + case .hermes: + return [EnvironmentRule(name: "HERMES_HOME", transform: .directory, official: true)] + case .opencode: + return [ + EnvironmentRule(name: "OPENCODE_DB", transform: .file, official: true), + EnvironmentRule(name: "OPENCODE_HOME", transform: .directory, official: false), + EnvironmentRule(name: "XDG_DATA_HOME", transform: .child("opencode"), official: true), + ] + case .qwen: + return [ + EnvironmentRule(name: "QWEN_RUNTIME_DIR", transform: .directory, official: true), + EnvironmentRule(name: "QWEN_HOME", transform: .directory, official: true), + ] + case .copilot: + return [ + EnvironmentRule(name: "COPILOT_HOME", transform: .directory, official: true), + EnvironmentRule(name: "COPILOT_OTEL_FILE_EXPORTER_PATH", transform: .file, official: true), + ] + case .grok: + return [EnvironmentRule(name: "GROK_HOME", transform: .directory, official: false)] + case .aider: + return [ + EnvironmentRule(name: "AIDER_ANALYTICS_LOG", transform: .fileParent, official: true), + EnvironmentRule(name: "AIDER_HOME", transform: .directory, official: false), + EnvironmentRule(name: "XDG_STATE_HOME", transform: .child("aider"), official: false), + ] + case .antigravity: + return [EnvironmentRule(name: "ANTIGRAVITY_HOME", transform: .directory, official: false)] + case .cline: + return [ + EnvironmentRule(name: "CLINE_HOME", transform: .directory, official: false), + EnvironmentRule( + name: "XDG_CONFIG_HOME", + transform: .child("Code/User/globalStorage/saoudrizwan.claude-dev"), + official: true + ), + ] + case .continue: + return [EnvironmentRule(name: "CONTINUE_HOME", transform: .directory, official: false)] + case .cursorAgent: + return [ + EnvironmentRule(name: "CURSOR_AGENT_HOME", transform: .directory, official: false), + EnvironmentRule(name: "XDG_CONFIG_HOME", transform: .child("Cursor/User/globalStorage"), official: true), + ] + } + } + + private static func alternatePaths(for provider: Provider) -> [String] { + switch provider { + case .opencode: + return [home(".local", "share", "opencode"), home(".opencode")] + case .aider: + return [home(".aider")] + case .antigravity: + return [home(".gemini", "antigravity-ide"), home(".gemini", "antigravity")] + case .cline: + return [ + config("VSCodium", "User", "globalStorage", "saoudrizwan.claude-dev"), + config("Code - OSS", "User", "globalStorage", "saoudrizwan.claude-dev"), + config("Cursor", "User", "globalStorage", "saoudrizwan.claude-dev"), + home(".vscode-server", "data", "User", "globalStorage", "saoudrizwan.claude-dev"), + home(".vscode-server-insiders", "data", "User", "globalStorage", "saoudrizwan.claude-dev"), + home(".cursor-server", "data", "User", "globalStorage", "saoudrizwan.claude-dev"), + ] + case .cursorAgent: + return [home(".cursor-server", "data", "User", "globalStorage")] + default: + return [] + } + } + + private static func environmentPath( + _ rawValue: String, + transform: EnvironmentTransform + ) -> (path: String, isDirectFile: Bool)? { + let value = expandEnvironmentVariables(in: rawValue) + guard value != "-" else { return nil } + let normalized = normalizedPath(value) + switch transform { + case .directory: + return (normalized, false) + case .child(let child): + return ( + URL(fileURLWithPath: normalized, isDirectory: true) + .appendingPathComponent(child, isDirectory: true).path, + false + ) + case .file: + return (normalized, true) + case .fileParent: + return (URL(fileURLWithPath: normalized).deletingLastPathComponent().path, false) + } + } + + private static func xdgDirectory(variable: String, fallbackComponents: [String]) -> String { + if let rawValue = ProcessInfo.processInfo.environment[variable], !rawValue.isEmpty { + let expanded = expandEnvironmentVariables(in: rawValue) + if expanded.hasPrefix("/") { return normalizedPath(expanded) } + } + return home(fallbackComponents) + } + + private static func home(_ components: String...) -> String { home(components) } + + private static func home(_ components: [String]) -> String { + path(root: homeDirectory, components: components) + } + + private static func config(_ components: String...) -> String { + path(root: configHome, components: components) + } + + private static func data(_ components: String...) -> String { + path(root: dataHome, components: components) + } + + private static func state(_ components: String...) -> String { + path(root: stateHome, components: components) + } + + private static func path(root: String, components: [String]) -> String { + components.reduce(root) { partial, component in + URL(fileURLWithPath: partial, isDirectory: true) + .appendingPathComponent(component, isDirectory: true).path + } + } +} +#endif diff --git a/Sources/TokenClock/Linux/LinuxSettingsWindow.swift b/Sources/TokenClock/Linux/LinuxSettingsWindow.swift index 5824e90..35acf73 100644 --- a/Sources/TokenClock/Linux/LinuxSettingsWindow.swift +++ b/Sources/TokenClock/Linux/LinuxSettingsWindow.swift @@ -48,6 +48,10 @@ final class LinuxSettingsWindow: @unchecked Sendable { private var customHandButtons: [String: UnsafeMutablePointer] = [:] private var customNumberStyleButtons: [String: UnsafeMutablePointer] = [:] private var customFontButtons: [String: UnsafeMutablePointer] = [:] + private var sectionRevealers: [String: UnsafeMutablePointer] = [:] + private var sectionButtons: [String: UnsafeMutablePointer] = [:] + private var sectionTitles: [String: String] = [:] + private var expandedSections: Set = [] private var selectedCustomHandStyle = "round" private var selectedCustomNumberStyle = "arabic" private var selectedCustomFont = "rounded" @@ -65,6 +69,7 @@ final class LinuxSettingsWindow: @unchecked Sendable { loadValues() guard let window else { return } gtk_widget_show_all(window) + syncDisclosureStates() gtk_window_present(tc_gtk_window(window)) } @@ -82,6 +87,10 @@ final class LinuxSettingsWindow: @unchecked Sendable { fileprivate func handleAction(widget: UnsafeMutablePointer) { let name = String(cString: tc_gtk_widget_name(widget)) + if name.hasPrefix("settings:section:") { + toggleDisclosure(String(name.dropFirst("settings:section:".count))) + return + } if name.hasPrefix("settings:browse:") { browse(service: String(name.dropFirst("settings:browse:".count))) return @@ -135,23 +144,53 @@ final class LinuxSettingsWindow: @unchecked Sendable { private func buildWindow(parent: UnsafeMutablePointer) { guard let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL), let root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0), - let notebook = gtk_notebook_new() else { return } + let scroll = gtk_scrolled_window_new(nil, nil), + let content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8) else { return } window = createdWindow gtk_window_set_title(tc_gtk_window(createdWindow), L10n.shared.tr("settings.title")) - gtk_window_set_default_size(tc_gtk_window(createdWindow), 600, 600) - gtk_window_set_resizable(tc_gtk_window(createdWindow), 1) + gtk_window_set_default_size(tc_gtk_window(createdWindow), 520, 548) + gtk_window_set_resizable(tc_gtk_window(createdWindow), 0) gtk_window_set_transient_for(tc_gtk_window(createdWindow), tc_gtk_window(parent)) gtk_window_set_position(tc_gtk_window(createdWindow), GTK_WIN_POS_CENTER_ON_PARENT) gtk_window_set_keep_above(tc_gtk_window(createdWindow), 1) _ = tc_gtk_hide_on_delete(createdWindow) gtk_container_add(tc_gtk_container(createdWindow), root) - appendPage(generalPage(), title: localized(zh: "常规", en: "General"), to: notebook) - appendPage(toolsPage(), title: localized(zh: "工具", en: "Tools"), to: notebook) - appendPage(pathsPage(), title: localized(zh: "路径", en: "Paths"), to: notebook) - appendPage(usagePage(), title: localized(zh: "速率", en: "Usage"), to: notebook) - appendPage(themesPage(), title: localized(zh: "表盘", en: "Faces"), to: notebook) - gtk_box_pack_start(tc_gtk_box(root), notebook, 1, 1, 0) + gtk_scrolled_window_set_policy( + tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC + ) + gtk_container_set_border_width(tc_gtk_container(content), 14) + gtk_container_add(tc_gtk_container(scroll), content) + gtk_box_pack_start(tc_gtk_box(root), scroll, 1, 1, 0) + + let title = gtk_label_new(L10n.shared.tr("settings.title")) + gtk_label_set_xalign(tc_gtk_label(title), 0) + tc_gtk_add_class(title, "tokenclock-settings-title") + gtk_box_pack_start(tc_gtk_box(content), title, 0, 0, 2) + if let overview = overviewPage() { + tc_gtk_add_class(overview, "tokenclock-settings-overview") + gtk_box_pack_start(tc_gtk_box(content), overview, 0, 0, 0) + } + appendDisclosure( + key: "api", title: localized(zh: "本地 API", en: "Local API"), + page: localAPIPage(), to: content + ) + appendDisclosure( + key: "tools", title: L10n.shared.tr("settings.toolSelection"), + page: toolsPage(), to: content + ) + appendDisclosure( + key: "paths", title: L10n.shared.tr("settings.dataPaths"), + page: pathsPage(), to: content + ) + appendDisclosure( + key: "usage", title: L10n.shared.tr("rate.title"), + page: usagePage(), to: content + ) + appendDisclosure( + key: "themes", title: localized(zh: "表盘与自定义", en: "Clock Faces & Custom"), + page: themesPage(), to: content + ) let footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) gtk_container_set_border_width(tc_gtk_container(footer), 12) @@ -160,19 +199,28 @@ final class LinuxSettingsWindow: @unchecked Sendable { gtk_box_pack_start(tc_gtk_box(footer), spacer, 1, 1, 0) appendButton(localized(zh: "取消", en: "Cancel"), name: "settings:cancel", to: footer) appendButton(L10n.shared.tr("settings.done"), name: "settings:save", to: footer) - gtk_box_pack_start(tc_gtk_box(root), footer, 0, 0, 0) + gtk_box_pack_end(tc_gtk_box(root), footer, 0, 0, 0) tc_gtk_apply_css(""" + .tokenclock-settings-title { font-size: 20px; font-weight: 700; padding: 2px 3px 7px; } .tokenclock-settings-section { font-size: 14px; font-weight: 700; } .tokenclock-settings-hint { color: rgba(110, 110, 110, 0.95); font-size: 11px; } .tokenclock-settings-row { padding: 5px; } + .tokenclock-settings-overview { + background: rgba(120, 120, 120, 0.055); border-radius: 10px; padding: 10px; + } + .tokenclock-settings-disclosure { + background: rgba(120, 120, 120, 0.06); + border: 1px solid rgba(120, 120, 120, 0.16); + border-radius: 9px; padding: 9px 11px; font-weight: 600; + } + .tokenclock-settings-disclosure:hover { background: rgba(73, 132, 255, 0.10); } """) loadValues() } - private func generalPage() -> UnsafeMutablePointer? { - let page = verticalPage() - appendSection(localized(zh: "本地 API", en: "Local API"), to: page) + private func localAPIPage() -> UnsafeMutablePointer? { + let page = verticalPage(border: 12) apiEnabledButton = gtk_check_button_new_with_label( localized(zh: "启用本地只读 API 服务", en: "Enable local read-only API server") ) @@ -188,24 +236,31 @@ final class LinuxSettingsWindow: @unchecked Sendable { tc_gtk_add_class(endpoint, "tokenclock-settings-hint") gtk_box_pack_start(tc_gtk_box(portRow), endpoint, 0, 0, 0) gtk_box_pack_start(tc_gtk_box(page), portRow, 0, 0, 0) + return page + } - appendSection(L10n.shared.tr("settings.autoDetect"), to: page) + private func overviewPage() -> UnsafeMutablePointer? { + let page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6) + gtk_container_set_border_width(tc_gtk_container(page), 4) let detectRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) + let detectTitle = gtk_label_new(L10n.shared.tr("settings.autoDetect")) + gtk_label_set_xalign(tc_gtk_label(detectTitle), 0) + tc_gtk_add_class(detectTitle, "tokenclock-settings-section") + gtk_box_pack_start(tc_gtk_box(detectRow), detectTitle, 1, 1, 0) appendButton(L10n.shared.tr("settings.redetect"), name: "settings:detect", to: detectRow) + gtk_box_pack_start(tc_gtk_box(page), detectRow, 0, 0, 0) detectionLabel = gtk_label_new("") gtk_label_set_xalign(tc_gtk_label(detectionLabel), 0) - gtk_label_set_line_wrap(tc_gtk_label(detectionLabel), 1) gtk_widget_set_hexpand(detectionLabel, 1) tc_gtk_add_class(detectionLabel, "tokenclock-settings-hint") - gtk_box_pack_start(tc_gtk_box(detectRow), detectionLabel, 1, 1, 0) - gtk_box_pack_start(tc_gtk_box(page), detectRow, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(page), detectionLabel, 0, 0, 0) - appendSection(localized(zh: "网络数据", en: "Network Data"), to: page) cursorCloudButton = gtk_check_button_new_with_label(L10n.shared.tr("dataFetch.cursorCloud")) gtk_box_pack_start(tc_gtk_box(page), cursorCloudButton, 0, 0, 0) let cursorHint = gtk_label_new(L10n.shared.tr("dataFetch.cursorCloudHint")) gtk_label_set_xalign(tc_gtk_label(cursorHint), 0) - gtk_label_set_line_wrap(tc_gtk_label(cursorHint), 1) + gtk_label_set_ellipsize(tc_gtk_label(cursorHint), PANGO_ELLIPSIZE_END) + gtk_label_set_max_width_chars(tc_gtk_label(cursorHint), 58) tc_gtk_add_class(cursorHint, "tokenclock-settings-hint") gtk_box_pack_start(tc_gtk_box(page), cursorHint, 0, 0, 0) return page @@ -238,9 +293,7 @@ final class LinuxSettingsWindow: @unchecked Sendable { } private func pathsPage() -> UnsafeMutablePointer? { - guard let scroll = gtk_scrolled_window_new(nil, nil), - let page = verticalPage() else { return nil } - gtk_scrolled_window_set_policy(tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) + guard let page = verticalPage() else { return nil } appendSection(L10n.shared.tr("settings.dataPaths"), to: page) for option in Self.toolOptions { let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8) @@ -265,8 +318,7 @@ final class LinuxSettingsWindow: @unchecked Sendable { gtk_label_set_line_wrap(tc_gtk_label(hint), 1) tc_gtk_add_class(hint, "tokenclock-settings-hint") gtk_box_pack_start(tc_gtk_box(page), hint, 0, 0, 4) - gtk_container_add(tc_gtk_container(scroll), page) - return scroll + return page } private func usagePage() -> UnsafeMutablePointer? { @@ -312,9 +364,7 @@ final class LinuxSettingsWindow: @unchecked Sendable { } private func themesPage() -> UnsafeMutablePointer? { - guard let scroll = gtk_scrolled_window_new(nil, nil), - let page = verticalPage() else { return nil } - gtk_scrolled_window_set_policy(tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) + guard let page = verticalPage() else { return nil } appendSection(L10n.shared.tr("theme.title"), to: page) let description = gtk_label_new( localized( @@ -451,13 +501,12 @@ final class LinuxSettingsWindow: @unchecked Sendable { gtk_box_pack_start(tc_gtk_box(page), row, 0, 0, 0) } } - gtk_container_add(tc_gtk_container(scroll), page) - return scroll + return page } - private func verticalPage() -> UnsafeMutablePointer? { + private func verticalPage(border: Int = 18) -> UnsafeMutablePointer? { let page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12) - gtk_container_set_border_width(tc_gtk_container(page), 18) + gtk_container_set_border_width(tc_gtk_container(page), guint(border)) return page } @@ -470,6 +519,52 @@ final class LinuxSettingsWindow: @unchecked Sendable { _ = gtk_notebook_append_page(tc_gtk_notebook(notebook), page, gtk_label_new(title)) } + private func appendDisclosure( + key: String, + title: String, + page: UnsafeMutablePointer?, + to content: UnsafeMutablePointer + ) { + guard let page, + let button = gtk_button_new_with_label("▸ \(title)"), + let revealer = gtk_revealer_new() else { return } + gtk_widget_set_name(button, "settings:section:\(key)") + gtk_button_set_relief(tc_gtk_button(button), GTK_RELIEF_NONE) + gtk_button_set_alignment(tc_gtk_button(button), 0, 0.5) + tc_gtk_add_class(button, "tokenclock-settings-disclosure") + _ = tc_gtk_on_clicked(button, linuxSettingsAction, opaque) + gtk_revealer_set_transition_type( + tc_gtk_revealer(revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN + ) + gtk_revealer_set_transition_duration(tc_gtk_revealer(revealer), 160) + gtk_container_add(tc_gtk_container(revealer), page) + gtk_box_pack_start(tc_gtk_box(content), button, 0, 0, 0) + gtk_box_pack_start(tc_gtk_box(content), revealer, 0, 0, 0) + sectionRevealers[key] = revealer + sectionButtons[key] = button + sectionTitles[key] = title + } + + private func toggleDisclosure(_ key: String) { + if expandedSections.contains(key) { expandedSections.remove(key) } + else { expandedSections.insert(key) } + syncDisclosureState(key) + } + + private func syncDisclosureStates() { + for key in sectionRevealers.keys { syncDisclosureState(key) } + } + + private func syncDisclosureState(_ key: String) { + let expanded = expandedSections.contains(key) + if let revealer = sectionRevealers[key] { + gtk_revealer_set_reveal_child(tc_gtk_revealer(revealer), expanded ? 1 : 0) + } + if let button = sectionButtons[key], let title = sectionTitles[key] { + gtk_button_set_label(tc_gtk_button(button), "\(expanded ? "▾" : "▸") \(title)") + } + } + private func appendSection(_ title: String, to page: UnsafeMutablePointer?) { let label = gtk_label_new(title) gtk_label_set_xalign(tc_gtk_label(label), 0) @@ -645,7 +740,7 @@ final class LinuxSettingsWindow: @unchecked Sendable { _ = LinuxCustomThemeStore.shared.resetDraft() UserDefaults.standard.remove(.activeCustomThemeId) loadCustomThemeValues() - owner?.applyCustomTheme(id: nil) + owner?.resetCustomThemeToClassic() } private func colorValue(_ key: String, fallback: LinuxThemeColor) -> LinuxThemeColor { @@ -813,6 +908,9 @@ final class LinuxSettingsWindow: @unchecked Sendable { customHandButtons.removeAll() customNumberStyleButtons.removeAll() customFontButtons.removeAll() + sectionRevealers.removeAll() + sectionButtons.removeAll() + sectionTitles.removeAll() } private func localized(zh: String, en: String) -> String { diff --git a/Sources/TokenClock/Linux/LinuxThemePicker.swift b/Sources/TokenClock/Linux/LinuxThemePicker.swift index b4dd264..962a6e6 100644 --- a/Sources/TokenClock/Linux/LinuxThemePicker.swift +++ b/Sources/TokenClock/Linux/LinuxThemePicker.swift @@ -1,28 +1,28 @@ import Foundation import CGtk -/// GTK visual counterpart of macOS's clock-face picker popover. +/// Compact 3-column clock-face picker matching macOS normal's visual model. final class LinuxThemePicker: @unchecked Sendable { private let renderer = LinuxClockRenderer() private weak var parentOwner: LinuxApp? - private var parent: UnsafeMutablePointer? private var selected: LinuxClockTheme = .classic - private var buttons: [LinuxClockTheme: UnsafeMutablePointer] = [:] - private var labels: [LinuxClockTheme: UnsafeMutablePointer] = [:] - private var previewAreas: [LinuxClockTheme: UnsafeMutablePointer] = [:] - private var titleLabel: UnsafeMutablePointer? + private var root: UnsafeMutablePointer? + private var buttons: [String: UnsafeMutablePointer] = [:] + private var labels: [String: UnsafeMutablePointer] = [:] + private var displayNames: [String: String] = [:] + private var savedPreviewConfigs: [UUID: LinuxCustomThemeConfig] = [:] private(set) var window: UnsafeMutablePointer? private lazy var opaque = Unmanaged.passUnretained(self).toOpaque() init(parent: UnsafeMutablePointer, owner: LinuxApp) { - self.parent = parent parentOwner = owner buildWindow(parent: parent) } func show(selected: LinuxClockTheme) { self.selected = selected + rebuildContents() updateSelection() guard let window else { return } gtk_window_set_position(tc_gtk_window(window), GTK_WIN_POS_CENTER_ON_PARENT) @@ -36,14 +36,26 @@ final class LinuxThemePicker: @unchecked Sendable { func refreshLanguage() { if let window { gtk_window_set_title(tc_gtk_window(window), L10n.shared.tr("menu.clockFace")) } - if let titleLabel { gtk_label_set_text(tc_gtk_label(titleLabel), L10n.shared.tr("menu.clockFace")) } - updateSelection() + rebuildContents() } fileprivate func draw(widget: UnsafeMutablePointer, context: OpaquePointer) { let name = String(cString: tc_gtk_widget_name(widget)) - guard name.hasPrefix("theme-preview:"), - let theme = LinuxClockTheme(rawValue: String(name.dropFirst("theme-preview:".count))) else { + let theme: LinuxClockTheme + if name.hasPrefix("theme-preview:"), + let value = LinuxClockTheme(rawValue: String(name.dropFirst("theme-preview:".count))) { + theme = value + } else if name.hasPrefix("saved-preview:"), + let id = UUID(uuidString: String(name.dropFirst("saved-preview:".count))), + let config = savedPreviewConfigs[id] { + renderer.drawPreview( + context, + width: Double(gtk_widget_get_allocated_width(widget)), + height: Double(gtk_widget_get_allocated_height(widget)), + customConfig: config + ) + return + } else { return } renderer.drawPreview( @@ -56,52 +68,43 @@ final class LinuxThemePicker: @unchecked Sendable { fileprivate func activate(widget: UnsafeMutablePointer) { let name = String(cString: tc_gtk_widget_name(widget)) - guard name.hasPrefix("theme-select:"), - let theme = LinuxClockTheme(rawValue: String(name.dropFirst("theme-select:".count))) else { + if name.hasPrefix("theme-select:"), + let theme = LinuxClockTheme(rawValue: String(name.dropFirst("theme-select:".count))) { + selected = theme + parentOwner?.chooseThemeFromPicker(theme) + hide() return } - selected = theme - updateSelection() - parentOwner?.chooseThemeFromPicker(theme) - hide() + if name.hasPrefix("saved-select:"), + let id = UUID(uuidString: String(name.dropFirst("saved-select:".count))) { + selected = .custom + parentOwner?.applyCustomTheme(id: id) + hide() + } } private func buildWindow(parent: UnsafeMutablePointer) { guard let createdWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL), - let root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10) else { return } + let createdRoot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10) else { return } window = createdWindow + root = createdRoot gtk_window_set_title(tc_gtk_window(createdWindow), L10n.shared.tr("menu.clockFace")) - gtk_window_set_default_size(tc_gtk_window(createdWindow), 440, 280) + gtk_window_set_default_size(tc_gtk_window(createdWindow), 300, 434) gtk_window_set_resizable(tc_gtk_window(createdWindow), 0) gtk_window_set_transient_for(tc_gtk_window(createdWindow), tc_gtk_window(parent)) gtk_window_set_skip_taskbar_hint(tc_gtk_window(createdWindow), 1) gtk_window_set_skip_pager_hint(tc_gtk_window(createdWindow), 1) gtk_window_set_keep_above(tc_gtk_window(createdWindow), 1) - gtk_container_set_border_width(tc_gtk_container(root), 14) - gtk_container_add(tc_gtk_container(createdWindow), root) - - let title = gtk_label_new(L10n.shared.tr("menu.clockFace")) - titleLabel = title - gtk_label_set_xalign(tc_gtk_label(title), 0) - tc_gtk_add_class(title, "tokenclock-picker-title") - gtk_box_pack_start(tc_gtk_box(root), title, 0, 0, 0) - - for rowIndex in 0..<2 { - let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10) - gtk_box_pack_start(tc_gtk_box(root), row, 1, 1, 0) - for column in 0..<4 { - let index = rowIndex * 4 + column - let theme = LinuxClockTheme.builtInCases[index] - appendTheme(theme, to: row) - } - } + gtk_container_set_border_width(tc_gtk_container(createdRoot), 12) + gtk_container_add(tc_gtk_container(createdWindow), createdRoot) + _ = tc_gtk_hide_on_delete(createdWindow) tc_gtk_apply_css(""" .tokenclock-theme-tile { background: rgba(120, 120, 120, 0.06); border: 1px solid rgba(120, 120, 120, 0.20); border-radius: 12px; - padding: 6px; + padding: 4px; } .tokenclock-theme-tile:hover { background: rgba(73, 132, 255, 0.12); @@ -113,45 +116,104 @@ final class LinuxThemePicker: @unchecked Sendable { } .tokenclock-picker-title { font-size: 16px; font-weight: 700; } """) - _ = tc_gtk_on_destroy(createdWindow, linuxThemePickerDestroyed, opaque) + rebuildContents() + } + + private func rebuildContents() { + guard let root else { return } + tc_gtk_remove_all_children(root) + buttons.removeAll() + labels.removeAll() + displayNames.removeAll() + savedPreviewConfigs.removeAll() + + let title = gtk_label_new(L10n.shared.tr("menu.clockFace")) + gtk_label_set_xalign(tc_gtk_label(title), 0) + tc_gtk_add_class(title, "tokenclock-picker-title") + gtk_box_pack_start(tc_gtk_box(root), title, 0, 0, 0) + + guard let scroll = gtk_scrolled_window_new(nil, nil), + let list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8) else { return } + gtk_scrolled_window_set_policy( + tc_gtk_scrolled_window(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC + ) + gtk_container_add(tc_gtk_container(scroll), list) + gtk_box_pack_start(tc_gtk_box(root), scroll, 1, 1, 0) + + var tiles: [(key: String, title: String, theme: LinuxClockTheme, savedID: UUID?)] = + LinuxClockTheme.allCases.map { + ("theme:\($0.rawValue)", $0.displayName, $0, nil) + } + let savedThemes = LinuxCustomThemeStore.shared.themes + tiles += savedThemes.map { + savedPreviewConfigs[$0.id] = $0.config + return ("saved:\($0.id.uuidString)", $0.name, .custom, $0.id) + } + + for rowStart in stride(from: 0, to: tiles.count, by: 3) { + let row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 7) + gtk_box_pack_start(tc_gtk_box(list), row, 0, 0, 0) + for column in 0..<3 { + let index = rowStart + column + if index < tiles.count { + appendTile(tiles[index], to: row) + } else { + let spacer = gtk_label_new("") + gtk_widget_set_size_request(spacer, 84, 1) + gtk_box_pack_start(tc_gtk_box(row), spacer, 1, 1, 0) + } + } + } } - private func appendTheme( - _ theme: LinuxClockTheme, + private func appendTile( + _ tile: (key: String, title: String, theme: LinuxClockTheme, savedID: UUID?), to row: UnsafeMutablePointer? ) { guard let button = gtk_button_new(), - let stack = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4), + let stack = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3), let preview = gtk_drawing_area_new(), - let label = gtk_label_new(theme.displayName) else { return } - gtk_widget_set_name(button, "theme-select:\(theme.rawValue)") - gtk_widget_set_name(preview, "theme-preview:\(theme.rawValue)") + let label = gtk_label_new(tile.title) else { return } + if let savedID = tile.savedID { + gtk_widget_set_name(button, "saved-select:\(savedID.uuidString)") + gtk_widget_set_name(preview, "saved-preview:\(savedID.uuidString)") + } else { + gtk_widget_set_name(button, "theme-select:\(tile.theme.rawValue)") + gtk_widget_set_name(preview, "theme-preview:\(tile.theme.rawValue)") + } tc_gtk_add_class(button, "tokenclock-theme-tile") gtk_button_set_relief(tc_gtk_button(button), GTK_RELIEF_NONE) - gtk_widget_set_size_request(button, 94, 105) - gtk_widget_set_size_request(preview, 76, 76) + gtk_widget_set_size_request(button, 84, 103) + gtk_widget_set_size_request(preview, 66, 66) + gtk_label_set_ellipsize(tc_gtk_label(label), PANGO_ELLIPSIZE_END) gtk_container_add(tc_gtk_container(button), stack) gtk_box_pack_start(tc_gtk_box(stack), preview, 1, 1, 0) gtk_box_pack_start(tc_gtk_box(stack), label, 0, 0, 0) gtk_box_pack_start(tc_gtk_box(row), button, 1, 1, 0) _ = tc_gtk_on_draw(preview, linuxThemePreviewDraw, opaque) _ = tc_gtk_on_clicked(button, linuxThemePickerAction, opaque) - buttons[theme] = button - labels[theme] = label - previewAreas[theme] = preview + buttons[tile.key] = button + labels[tile.key] = label + displayNames[tile.key] = tile.title } private func updateSelection() { - for theme in LinuxClockTheme.builtInCases { - guard let button = buttons[theme], let label = labels[theme] else { continue } - if theme == selected { + let activeID = UserDefaults.standard.string(for: .activeCustomThemeId) + let selectedKey: String + if selected == .custom, let activeID { + selectedKey = "saved:\(activeID)" + } else { + selectedKey = "theme:\(selected.rawValue)" + } + for (key, button) in buttons { + guard let label = labels[key], let title = displayNames[key] else { continue } + if key == selectedKey { tc_gtk_add_class(button, "tokenclock-theme-selected") - gtk_label_set_text(tc_gtk_label(label), "✓ \(theme.displayName)") + gtk_label_set_text(tc_gtk_label(label), "✓ \(title)") } else { tc_gtk_remove_class(button, "tokenclock-theme-selected") - gtk_label_set_text(tc_gtk_label(label), theme.displayName) + gtk_label_set_text(tc_gtk_label(label), title) } - if let preview = previewAreas[theme] { gtk_widget_queue_draw(preview) } } } } @@ -178,10 +240,3 @@ private func linuxThemePickerAction( guard let widget else { return } picker(from: data)?.activate(widget: widget) } - -private func linuxThemePickerDestroyed( - _ widget: UnsafeMutablePointer?, - _ data: gpointer? -) { - picker(from: data)?.hide() -} diff --git a/Sources/TokenClock/Linux/LinuxUsageModel.swift b/Sources/TokenClock/Linux/LinuxUsageModel.swift index c7eab64..0733d74 100644 --- a/Sources/TokenClock/Linux/LinuxUsageModel.swift +++ b/Sources/TokenClock/Linux/LinuxUsageModel.swift @@ -16,20 +16,23 @@ final class LinuxUsageModel: @unchecked Sendable { private var storedRateWindowMinutes: Int private var scanning = false - private let openclawService = OpenClawUsageService() - private let claudeCodeService = ClaudeCodeUsageService() - private let geminiService = GeminiUsageService() - private let codexService = CodexUsageService() - private let hermesService = HermesUsageService() - private let opencodeService = OpenCodeUsageService() - private let qwenService = QwenCodeUsageService() - private let copilotService = CopilotUsageService() - private let grokService = GrokUsageService() - private let aiderService = AiderUsageService() - private let antigravityService = AntigravityUsageService() - private let clineService = ClineUsageService() - private let continueService = ContinueUsageService() - private let cursorAgentService = CursorAgentUsageService() + // Construct parsers only after init-time Linux catalog detection has saved + // any alternate path. Otherwise an alternate would take effect only after + // restarting TokenClock because parser homes are captured in init(). + private lazy var openclawService = OpenClawUsageService() + private lazy var claudeCodeService = ClaudeCodeUsageService() + private lazy var geminiService = GeminiUsageService() + private lazy var codexService = CodexUsageService() + private lazy var hermesService = HermesUsageService() + private lazy var opencodeService = OpenCodeUsageService() + private lazy var qwenService = QwenCodeUsageService() + private lazy var copilotService = CopilotUsageService() + private lazy var grokService = GrokUsageService() + private lazy var aiderService = AiderUsageService() + private lazy var antigravityService = AntigravityUsageService() + private lazy var clineService = ClineUsageService() + private lazy var continueService = ContinueUsageService() + private lazy var cursorAgentService = CursorAgentUsageService() static let allToolNames = Set([ "OpenClaw", "Claude Code", "Gemini CLI", "Codex", "Hermes", "OpenCode", diff --git a/Sources/TokenClock/Services/AiderUsageService.swift b/Sources/TokenClock/Services/AiderUsageService.swift index d3bf385..50b0bbb 100644 --- a/Sources/TokenClock/Services/AiderUsageService.swift +++ b/Sources/TokenClock/Services/AiderUsageService.swift @@ -5,6 +5,7 @@ import Foundation /// 数据位置: 用户指定路径,默认检测 ~/.aider/analytics.jsonl /// 格式: JSONL,每行含 event 和 properties.prompt_tokens / properties.completion_tokens / properties.cost final class AiderUsageService: @unchecked Sendable { + private static let messageLineNeedle = [Data("\"message_send\"".utf8)] private(set) var dailyData: [String: DayUsage] = [:] private(set) var hourlyData: [String: HourlyUsage] = [:] private var recentEntries: [RecentEntry] = [] @@ -13,8 +14,8 @@ final class AiderUsageService: @unchecked Sendable { private let fm = FileManager.default private let aiderHome: String - init() { - aiderHome = PathConfig.aiderHome() + init(aiderHome: String? = nil) { + self.aiderHome = aiderHome ?? PathConfig.aiderHome() } func fullScan() { @@ -57,7 +58,11 @@ final class AiderUsageService: @unchecked Sendable { private func scanAnalyticsFile() { let path = analyticsPath var isFile: ObjCBool = false - guard fm.fileExists(atPath: path, isDirectory: &isFile), !isFile.boolValue else { return } + guard fm.fileExists(atPath: path, isDirectory: &isFile), !isFile.boolValue else { + dailyData.removeAll(); hourlyData.removeAll(); recentEntries = [] + lastScanTime = .distantPast + return + } guard let attrs = try? fm.attributesOfItem(atPath: path), let modDate = attrs[.modificationDate] as? Date, @@ -66,35 +71,22 @@ final class AiderUsageService: @unchecked Sendable { // 文件有修改,重读 dailyData.removeAll(); hourlyData.removeAll(); recentEntries = [] - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 else { continue } + guard total > 0 else { return } // Aider 用 Unix timestamp(秒) let time = obj["time"] as? Double ?? 0 @@ -110,15 +102,15 @@ final class AiderUsageService: @unchecked Sendable { if dateKey == today { recentEntries.append(RecentEntry(timestamp: date, tokens: total)) - // L4: 限制 recentEntries 增长,只保留 active 窗口 3 倍内的条目 - if recentEntries.count > 64 { - let cutoff = Date().addingTimeInterval(-AppConfig.Scan.activeThresholdSeconds * 3) - recentEntries = recentEntries.filter { $0.timestamp >= cutoff } - } } } - } + ) != nil else { return } + // 单文件读取完后再修剪一次;逐行过滤会让繁忙日志退化为 O(n²)。 + if recentEntries.count > 64 { + let cutoff = Date().addingTimeInterval(-AppConfig.Scan.activeThresholdSeconds * 3) + recentEntries = recentEntries.filter { $0.timestamp >= cutoff } + } lastScanTime = Date() } diff --git a/Sources/TokenClock/Services/ClaudeCodeUsageService.swift b/Sources/TokenClock/Services/ClaudeCodeUsageService.swift index 7ef440c..ed0d7dc 100644 --- a/Sources/TokenClock/Services/ClaudeCodeUsageService.swift +++ b/Sources/TokenClock/Services/ClaudeCodeUsageService.swift @@ -3,6 +3,7 @@ import Foundation /// 从 Claude Code 本地 JSONL 日志读取 token 使用数据 /// 日志位置: ~/.claude/projects/*/ final class ClaudeCodeUsageService: @unchecked Sendable { + private static let assistantLineNeedle = [Data("\"assistant\"".utf8)] private(set) var dailyData: [String: DayUsage] = [:] private(set) var hourlyData: [String: HourlyUsage] = [:] private(set) var dailyCache: [String: Int] = [:] @@ -11,13 +12,19 @@ final class ClaudeCodeUsageService: @unchecked Sendable { private var fileHourlyContrib: [String: [String: HourlyUsage]] = [:] private var fileCacheContrib: [String: [String: Int]] = [:] private var fileRecentContrib: [String: [RecentEntry]] = [:] + private var fileLastModel: [String: [String: String]] = [:] + /// Nested project logs historically participate in session detail only, not + /// the tool total. Keep that behavior while avoiding recursive re-parsing. + private var nestedFileCache: [String: FileMeta] = [:] + private var nestedDailyContrib: [String: [String: DayUsage]] = [:] + private var nestedLastModel: [String: [String: String]] = [:] private var recentEntries: [RecentEntry] = [] private let fm = FileManager.default private let claudeHome: String - init() { - claudeHome = PathConfig.claudeCodeHome() + init(claudeHome: String? = nil) { + self.claudeHome = claudeHome ?? PathConfig.claudeCodeHome() } func fullScan() { @@ -29,6 +36,10 @@ final class ClaudeCodeUsageService: @unchecked Sendable { fileHourlyContrib.removeAll() fileCacheContrib.removeAll() fileRecentContrib.removeAll() + fileLastModel.removeAll() + nestedFileCache.removeAll() + nestedDailyContrib.removeAll() + nestedLastModel.removeAll() recentEntries = [] scanProjectsDir() } @@ -65,38 +76,75 @@ final class ClaudeCodeUsageService: @unchecked Sendable { private func scanProjectsDir() { let projectsDir = claudeHome + "/projects" - guard let projects = try? fm.contentsOfDirectory(atPath: projectsDir) else { return } + guard let projects = try? fm.contentsOfDirectory(atPath: projectsDir) else { + evictTopLevelFiles(notIn: []) + nestedFileCache.removeAll() + nestedDailyContrib.removeAll() + nestedLastModel.removeAll() + rebuildRecentEntries() + return + } + + var liveTopLevelPaths = Set() + var liveNestedPaths = Set() for project in projects { let projectPath = projectsDir + "/" + project var isDir: ObjCBool = false guard fm.fileExists(atPath: projectPath, isDirectory: &isDir), isDir.boolValue else { continue } - guard let contents = try? fm.contentsOfDirectory(atPath: projectPath) else { continue } - - for file in contents { - guard file.hasSuffix(".jsonl") else { continue } - let fullPath = projectPath + "/" + file - var fIsDir: ObjCBool = false - guard fm.fileExists(atPath: fullPath, isDirectory: &fIsDir), !fIsDir.boolValue else { continue } - + let projectURL = URL(fileURLWithPath: projectPath, isDirectory: true) + guard let enumerator = fm.enumerator( + at: projectURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for case let url as URL in enumerator where url.pathExtension == "jsonl" { + let fullPath = url.path guard let attrs = try? fm.attributesOfItem(atPath: fullPath), let modDate = attrs[.modificationDate] as? Date else { continue } - let cached = fileCache[fullPath] - if cached != nil && cached?.modDate == modDate { continue } - - // 撤销旧贡献 - if let old = fileDailyContrib[fullPath] { subtractDaily(old) } - if let old = fileHourlyContrib[fullPath] { subtractHourly(old) } - if let old = fileCacheContrib[fullPath] { subtractCache(old) } - - parseFile(path: fullPath) - fileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) + if url.deletingLastPathComponent().standardizedFileURL.path + == projectURL.standardizedFileURL.path { + liveTopLevelPaths.insert(fullPath) + if fileCache[fullPath]?.modDate == modDate { continue } + + if let old = fileDailyContrib[fullPath] { subtractDaily(old) } + if let old = fileHourlyContrib[fullPath] { subtractHourly(old) } + if let old = fileCacheContrib[fullPath] { subtractCache(old) } + + parseFile(path: fullPath) + fileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) + } else { + liveNestedPaths.insert(fullPath) + if nestedFileCache[fullPath]?.modDate == modDate { continue } + parseNestedDetailFile(path: fullPath) + nestedFileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) + } } } + evictTopLevelFiles(notIn: liveTopLevelPaths) + nestedFileCache = nestedFileCache.filter { liveNestedPaths.contains($0.key) } + nestedDailyContrib = nestedDailyContrib.filter { liveNestedPaths.contains($0.key) } + nestedLastModel = nestedLastModel.filter { liveNestedPaths.contains($0.key) } rebuildRecentEntries() } + private func evictTopLevelFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDaily(old) } + if let old = fileHourlyContrib[path] { subtractHourly(old) } + if let old = fileCacheContrib[path] { subtractCache(old) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileCacheContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) + fileLastModel.removeValue(forKey: path) + } + } + private func subtractDaily(_ contrib: [String: DayUsage]) { for (k, u) in contrib { if var e = dailyData[k] { @@ -128,44 +176,35 @@ final class ClaudeCodeUsageService: @unchecked Sendable { } private func parseFile(path: String) { - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() var newDaily: [String: DayUsage] = [:] var newHourly: [String: HourlyUsage] = [:] var newCache: [String: Int] = [:] var newRecent: [RecentEntry] = [] - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex..() var results: [SessionInfo] = [] @@ -244,7 +313,10 @@ final class ClaudeCodeUsageService: @unchecked Sendable { guard seen.insert(sessionId).inserted else { continue } - let (tokens, messages, model) = findSessionTokens(sessionId: sessionId, today: today) + let usage = cachedUsage[sessionId] + let tokens = usage?.tokens ?? 0 + let messages = usage?.messages ?? 0 + let model = usage?.model guard tokens > 0 || messages > 0 else { continue } let displayId = SessionIdDisplay.format(sessionId) @@ -265,93 +337,46 @@ final class ClaudeCodeUsageService: @unchecked Sendable { return results.sorted { $0.todayTokens > $1.todayTokens } } - /// 在 projects 目录中递归查找指定 sessionId 的 .jsonl 文件,并计算今日 token - private func findSessionTokens(sessionId: String, today: String) -> (tokens: Int, messages: Int, model: String?) { - let projectsDir = claudeHome + "/projects" - var isDir: ObjCBool = false - guard fm.fileExists(atPath: projectsDir, isDirectory: &isDir), isDir.boolValue else { return (0, 0, nil) } - - // session 文件名就是 sessionId + ".jsonl" - let targetFile = sessionId + ".jsonl" - - // 递归查找 - guard let projects = try? fm.contentsOfDirectory(atPath: projectsDir) else { return (0, 0, nil) } - for project in projects { - let projectPath = projectsDir + "/" + project - var pIsDir: ObjCBool = false - guard fm.fileExists(atPath: projectPath, isDirectory: &pIsDir), pIsDir.boolValue else { continue } - - // 检查当前目录 - let filePath = projectPath + "/" + targetFile - var fIsDir: ObjCBool = false - if fm.fileExists(atPath: filePath, isDirectory: &fIsDir), !fIsDir.boolValue { - return parseSessionFileToday(path: filePath, today: today) - } - - // 深度递归(Claude projects 可能有子目录) - if let found = findSessionFileRecursive(dir: projectPath, targetFile: targetFile, today: today) { - return found - } - } - return (0, 0, nil) - } - - private func findSessionFileRecursive(dir: String, targetFile: String, today: String) -> (tokens: Int, messages: Int, model: String?)? { - guard let contents = try? fm.contentsOfDirectory(atPath: dir) else { return nil } - for item in contents { - let fullPath = dir + "/" + item - var isDir: ObjCBool = false - guard fm.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } - if isDir.boolValue { - if let found = findSessionFileRecursive(dir: fullPath, targetFile: targetFile, today: today) { - return found - } - } else if item == targetFile { - return parseSessionFileToday(path: fullPath, today: today) - } - } - return nil - } - - /// 解析单个 session 文件的今日数据(复用现有解析逻辑) - private func parseSessionFileToday(path: String, today: String) -> (tokens: Int, messages: Int, model: String?) { - guard let stream = InputStream(fileAtPath: path) else { return (0, 0, nil) } - stream.open() - defer { stream.close() } - - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - var tokens = 0, messages = 0 - // 取该 session 出现过的模型(最后一条 assistant turn 的 message.model) + private struct CachedSessionUsage { + var tokens = 0 + var messages = 0 var model: String? + } - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. [String: CachedSessionUsage] { + var result: [String: CachedSessionUsage] = [:] + + // Top-level files have always been scanned for tool totals; reuse those + // contributions first so the detail result retains the old lookup order. + for (path, dates) in fileDailyContrib { + guard let usage = dates[dateKey] else { continue } + let sessionId = sessionId(fromJSONLPath: path) + guard !sessionId.isEmpty, result[sessionId] == nil else { continue } + result[sessionId] = CachedSessionUsage( + tokens: usage.tokens, + messages: usage.messages, + model: fileLastModel[path]?[dateKey] + ) } - if !lineBuf.isEmpty, - let line = String(data: lineBuf, encoding: .utf8), - line.contains("\"assistant\""), let r = parseLine(line), r.dateKey == today { - tokens += r.tokens - messages += 1 - if let m = r.model { model = m } + // Nested files remain detail-only, matching the baseline aggregation + // boundary while preserving recursive session discovery. + for (path, dates) in nestedDailyContrib { + guard let usage = dates[dateKey] else { continue } + let sessionId = sessionId(fromJSONLPath: path) + guard !sessionId.isEmpty, result[sessionId] == nil else { continue } + result[sessionId] = CachedSessionUsage( + tokens: usage.tokens, + messages: usage.messages, + model: nestedLastModel[path]?[dateKey] + ) } + return result + } - return (tokens, messages, model) + private func sessionId(fromJSONLPath path: String) -> String { + let filename = (path as NSString).lastPathComponent + guard filename.hasSuffix(".jsonl") else { return "" } + return String(filename.dropLast(".jsonl".count)) } } diff --git a/Sources/TokenClock/Services/CodexQuotaService.swift b/Sources/TokenClock/Services/CodexQuotaService.swift new file mode 100644 index 0000000..706e3cc --- /dev/null +++ b/Sources/TokenClock/Services/CodexQuotaService.swift @@ -0,0 +1,432 @@ +import Foundation + +enum CodexQuotaSource: String, Sendable { + case appServer + case sessionLog +} + +enum CodexQuotaStatus: String, Sendable { + case idle + case loading + case available + case unavailable +} + +struct CodexQuotaBucket: Identifiable, Equatable, Sendable { + let id: String + let name: String + let usedPercent: Double + let windowMinutes: Int + let resetsAt: Date? + + var remainingPercent: Double { + min(100, max(0, 100 - usedPercent)) + } +} + +struct CodexQuotaSnapshot: Equatable, Sendable { + var status: CodexQuotaStatus + var buckets: [CodexQuotaBucket] + var planType: String? + var creditBalance: String? + var hasUnlimitedCredits: Bool + var resetCreditCount: Int + var refreshedAt: Date? + var source: CodexQuotaSource? + var message: String? + + static let idle = CodexQuotaSnapshot( + status: .idle, + buckets: [], + planType: nil, + creditBalance: nil, + hasUnlimitedCredits: false, + resetCreditCount: 0, + refreshedAt: nil, + source: nil, + message: nil + ) + + static func loading(previous: CodexQuotaSnapshot) -> CodexQuotaSnapshot { + var value = previous + value.status = .loading + value.message = nil + return value + } + + static func unavailable(_ message: String) -> CodexQuotaSnapshot { + var value = Self.idle + value.status = .unavailable + value.message = message + value.refreshedAt = Date() + return value + } + + var isStale: Bool { + guard let refreshedAt else { return true } + return Date().timeIntervalSince(refreshedAt) >= AppConfig.Scan.codexQuotaCacheSeconds + } +} + +/// Reads Codex rate-limit windows without touching auth files. +/// +/// The primary path is the official Codex app-server JSON-RPC method +/// `account/rateLimits/read`. A bounded tail read of recent rollout logs is kept as +/// an offline fallback for older CLI versions. The service is deliberately invoked +/// on demand from the detail panel; it never installs a timer or leaves app-server +/// running after the response/timeout. +final class CodexQuotaService: @unchecked Sendable { + enum HostPlatform: Equatable { + case macOS + case windows + case unix + + static var current: Self { + #if os(macOS) + return .macOS + #elseif os(Windows) + return .windows + #else + return .unix + #endif + } + } + + private final class ResponseBuffer: @unchecked Sendable { + private let lock = NSLock() + private var pending = Data() + private var responseLine: Data? + private var didSignal = false + + func append(_ data: Data) -> Bool { + lock.lock() + defer { lock.unlock() } + guard responseLine == nil else { return false } + pending.append(data) + while let newline = pending.firstIndex(of: 0x0A) { + let line = Data(pending[.. Data? { + lock.lock() + defer { lock.unlock() } + return responseLine + } + } + + private let fileManager = FileManager.default + private let codexHome: String + + init(codexHome: String? = nil) { + self.codexHome = codexHome ?? Self.defaultCodexHome() + } + + private static func defaultCodexHome() -> String { + return PathConfig.codexHome() + } + + func fetch() -> CodexQuotaSnapshot { + if let executable = codexExecutable(), + let response = fetchFromAppServer(executable: executable), + let snapshot = Self.decodeAppServerResponse(response) { + return snapshot + } + if let snapshot = fetchFromRecentSessionLogs() { + return snapshot + } + return .unavailable("Codex rate-limit data is not available. Make sure Codex is installed and signed in.") + } + + private func codexExecutable() -> URL? { + let candidates = Self.executableCandidatePaths( + environment: ProcessInfo.processInfo.environment, + platform: .current, + homeDirectory: NSHomeDirectory() + ) + + var seen = Set() + for rawPath in candidates { + let path = (rawPath as NSString).expandingTildeInPath + guard seen.insert(path).inserted, + fileManager.isExecutableFile(atPath: path) else { continue } + return URL(fileURLWithPath: path) + } + return nil + } + + /// Builds host-specific executable candidates without probing the filesystem. + /// Kept deterministic so Windows/Linux packaging can regression-test path rules + /// while sharing the quota parser and service implementation. + static func executableCandidatePaths( + environment: [String: String], + platform: HostPlatform, + homeDirectory: String + ) -> [String] { + var candidates: [String] = [] + if let override = environment["CODEX_BINARY"], !override.isEmpty { + candidates.append((override as NSString).expandingTildeInPath) + } + + switch platform { + case .macOS: + // Prefer the native binary bundled with ChatGPT. GUI apps commonly do + // not inherit Homebrew's PATH, while npm wrappers depend on finding node. + candidates += [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + ] + case .windows: + if let localAppData = environment["LOCALAPPDATA"], !localAppData.isEmpty { + candidates += [ + localAppData + "\\Programs\\OpenAI\\Codex\\bin\\codex.exe", + localAppData + "\\OpenAI\\Codex\\bin\\codex.exe", + ] + } + if let appData = environment["APPDATA"], !appData.isEmpty { + let npmRoot = appData + "\\npm\\node_modules\\@openai\\codex\\node_modules" + candidates += [ + npmRoot + "\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\codex\\codex.exe", + npmRoot + "\\@openai\\codex-win32-arm64\\vendor\\aarch64-pc-windows-msvc\\codex\\codex.exe", + ] + } + case .unix: + candidates += [ + homeDirectory + "/.local/bin/codex", + "/usr/local/bin/codex", + "/usr/bin/codex", + ] + } + + let path = platform == .windows + ? (environment["Path"] ?? environment["PATH"]) + : environment["PATH"] + if let path, !path.isEmpty { + let separator: Character = platform == .windows ? ";" : ":" + let executable = platform == .windows ? "codex.exe" : "codex" + let slash = platform == .windows ? "\\" : "/" + candidates += path.split(separator: separator).map { + String($0).trimmingCharacters(in: .whitespacesAndNewlines) + slash + executable + } + } + return candidates + } + + private func fetchFromAppServer(executable: URL) -> Data? { + let process = Process() + let input = Pipe() + let output = Pipe() + let errors = Pipe() + let responseBuffer = ResponseBuffer() + let responseReady = DispatchSemaphore(value: 0) + + process.executableURL = executable + process.arguments = ["app-server"] + process.standardInput = input + process.standardOutput = output + process.standardError = errors + + output.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty, responseBuffer.append(data) { + responseReady.signal() + } + } + + do { + try process.run() + } catch { + output.fileHandleForReading.readabilityHandler = nil + return nil + } + + let requests: [[String: Any]] = [ + [ + "method": "initialize", + "id": 1, + "params": [ + "clientInfo": [ + "name": "tokenclock", + "title": "TokenClock", + "version": "1.0", + ], + ], + ], + ["method": "initialized", "params": [:]], + ["method": "account/rateLimits/read", "id": 2, "params": [:]], + ] + var requestData = Data() + for request in requests { + guard let line = try? JSONSerialization.data(withJSONObject: request) else { continue } + requestData.append(line) + requestData.append(0x0A) + } + do { + try input.fileHandleForWriting.write(contentsOf: requestData) + } catch { + stop(process: process, input: input, output: output, errors: errors) + return nil + } + + _ = responseReady.wait(timeout: .now() + AppConfig.Scan.codexQuotaTimeoutSeconds) + let response = responseBuffer.response() + stop(process: process, input: input, output: output, errors: errors) + return response + } + + private func stop(process: Process, input: Pipe, output: Pipe, errors: Pipe) { + output.fileHandleForReading.readabilityHandler = nil + try? input.fileHandleForWriting.close() + if process.isRunning { process.terminate() } + // Closing all pipe ends ensures a wedged CLI cannot retain descriptors in + // the widget after timeout. Do not wait synchronously for termination. + try? output.fileHandleForReading.close() + try? errors.fileHandleForReading.close() + } + + static func decodeAppServerResponse(_ data: Data, now: Date = Date()) -> CodexQuotaSnapshot? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["error"] == nil, + let result = object["result"] as? [String: Any] else { return nil } + + let byID = result["rateLimitsByLimitId"] as? [String: Any] + let main = result["rateLimits"] as? [String: Any] + var groups: [(String, [String: Any])] = [] + if let byID, !byID.isEmpty { + groups = byID.compactMap { key, value in + guard let dictionary = value as? [String: Any] else { return nil } + return (key, dictionary) + } + } else if let main { + groups = [((main["limitId"] as? String) ?? "codex", main)] + } + + var buckets: [CodexQuotaBucket] = [] + for (groupID, group) in groups { + let baseName = (group["limitName"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? (groupID == "codex" ? "Codex" : groupID) + for key in ["primary", "secondary"] { + guard let window = group[key] as? [String: Any], + let used = number(window["usedPercent"]), + let minutes = integer(window["windowDurationMins"]) else { continue } + let reset = number(window["resetsAt"]).map { Date(timeIntervalSince1970: $0) } + buckets.append(CodexQuotaBucket( + id: "\(groupID):\(key)", + name: baseName, + usedPercent: used, + windowMinutes: minutes, + resetsAt: reset + )) + } + } + guard !buckets.isEmpty else { return nil } + buckets.sort { + if $0.name == "Codex" && $1.name != "Codex" { return true } + if $0.name != "Codex" && $1.name == "Codex" { return false } + if $0.name == $1.name { return $0.windowMinutes > $1.windowMinutes } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + + let credits = main?["credits"] as? [String: Any] + let resetCredits = result["rateLimitResetCredits"] as? [String: Any] + return CodexQuotaSnapshot( + status: .available, + buckets: buckets, + planType: main?["planType"] as? String, + creditBalance: credits?["balance"] as? String, + hasUnlimitedCredits: credits?["unlimited"] as? Bool ?? false, + resetCreditCount: integer(resetCredits?["availableCount"]) ?? 0, + refreshedAt: now, + source: .appServer, + message: nil + ) + } + + private func fetchFromRecentSessionLogs() -> CodexQuotaSnapshot? { + let root = URL(fileURLWithPath: codexHome + "/sessions", isDirectory: true) + let keys: Set = [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey] + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { return nil } + + var candidates: [(URL, Date, Int)] = [] + for case let url as URL in enumerator { + guard url.pathExtension == "jsonl", + url.lastPathComponent.hasPrefix("rollout-"), + let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true else { continue } + candidates.append((url, values.contentModificationDate ?? .distantPast, values.fileSize ?? 0)) + } + candidates.sort { $0.1 > $1.1 } + + for (url, _, size) in candidates.prefix(AppConfig.Scan.codexQuotaFallbackFileLimit) { + guard let handle = FileHandle(forReadingAtPath: url.path) else { continue } + defer { try? handle.close() } + let tailSize = min(size, AppConfig.Scan.codexQuotaFallbackTailBytes) + try? handle.seek(toOffset: UInt64(max(0, size - tailSize))) + guard let data = try? handle.readToEnd(), !data.isEmpty else { continue } + let lines = data.split(separator: 0x0A, omittingEmptySubsequences: true) + for line in lines.reversed() where line.range(of: Data("\"rate_limits\"".utf8)) != nil { + guard let snapshot = Self.decodeSessionLogLine(Data(line)) else { continue } + return snapshot + } + } + return nil + } + + static func decodeSessionLogLine(_ data: Data) -> CodexQuotaSnapshot? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let payload = object["payload"] as? [String: Any], + let limits = payload["rate_limits"] as? [String: Any] else { return nil } + var buckets: [CodexQuotaBucket] = [] + for key in ["primary", "secondary"] { + guard let window = limits[key] as? [String: Any], + let used = number(window["used_percent"]), + let minutes = integer(window["window_minutes"]) else { continue } + let reset = number(window["resets_at"]).map { Date(timeIntervalSince1970: $0) } + buckets.append(CodexQuotaBucket( + id: "codex:\(key)", + name: "Codex", + usedPercent: used, + windowMinutes: minutes, + resetsAt: reset + )) + } + guard !buckets.isEmpty else { return nil } + let timestamp = (object["timestamp"] as? String).flatMap(DateHelper.parseISO8601) ?? Date() + let credits = limits["credits"] as? [String: Any] + return CodexQuotaSnapshot( + status: .available, + buckets: buckets.sorted { $0.windowMinutes > $1.windowMinutes }, + planType: limits["plan_type"] as? String, + creditBalance: credits?["balance"] as? String, + hasUnlimitedCredits: credits?["unlimited"] as? Bool ?? false, + resetCreditCount: 0, + refreshedAt: timestamp, + source: .sessionLog, + message: nil + ) + } + + private static func number(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } + + private static func integer(_ value: Any?) -> Int? { + number(value).map { Int($0) } + } +} diff --git a/Sources/TokenClock/Services/CodexUsageService.swift b/Sources/TokenClock/Services/CodexUsageService.swift index 08dd33c..a17bff0 100644 --- a/Sources/TokenClock/Services/CodexUsageService.swift +++ b/Sources/TokenClock/Services/CodexUsageService.swift @@ -13,6 +13,10 @@ import SQLite3 /// 缓存率从 last_token_usage.cached_input_tokens 计算 /// Session 列表从 SQLite threads 表读取 final class CodexUsageService: @unchecked Sendable { + private static let relevantLineNeedles = [ + Data("\"type\":\"token_count\"".utf8), + Data("\"type\":\"turn_context\"".utf8), + ] private(set) var dailyData: [String: DayUsage] = [:] private(set) var hourlyData: [String: HourlyUsage] = [:] private var dailyCache: [String: Int] = [:] @@ -23,46 +27,39 @@ final class CodexUsageService: @unchecked Sendable { /// Codex rollout 里模型由 turn_context 事件声明,同一 session 切换模型时取用量最大的。 private var sessionModels: [String: String] = [:] - /// 已扫描的 JSONL 文件路径及其统计结果(含文件大小用于变更检测) - private var jsonlCache: [String: (dateKey: String, count: Int, tokens: Int, cachedTokens: Int, fileSize: Int)] = [:] + /// 每个 rollout 的解析状态。增长中的文件只读取上次 EOF 之后的字节;截断、 + /// 原地替换或同尺寸改写时才重读单个文件。 + private struct JSONLFileState { + var fileSize: UInt64 = 0 + var modificationDate = Date.distantPast + var fileNumber: UInt64? + var trailingData = Data() + var currentModel: String? + var dailyTokens: [String: Int] = [:] + var dailyMessages: [String: Int] = [:] + var dailyCached: [String: Int] = [:] + var hourlyTokens: [String: Int] = [:] + var hourlyMessages: [String: Int] = [:] + var recentEntries: [RecentEntry] = [] + var modelTokens: [String: Int] = [:] + } + + private var jsonlCache: [String: JSONLFileState] = [:] private let fm = FileManager.default private let codexHome: String - init() { - codexHome = PathConfig.codexHome() + init(codexHome: String? = nil) { + self.codexHome = codexHome ?? PathConfig.codexHome() } func fullScan() { - dailyData.removeAll() - hourlyData.removeAll() - dailyCache.removeAll() - recentEntries = [] - sessionMessagesByDate = [:] - sessionTokensByDate = [:] - sessionModels = [:] jsonlCache = [:] scanJSONL() } func incrementalScan() { - // 检测文件变化:如有任何已缓存文件增长,触发全量重扫 - let needsRescan = checkForFileChanges() - if needsRescan { - fullScan() - } else { - scanJSONL() - } - } - - /// 检查已缓存的文件是否有增长 - private func checkForFileChanges() -> Bool { - for (path, cached) in jsonlCache { - let attrs = try? fm.attributesOfItem(atPath: path) - let size = (attrs?[.size] as? Int) ?? 0 - if size != cached.fileSize { return true } - } - return false + scanJSONL() } func todayUsage() -> (tokens: Int, messages: Int, cacheRate: Double) { @@ -96,141 +93,176 @@ final class CodexUsageService: @unchecked Sendable { private func scanJSONL() { let sessionsDir = codexHome + "/sessions" var isDir: ObjCBool = false - guard fm.fileExists(atPath: sessionsDir, isDirectory: &isDir), isDir.boolValue else { return } - scanJSONLDirRecursive(sessionsDir) - } + guard fm.fileExists(atPath: sessionsDir, isDirectory: &isDir), isDir.boolValue else { + jsonlCache.removeAll() + rebuildAggregates() + return + } - private func scanJSONLDirRecursive(_ dirPath: String) { - guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { return } - for item in contents { - let fullPath = dirPath + "/" + item - var isDir: ObjCBool = false - guard fm.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } - if isDir.boolValue { - scanJSONLDirRecursive(fullPath) - } else if item.hasPrefix("rollout-") && item.hasSuffix(".jsonl") { - guard jsonlCache[fullPath] == nil else { continue } - parseJSONLFile(path: fullPath) + let rootURL = URL(fileURLWithPath: sessionsDir, isDirectory: true) + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + let cutoff = calendar.date( + byAdding: .day, + value: -(AppConfig.Scan.codexSessionLookbackDays - 1), + to: startOfToday + ) ?? startOfToday + guard let enumerator = fm.enumerator( + at: rootURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return } + + var livePaths = Set() + for case let url as URL in enumerator { + let name = url.lastPathComponent + guard name.hasPrefix("rollout-"), name.hasSuffix(".jsonl") else { continue } + let path = url.path + guard let metadata = fileMetadata(at: path), + metadata.modificationDate >= cutoff else { continue } + livePaths.insert(path) + if var state = jsonlCache[path] { + let sameFile = state.fileNumber == nil || metadata.fileNumber == nil || state.fileNumber == metadata.fileNumber + if sameFile, metadata.size > state.fileSize { + appendJSONLFile(path: path, metadata: metadata, state: &state) + jsonlCache[path] = state + } else if sameFile, + metadata.size == state.fileSize, + metadata.modificationDate == state.modificationDate { + continue + } else { + jsonlCache[path] = parseJSONLFile(path: path, metadata: metadata) + } + } else { + jsonlCache[path] = parseJSONLFile(path: path, metadata: metadata) } } + + jsonlCache = jsonlCache.filter { livePaths.contains($0.key) } + rebuildAggregates() } - private func parseJSONLFile(path: String) { - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - var lastDateKey = "" - var lastTimestamp: Date? - var dailyTokens: [String: Int] = [:] - var dailyMsgs: [String: Int] = [:] - var dailyCached: [String: Int] = [:] - // 模型状态机:turn_context 事件声明本 turn 的模型,缓存到下一条 token_count。 - var currentModel: String? - // 模型 → token 用量,结尾取最大者作为该 session 的代表模型。 - var modelTokens: [String: Int] = [:] + private func parseJSONLFile(path: String, metadata: FileMetadata) -> JSONLFileState { + var state = JSONLFileState( + modificationDate: metadata.modificationDate, + fileNumber: metadata.fileNumber + ) + appendJSONLFile(path: path, metadata: metadata, state: &state) + return state + } - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 { - dailyTokens[dateKey, default: 0] += tokens - dailyMsgs[dateKey, default: 0] += 1 - dailyCached[dateKey, default: 0] += cached - if let m = modelForTurn { modelTokens[m, default: 0] += tokens } - } - } else if line.contains("\"type\":\"turn_context\"") { - // turn_context 声明本 turn 模型,缓存供后续 token_count 归因。 - if let m = codexModel(fromLine: line) { currentModel = m } - } - } - } + private func appendJSONLFile(path: String, metadata: FileMetadata, state: inout JSONLFileState) { + let oldOffset = state.fileSize + let recentCutoff = Date().addingTimeInterval(-AppConfig.Scan.oneDaySeconds) + guard let result = JSONLLineReader.read( + path: path, + from: oldOffset, + prefix: state.trailingData, + matchingAny: Self.relevantLineNeedles, + onLine: { line in processJSONLLine(line, recentCutoff: recentCutoff, state: &state) } + ) else { return } + + state.fileSize = result.offset + state.trailingData = JSONLLineReader.consumeCompleteTrailingLine( + result.trailingData, + onLine: { line in processJSONLLine(line, recentCutoff: recentCutoff, state: &state) } + ) + state.recentEntries.removeAll { $0.timestamp < recentCutoff } + state.modificationDate = metadata.modificationDate + state.fileNumber = metadata.fileNumber + } - // 尾部残留 - if !lineBuf.isEmpty, - let line = String(data: lineBuf, encoding: .utf8), - line.contains("\"type\":\"token_count\",\"info\""), - let ltuRange = line.range(of: "\"last_token_usage\":{") { + private func processJSONLLine( + _ line: String, + recentCutoff: Date, + state: inout JSONLFileState + ) { + if line.contains("\"type\":\"token_count\"") { + guard let ltuRange = line.range(of: "\"last_token_usage\":{") else { return } let segment = String(line[ltuRange.lowerBound...].prefix(500)) - let totalTokens = extractInt(from: segment, key: "\"total_tokens\"") - // total_tokens 已含 reasoning_output_tokens,不再重复累加 - let tokens = totalTokens - if tokens > 0, !lastDateKey.isEmpty { - dailyTokens[lastDateKey, default: 0] += tokens - dailyMsgs[lastDateKey, default: 0] += 1 - dailyCached[lastDateKey, default: 0] += extractInt(from: segment, key: "\"cached_input_tokens\"") - if let m = currentModel { modelTokens[m, default: 0] += tokens } + let tokens = extractInt(from: segment, key: "\"total_tokens\"") + let cached = extractInt(from: segment, key: "\"cached_input_tokens\"") + let contextWindow = extractInt(from: line, key: "\"model_context_window\"") + let modelForTurn = state.currentModel ?? (contextWindow == 258400 ? "gpt-5.5" : nil) + + guard let tsRange = line.range(of: "\"timestamp\":\"") else { return } + let start = tsRange.upperBound + guard let end = line[start...].firstIndex(of: "\"") else { return } + let timestamp = String(line[start.. 0 { + state.dailyTokens[dateKey, default: 0] += tokens + state.dailyMessages[dateKey, default: 0] += 1 + state.dailyCached[dateKey, default: 0] += cached + state.hourlyTokens[hourKey, default: 0] += tokens + state.hourlyMessages[hourKey, default: 0] += 1 + if date >= recentCutoff { + state.recentEntries.append(RecentEntry(timestamp: date, tokens: tokens)) + } + if let modelForTurn { + state.modelTokens[modelForTurn, default: 0] += tokens + } + } + } else if line.contains("\"type\":\"turn_context\"") { + if let model = codexModel(fromLine: line) { + state.currentModel = model } } + } - let totalTokens = dailyTokens.values.reduce(0, +) - let totalMsgs = dailyMsgs.values.reduce(0, +) - let totalCached = dailyCached.values.reduce(0, +) - jsonlCache[path] = (lastDateKey, totalMsgs, totalTokens, totalCached, fileSize(at: path)) - guard totalMsgs > 0, !lastDateKey.isEmpty else { return } - - // 按日期写入 dailyData - for (dateKey, tokens) in dailyTokens { - dailyData[dateKey, default: DayUsage(tokens: 0, messages: 0)].tokens += tokens - dailyData[dateKey]!.messages += dailyMsgs[dateKey] ?? 0 - dailyCache[dateKey, default: 0] += dailyCached[dateKey] ?? 0 - } - - if let ts = lastTimestamp { - recentEntries.append(RecentEntry(timestamp: ts, tokens: totalTokens)) - // L4: 限制 recentEntries 增长,只保留 active 窗口 3 倍内的条目 - if recentEntries.count > 64 { - let cutoff = Date().addingTimeInterval(-AppConfig.Scan.activeThresholdSeconds * 3) - recentEntries = recentEntries.filter { $0.timestamp >= cutoff } + private func rebuildAggregates() { + dailyData.removeAll(keepingCapacity: true) + hourlyData.removeAll(keepingCapacity: true) + dailyCache.removeAll(keepingCapacity: true) + recentEntries.removeAll(keepingCapacity: true) + sessionMessagesByDate.removeAll(keepingCapacity: true) + sessionTokensByDate.removeAll(keepingCapacity: true) + sessionModels.removeAll(keepingCapacity: true) + + for (path, state) in jsonlCache { + for (dateKey, tokens) in state.dailyTokens { + dailyData[dateKey, default: DayUsage(tokens: 0, messages: 0)].tokens += tokens + dailyData[dateKey]!.messages += state.dailyMessages[dateKey] ?? 0 + dailyCache[dateKey, default: 0] += state.dailyCached[dateKey] ?? 0 } - } - if let sessionId = sessionId(fromJSONLPath: path), !sessionId.isEmpty { - for (dateKey, tokens) in dailyTokens { - sessionMessagesByDate[dateKey, default: [:]][sessionId, default: 0] += dailyMsgs[dateKey] ?? 0 + for (hourKey, tokens) in state.hourlyTokens { + hourlyData[hourKey, default: HourlyUsage(tokens: 0, messages: 0)].tokens += tokens + hourlyData[hourKey]!.messages += state.hourlyMessages[hourKey] ?? 0 + } + recentEntries.append(contentsOf: state.recentEntries) + + guard let sessionId = sessionId(fromJSONLPath: path), !sessionId.isEmpty else { continue } + for (dateKey, tokens) in state.dailyTokens { + sessionMessagesByDate[dateKey, default: [:]][sessionId, default: 0] += state.dailyMessages[dateKey] ?? 0 sessionTokensByDate[dateKey, default: [:]][sessionId, default: 0] += tokens } - // 取用量最大的模型作为该 session 的代表模型 - if let dominant = modelTokens.max(by: { $0.value < $1.value })?.key { + if let dominant = state.modelTokens.max(by: { $0.value < $1.value })?.key { sessionModels[sessionId] = dominant } } + + recentEntries.sort { $0.timestamp < $1.timestamp } + } + + private struct FileMetadata { + let size: UInt64 + let modificationDate: Date + let fileNumber: UInt64? + } + + private func fileMetadata(at path: String) -> FileMetadata? { + guard let attrs = try? fm.attributesOfItem(atPath: path), + let size = (attrs[.size] as? NSNumber)?.uint64Value, + let modificationDate = attrs[.modificationDate] as? Date else { return nil } + return FileMetadata( + size: size, + modificationDate: modificationDate, + fileNumber: (attrs[.systemFileNumber] as? NSNumber)?.uint64Value + ) } private func sessionId(fromJSONLPath path: String) -> String? { @@ -269,7 +301,7 @@ final class CodexUsageService: @unchecked Sendable { return nil } -private func extractInt(from str: String, key: String) -> Int { + private func extractInt(from str: String, key: String) -> Int { guard let range = str.range(of: key) else { return 0 } let after = str[range.upperBound...] // 跳过可能的冒号和空格 @@ -378,7 +410,4 @@ private func extractInt(from str: String, key: String) -> Int { return nil } - private func fileSize(at path: String) -> Int { - (try? fm.attributesOfItem(atPath: path)[FileAttributeKey.size] as? Int) ?? 0 - } } diff --git a/Sources/TokenClock/Services/ContinueUsageService.swift b/Sources/TokenClock/Services/ContinueUsageService.swift index 3d7cb57..defc051 100644 --- a/Sources/TokenClock/Services/ContinueUsageService.swift +++ b/Sources/TokenClock/Services/ContinueUsageService.swift @@ -17,8 +17,8 @@ final class ContinueUsageService: @unchecked Sendable { private let fm = FileManager.default private let continueHome: String - init() { - continueHome = PathConfig.continueHome() + init(continueHome: String? = nil) { + self.continueHome = continueHome ?? PathConfig.continueHome() } func fullScan() { @@ -58,18 +58,34 @@ final class ContinueUsageService: @unchecked Sendable { private func scanAllDirs() { // dev_data 和 sessions 两个目录都扫 + var livePaths = Set() for sub in ["dev_data", "sessions"] { let dir = continueHome + "/" + sub var isDir: ObjCBool = false guard fm.fileExists(atPath: dir, isDirectory: &isDir), isDir.boolValue else { continue } guard let files = try? fm.contentsOfDirectory(atPath: dir) else { continue } for file in files where file.hasSuffix(".jsonl") { - processJSONL(dir + "/" + file) + let path = dir + "/" + file + livePaths.insert(path) + processJSONL(path) } } + evictFiles(notIn: livePaths) rebuildRecentEntries() } + private func evictFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDay(old, from: &dailyData) } + if let old = fileHourlyContrib[path] { subtractHour(old, from: &hourlyData) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) + } + } + private func processJSONL(_ path: String) { guard let attrs = try? fm.attributesOfItem(atPath: path), let modDate = attrs[.modificationDate] as? Date else { return } @@ -80,34 +96,18 @@ final class ContinueUsageService: @unchecked Sendable { if let old = fileDailyContrib[path] { subtractDay(old, from: &dailyData) } if let old = fileHourlyContrib[path] { subtractHour(old, from: &hourlyData) } - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() var newDaily: [String: DayUsage] = [:] var newHourly: [String: HourlyUsage] = [:] var newRecent: [RecentEntry] = [] - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex...allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 else { continue } results.append(SessionInfo( rawId: file, displayName: SessionIdDisplay.format(String(file.dropLast(".jsonl".count))), detail: nil, diff --git a/Sources/TokenClock/Services/CopilotUsageService.swift b/Sources/TokenClock/Services/CopilotUsageService.swift index af7a774..f622b84 100644 --- a/Sources/TokenClock/Services/CopilotUsageService.swift +++ b/Sources/TokenClock/Services/CopilotUsageService.swift @@ -18,8 +18,8 @@ final class CopilotUsageService: @unchecked Sendable { private let fm = FileManager.default private let copilotHome: String - init() { - copilotHome = PathConfig.copilotHome() + init(copilotHome: String? = nil) { + self.copilotHome = copilotHome ?? PathConfig.copilotHome() } func fullScan() { @@ -28,13 +28,11 @@ final class CopilotUsageService: @unchecked Sendable { fileHourlyContrib.removeAll(); fileCacheContrib.removeAll() fileRecentContrib.removeAll() recentEntries = [] - scanOtelDir() - scanSessionStateDir() + scanAllFiles() } func incrementalScan() { - scanOtelDir() - scanSessionStateDir() + scanAllFiles() } func todayUsage() -> (tokens: Int, messages: Int, cacheRate: Double) { @@ -63,34 +61,53 @@ final class CopilotUsageService: @unchecked Sendable { return recentEntries.contains { $0.timestamp >= cutoff } } - // MARK: - OTel JSONL 扫描 - - private func scanOtelDir() { - let otelDir = copilotHome + "/otel" + private func scanAllFiles() { + var livePaths = Set() var isDir: ObjCBool = false - guard fm.fileExists(atPath: otelDir, isDirectory: &isDir), isDir.boolValue else { return } - guard let files = try? fm.contentsOfDirectory(atPath: otelDir) else { return } +#if os(Linux) + if let directFile = PathConfig.copilotOtelFile(), + fm.fileExists(atPath: directFile, isDirectory: &isDir), !isDir.boolValue { + livePaths.insert(directFile) + processJSONLFile(directFile, parser: parseOtelEvent) + } +#endif + let otelDir = copilotHome + "/otel" + if fm.fileExists(atPath: otelDir, isDirectory: &isDir), isDir.boolValue, + let files = try? fm.contentsOfDirectory(atPath: otelDir) { + for file in files where file.hasSuffix(".jsonl") { + let path = otelDir + "/" + file + livePaths.insert(path) + processJSONLFile(path, parser: parseOtelEvent) + } + } - for file in files where file.hasSuffix(".jsonl") { - let fullPath = otelDir + "/" + file - processJSONLFile(fullPath, parser: parseOtelEvent) + let stateDir = copilotHome + "/session-state" + if fm.fileExists(atPath: stateDir, isDirectory: &isDir), isDir.boolValue, + let sessionDirs = try? fm.contentsOfDirectory(atPath: stateDir) { + for session in sessionDirs { + let path = stateDir + "/" + session + "/events.jsonl" + var isFile: ObjCBool = false + guard fm.fileExists(atPath: path, isDirectory: &isFile), !isFile.boolValue else { continue } + livePaths.insert(path) + processJSONLFile(path, parser: parseSessionEvent) + } } + evictFiles(notIn: livePaths) rebuildRecentEntries() } - // MARK: - Session events 扫描 - - private func scanSessionStateDir() { - let stateDir = copilotHome + "/session-state" - var isDir: ObjCBool = false - guard fm.fileExists(atPath: stateDir, isDirectory: &isDir), isDir.boolValue else { return } - guard let sessionDirs = try? fm.contentsOfDirectory(atPath: stateDir) else { return } - - for session in sessionDirs { - let eventsPath = stateDir + "/" + session + "/events.jsonl" - processJSONLFile(eventsPath, parser: parseSessionEvent) + private func evictFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDay(old, from: &dailyData) } + if let old = fileHourlyContrib[path] { subtractHour(old, from: &hourlyData) } + if let old = fileCacheContrib[path] { subtractCache(old) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileCacheContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) } - rebuildRecentEntries() } // MARK: - 通用 JSONL 文件处理 @@ -111,30 +128,15 @@ final class CopilotUsageService: @unchecked Sendable { var newCache: [String: Int] = [:] var newRecent: [RecentEntry] = [] - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() let today = DateHelper.todayKey() - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex...allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 else { continue } results.append(SessionInfo( diff --git a/Sources/TokenClock/Services/CursorAgentUsageService.swift b/Sources/TokenClock/Services/CursorAgentUsageService.swift index 3679199..ae3bcc3 100644 --- a/Sources/TokenClock/Services/CursorAgentUsageService.swift +++ b/Sources/TokenClock/Services/CursorAgentUsageService.swift @@ -118,7 +118,22 @@ final class CursorAgentUsageService: @unchecked Sendable { /// Cursor IDE 的 state.vscdb 路径(macOS: Library,Linux: XDG config) private func cursorStateDbPath() -> String { +#if os(Linux) + let configured = PathConfig.cursorAgentHome() + let candidates: [String] + if configured.hasSuffix(".vscdb") { + candidates = [configured] + } else { + candidates = [ + configured + "/state.vscdb", + configured + "/User/globalStorage/state.vscdb", + ] + } + return candidates.first(where: { FileManager.default.isReadableFile(atPath: $0) }) + ?? candidates[0] +#else AppPaths.appSupport("Cursor", "User", "globalStorage", "state.vscdb") +#endif } /// 从 token 提取 userId diff --git a/Sources/TokenClock/Services/GeminiUsageService.swift b/Sources/TokenClock/Services/GeminiUsageService.swift index 950578c..0d6ac16 100644 --- a/Sources/TokenClock/Services/GeminiUsageService.swift +++ b/Sources/TokenClock/Services/GeminiUsageService.swift @@ -5,6 +5,10 @@ import Foundation /// - JSON 格式: ~/.gemini/tmp/*/chats/session-*.json (旧版) /// - JSONL 格式: ~/.gemini/tmp/*/chats/session-*.jsonl (新版,优先) final class GeminiUsageService: @unchecked Sendable { + private static let jsonlLineNeedles = [ + Data("\"gemini\"".utf8), + Data("\"sessionId\"".utf8), + ] private(set) var dailyData: [String: DayUsage] = [:] private(set) var hourlyData: [String: HourlyUsage] = [:] private(set) var dailyCache: [String: Int] = [:] @@ -13,13 +17,15 @@ final class GeminiUsageService: @unchecked Sendable { private var fileHourlyContrib: [String: [String: HourlyUsage]] = [:] private var fileCacheContrib: [String: [String: Int]] = [:] private var fileRecentContrib: [String: [RecentEntry]] = [:] + private var fileLastModel: [String: [String: String]] = [:] + private var fileSessionId: [String: String] = [:] private var recentEntries: [RecentEntry] = [] private let fm = FileManager.default private let geminiHome: String - init() { - geminiHome = PathConfig.geminiHome() + init(geminiHome: String? = nil) { + self.geminiHome = geminiHome ?? PathConfig.geminiHome() } func fullScan() { @@ -31,6 +37,8 @@ final class GeminiUsageService: @unchecked Sendable { fileHourlyContrib.removeAll() fileCacheContrib.removeAll() fileRecentContrib.removeAll() + fileLastModel.removeAll() + fileSessionId.removeAll() recentEntries = [] scanSessionsDir() } @@ -67,7 +75,20 @@ final class GeminiUsageService: @unchecked Sendable { private func scanSessionsDir() { let tmpDir = geminiHome + "/tmp" - guard let projectDirs = try? fm.contentsOfDirectory(atPath: tmpDir) else { return } + guard let projectDirs = try? fm.contentsOfDirectory(atPath: tmpDir) else { + evictFiles(notIn: []) + rebuildRecentEntries() + return + } + + var livePaths = Set() + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + let cutoff = calendar.date( + byAdding: .day, + value: -(AppConfig.Scan.geminiSessionLookbackDays - 1), + to: startOfToday + ) ?? startOfToday for project in projectDirs { let chatsDir = tmpDir + "/" + project + "/chats" @@ -94,9 +115,10 @@ final class GeminiUsageService: @unchecked Sendable { } let fullPath = chatsDir + "/" + file - guard let attrs = try? fm.attributesOfItem(atPath: fullPath), - let modDate = attrs[.modificationDate] as? Date else { continue } + let modDate = attrs[.modificationDate] as? Date, + modDate >= cutoff else { continue } + livePaths.insert(fullPath) let cached = fileCache[fullPath] if cached != nil && cached?.modDate == modDate { continue } @@ -113,9 +135,26 @@ final class GeminiUsageService: @unchecked Sendable { fileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) } } + evictFiles(notIn: livePaths) rebuildRecentEntries() } + private func evictFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDaily(old) } + if let old = fileHourlyContrib[path] { subtractHourly(old) } + if let old = fileCacheContrib[path] { subtractCache(old) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileCacheContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) + fileLastModel.removeValue(forKey: path) + fileSessionId.removeValue(forKey: path) + } + } + private func subtractDaily(_ contrib: [String: DayUsage]) { for (k, u) in contrib { if var e = dailyData[k] { @@ -158,66 +197,58 @@ final class GeminiUsageService: @unchecked Sendable { var newHourly: [String: HourlyUsage] = [:] var newCache: [String: Int] = [:] var newRecent: [RecentEntry] = [] + var newLastModels: [String: String] = [:] for msg in messages { guard let result = parseGeminiEvent(msg) else { continue } accumulateResult(result, today: today, - daily: &newDaily, hourly: &newHourly, cache: &newCache, recent: &newRecent) + daily: &newDaily, hourly: &newHourly, cache: &newCache, + recent: &newRecent, lastModels: &newLastModels) } fileDailyContrib[path] = newDaily fileHourlyContrib[path] = newHourly fileCacheContrib[path] = newCache fileRecentContrib[path] = newRecent + fileLastModel[path] = newLastModels + fileSessionId[path] = (obj["sessionId"] as? String) ?? (obj["id"] as? String) ?? "" } // MARK: - JSONL 格式解析(新版) private func parseSessionFileJSONL(path: String) { - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() var newDaily: [String: DayUsage] = [:] var newHourly: [String: HourlyUsage] = [:] var newCache: [String: Int] = [:] var newRecent: [RecentEntry] = [] + var newLastModels: [String: String] = [:] + var sessionId = "" - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 else { continue } let displayId = sessionId.isEmpty ? SessionIdDisplay.format(String(file.dropFirst("session-".count))) : SessionIdDisplay.format(sessionId) @@ -351,78 +385,4 @@ final class GeminiUsageService: @unchecked Sendable { return results.sorted { $0.todayTokens > $1.todayTokens } } - // MARK: - Session 详情解析 - - private func parseSessionFileJSONToday(path: String, today: String) -> (tokens: Int, messages: Int, sessionId: String, model: String?) { - guard let data = fm.contents(atPath: path), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (0, 0, "", nil) } - - let sessionId = (obj["sessionId"] as? String) ?? (obj["id"] as? String) ?? "" - guard let messages = obj["messages"] as? [[String: Any]] else { return (0, 0, sessionId, nil) } - - var totalTokens = 0 - var msgCount = 0 - var model: String? - - for msg in messages { - guard let result = parseGeminiEvent(msg), result.dateKey == today else { continue } - totalTokens += result.tokens - msgCount += 1 - if let m = result.model { model = m } - } - - return (totalTokens, msgCount, sessionId, model) - } - - private func parseSessionFileJSONLToday(path: String, today: String) -> (tokens: Int, messages: Int, sessionId: String, model: String?) { - guard let stream = InputStream(fileAtPath: path) else { return (0, 0, "", nil) } - stream.open() - defer { stream.close() } - - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - var totalTokens = 0 - var msgCount = 0 - var sessionId = "" - var model: String? - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex..() for dir1 in level1 { let path1 = sessionsDir + "/" + dir1 @@ -71,6 +78,7 @@ final class GrokUsageService: @unchecked Sendable { let updatesPath = path1 + "/" + dir2 + "/updates.jsonl" var isFile: ObjCBool = false guard fm.fileExists(atPath: updatesPath, isDirectory: &isFile), !isFile.boolValue else { continue } + livePaths.insert(updatesPath) guard let attrs = try? fm.attributesOfItem(atPath: updatesPath), let modDate = attrs[.modificationDate] as? Date else { continue } @@ -85,9 +93,22 @@ final class GrokUsageService: @unchecked Sendable { fileCache[updatesPath] = FileMeta(path: updatesPath, modDate: modDate) } } + evictFiles(notIn: livePaths) rebuildRecentEntries() } + private func evictFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDay(old, from: &dailyData) } + if let old = fileHourlyContrib[path] { subtractHour(old, from: &hourlyData) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) + } + } + private func subtractDay(_ contrib: [String: DayUsage], from data: inout [String: DayUsage]) { for (k, u) in contrib { if var e = data[k] { @@ -109,54 +130,42 @@ final class GrokUsageService: @unchecked Sendable { } private func parseJSONL(path: String) { - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() var newDaily: [String: DayUsage] = [:] var newHourly: [String: HourlyUsage] = [:] var newRecent: [RecentEntry] = [] - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 else { continue } - - let ts = obj["timestamp"] as? String ?? obj["time"] as? String ?? "" - let dateKey = ts.isEmpty ? DateHelper.todayKey() : DateHelper.localDateKey(from: ts) - let hourKey = ts.isEmpty ? DateHelper.currentHourKey() : DateHelper.localHourKey(from: ts) - guard dateKey.count == 10 else { continue } - - if var e = dailyData[dateKey] { e.tokens += totalTokens; e.messages += 1; dailyData[dateKey] = e } - else { dailyData[dateKey] = DayUsage(tokens: totalTokens, messages: 1) } - if var e = newDaily[dateKey] { e.tokens += totalTokens; e.messages += 1; newDaily[dateKey] = e } - else { newDaily[dateKey] = DayUsage(tokens: totalTokens, messages: 1) } - - if var e = hourlyData[hourKey] { e.tokens += totalTokens; e.messages += 1; hourlyData[hourKey] = e } - else { hourlyData[hourKey] = HourlyUsage(tokens: totalTokens, messages: 1) } - if var e = newHourly[hourKey] { e.tokens += totalTokens; e.messages += 1; newHourly[hourKey] = e } - else { newHourly[hourKey] = HourlyUsage(tokens: totalTokens, messages: 1) } - - if dateKey == today { - let date = ts.isEmpty ? Date() : (DateHelper.parseISO8601(ts) ?? Date()) - newRecent.append(RecentEntry(timestamp: date, tokens: totalTokens)) - } + func process(_ line: String) { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + let totalTokens = obj["totalTokens"] as? Int ?? 0 + guard totalTokens > 0 else { return } + + let ts = obj["timestamp"] as? String ?? obj["time"] as? String ?? "" + let dateKey = ts.isEmpty ? DateHelper.todayKey() : DateHelper.localDateKey(from: ts) + let hourKey = ts.isEmpty ? DateHelper.currentHourKey() : DateHelper.localHourKey(from: ts) + guard dateKey.count == 10 else { return } + + if var e = dailyData[dateKey] { e.tokens += totalTokens; e.messages += 1; dailyData[dateKey] = e } + else { dailyData[dateKey] = DayUsage(tokens: totalTokens, messages: 1) } + if var e = newDaily[dateKey] { e.tokens += totalTokens; e.messages += 1; newDaily[dateKey] = e } + else { newDaily[dateKey] = DayUsage(tokens: totalTokens, messages: 1) } + + if var e = hourlyData[hourKey] { e.tokens += totalTokens; e.messages += 1; hourlyData[hourKey] = e } + else { hourlyData[hourKey] = HourlyUsage(tokens: totalTokens, messages: 1) } + if var e = newHourly[hourKey] { e.tokens += totalTokens; e.messages += 1; newHourly[hourKey] = e } + else { newHourly[hourKey] = HourlyUsage(tokens: totalTokens, messages: 1) } + + if dateKey == today { + let date = ts.isEmpty ? Date() : (DateHelper.parseISO8601(ts) ?? Date()) + newRecent.append(RecentEntry(timestamp: date, tokens: totalTokens)) } } + guard JSONLLineReader.read( + path: path, + matchingAny: Self.tokenLineNeedle, + onLine: process + ) != nil else { return } fileDailyContrib[path] = newDaily fileHourlyContrib[path] = newHourly @@ -196,19 +205,9 @@ final class GrokUsageService: @unchecked Sendable { let modDate = attrs[.modificationDate] as? Date, DateHelper.dateKey(from: modDate) == today else { continue } - var totalTokens = 0, msgCount = 0 - guard let data = fm.contents(atPath: updatesPath), - let content = String(data: data, encoding: .utf8) else { continue } - for line in content.components(separatedBy: .newlines) { - guard !line.isEmpty, - let obj = try? JSONSerialization.jsonObject(with: line.data(using: .utf8) ?? Data()) as? [String: Any] else { continue } - let ts = obj["timestamp"] as? String ?? obj["time"] as? String ?? "" - let dateKey = ts.isEmpty ? today : DateHelper.localDateKey(from: ts) - guard dateKey == today else { continue } - let tokens = obj["totalTokens"] as? Int ?? 0 - guard tokens > 0 else { continue } - totalTokens += tokens; msgCount += 1 - } + let usage = fileDailyContrib[updatesPath]?[today] + let totalTokens = usage?.tokens ?? 0 + let msgCount = usage?.messages ?? 0 guard totalTokens > 0 else { continue } results.append(SessionInfo( diff --git a/Sources/TokenClock/Services/JSONLLineReader.swift b/Sources/TokenClock/Services/JSONLLineReader.swift new file mode 100644 index 0000000..75fae5e --- /dev/null +++ b/Sources/TokenClock/Services/JSONLLineReader.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Chunked JSONL reader that compacts its buffer once per chunk instead of once +/// per line. The previous scanners copied the unconsumed suffix for every line, +/// which became quadratic for large rollout files. +enum JSONLLineReader { + struct Result { + let offset: UInt64 + let trailingData: Data + } + + static func read( + path: String, + from offset: UInt64 = 0, + prefix: Data = Data(), + matchingAny needles: [Data] = [], + onLine: (String) -> Void + ) -> Result? { + // Cold scans are dominated by large historical files. A read-only mmap + // lets us inspect line slices without allocating/copying each enormous + // tool-output record. EOF appends still use the streaming path below. + if offset == 0, prefix.isEmpty, + let mapped = try? Data( + contentsOf: URL(fileURLWithPath: path), + options: [.mappedIfSafe, .uncached] + ) { + return readMapped(mapped, matchingAny: needles, onLine: onLine) + } + + guard let handle = FileHandle(forReadingAtPath: path) else { return nil } + defer { try? handle.close() } + + do { + try handle.seek(toOffset: offset) + } catch { + return nil + } + + var buffer = prefix + // `prefix` is the already-searched, newline-free tail from a previous + // read. Start the next search at newly appended bytes so very large + // single JSON records stay linear instead of being rescanned per chunk. + var searchedByteCount = prefix.count + var readOffset = offset + let chunkSize = max(AppConfig.Scan.jsonlBufferSize, 4_096) + + while true { + let chunk: Data + do { + chunk = try handle.read(upToCount: chunkSize) ?? Data() + } catch { + return nil + } + guard !chunk.isEmpty else { break } + + readOffset += UInt64(chunk.count) + buffer.append(chunk) + + var lineStart = buffer.startIndex + var searchStart = buffer.index(buffer.startIndex, offsetBy: searchedByteCount) + while let newline = buffer[searchStart...].firstIndex(of: 0x0A) { + let lineData = buffer[lineStart.. buffer.startIndex { + buffer.removeSubrange(buffer.startIndex.. Void + ) -> Result { + var lineStart = data.startIndex + while lineStart < data.endIndex, + let newline = data[lineStart...].firstIndex(of: 0x0A) { + let lineData = data[lineStart.. Void + ) -> Data { + guard !data.isEmpty, + (try? JSONSerialization.jsonObject(with: data)) != nil, + let line = String(data: data, encoding: .utf8) else { + return data + } + withAutoreleasePool { onLine(line) } + return Data() + } + + @inline(__always) + private static func withAutoreleasePool(_ body: () -> Void) { +#if canImport(ObjectiveC) + autoreleasepool(invoking: body) +#else + body() +#endif + } +} diff --git a/Sources/TokenClock/Services/OpenClawUsageService.swift b/Sources/TokenClock/Services/OpenClawUsageService.swift index 8ce6d84..d3994aa 100644 --- a/Sources/TokenClock/Services/OpenClawUsageService.swift +++ b/Sources/TokenClock/Services/OpenClawUsageService.swift @@ -3,6 +3,7 @@ import Foundation /// 从 OpenClaw 本地 JSONL 日志读取 token 使用数据 /// 日志位置: ~/.openclaw/agents/*/sessions/ final class OpenClawUsageService: @unchecked Sendable { + private static let assistantLineNeedle = [Data("\"assistant\"".utf8)] private(set) var dailyData: [String: DayUsage] = [:] private(set) var hourlyData: [String: HourlyUsage] = [:] private(set) var dailyCache: [String: Int] = [:] @@ -11,17 +12,27 @@ final class OpenClawUsageService: @unchecked Sendable { private var fileHourlyContrib: [String: [String: HourlyUsage]] = [:] private var fileCacheContrib: [String: [String: Int]] = [:] private var fileRecentContrib: [String: [RecentEntry]] = [:] + private var fileModelContrib: [String: [String: [String: Int]]] = [:] + private var fileAgentNames: [String: String] = [:] private var recentEntries: [RecentEntry] = [] private let fm = FileManager.default private let openclawHome: String - init() { + init(openclawHome: String? = nil) { + if let openclawHome { + self.openclawHome = openclawHome + return + } +#if os(Linux) + self.openclawHome = PathConfig.openclawHome() +#else if let xdg = ProcessInfo.processInfo.environment["OPENCLAW_HOME"] { - openclawHome = xdg + self.openclawHome = xdg } else { - openclawHome = PathConfig.openclawHome() + self.openclawHome = PathConfig.openclawHome() } +#endif } // MARK: - 公开接口 @@ -35,6 +46,8 @@ final class OpenClawUsageService: @unchecked Sendable { fileHourlyContrib.removeAll() fileCacheContrib.removeAll() fileRecentContrib.removeAll() + fileModelContrib.removeAll() + fileAgentNames.removeAll() recentEntries = [] scanAgentDirectories() } @@ -78,7 +91,13 @@ final class OpenClawUsageService: @unchecked Sendable { private func scanAgentDirectories() { let agentsDir = openclawHome + "/agents" - guard let agentNames = try? fm.contentsOfDirectory(atPath: agentsDir) else { return } + guard let agentNames = try? fm.contentsOfDirectory(atPath: agentsDir) else { + evictFiles(notIn: []) + rebuildRecentEntries() + return + } + + var livePaths = Set() for name in agentNames { let sessionsDir = agentsDir + "/" + name + "/sessions" @@ -91,6 +110,8 @@ final class OpenClawUsageService: @unchecked Sendable { guard file.hasSuffix(".jsonl") else { continue } let fullPath = sessionsDir + "/" + file + livePaths.insert(fullPath) + fileAgentNames[fullPath] = name guard let attrs = try? fm.attributesOfItem(atPath: fullPath), let modDate = attrs[.modificationDate] as? Date else { continue } @@ -113,17 +134,36 @@ final class OpenClawUsageService: @unchecked Sendable { fileDailyContrib.removeValue(forKey: fullPath) fileHourlyContrib.removeValue(forKey: fullPath) fileCacheContrib.removeValue(forKey: fullPath) + fileRecentContrib.removeValue(forKey: fullPath) + fileModelContrib.removeValue(forKey: fullPath) fileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) continue } - parseFile(path: fullPath, isIncremental: fileDailyContrib[fullPath] != nil) + parseFile(path: fullPath) fileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) } } + evictFiles(notIn: livePaths) rebuildRecentEntries() } + private func evictFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDailyContributions(old) } + if let old = fileHourlyContrib[path] { subtractHourlyContributions(old) } + if let old = fileCacheContrib[path] { subtractCacheContributions(old) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileCacheContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) + fileModelContrib.removeValue(forKey: path) + fileAgentNames.removeValue(forKey: path) + } + } + private func subtractCacheContributions(_ contrib: [String: Int]) { for (dateKey, count) in contrib { if var existing = dailyCache[dateKey] { @@ -165,51 +205,30 @@ final class OpenClawUsageService: @unchecked Sendable { } } - private func parseFile(path: String, isIncremental: Bool = false) { - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - + private func parseFile(path: String) { let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - var newDailyContrib: [String: DayUsage] = [:] var newHourlyContrib: [String: HourlyUsage] = [:] var newCacheContrib: [String: Int] = [:] var newRecent: [RecentEntry] = [] + var newModels: [String: [String: Int]] = [:] - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. [SessionInfo] { - let agentsDir = openclawHome + "/agents" - let fm = FileManager.default - var isDir: ObjCBool = false - guard fm.fileExists(atPath: agentsDir, isDirectory: &isDir), isDir.boolValue else { return [] } - guard let agentNames = try? fm.contentsOfDirectory(atPath: agentsDir) else { return [] } - let today = DateHelper.todayKey() - var results: [SessionInfo] = [] - - for agentName in agentNames { - let sessionsDir = agentsDir + "/" + agentName + "/sessions" - var sIsDir: ObjCBool = false - guard fm.fileExists(atPath: sessionsDir, isDirectory: &sIsDir), sIsDir.boolValue else { continue } - guard let files = try? fm.contentsOfDirectory(atPath: sessionsDir) else { continue } - - var agentTokens = 0 - var agentMessages = 0 - // 该 agent 下各模型 → token 用量,用于取代表模型(一个 agent 可能跨多个 session 文件用不同模型) - var agentModelTokens: [String: Int] = [:] - - for file in files { - // 过滤掉 trajectory 和 checkpoint 文件,只保留主 session 日志 - guard file.hasSuffix(".jsonl"), - !file.contains(".checkpoint."), - !file.contains(".trajectory.") else { continue } - let fullPath = sessionsDir + "/" + file - - // 跳过 cron 触发的 session - if isCronSession(path: fullPath) { continue } - - // 解析该文件今日数据(以实际内容为准,不以文件修改时间为准) - let (tokens, messages, modelTokens) = parseSessionFileToday(path: fullPath, today: today) - agentTokens += tokens - agentMessages += messages - for (m, t) in modelTokens { agentModelTokens[m, default: 0] += t } - } - - if agentTokens > 0 { - let dominantModel = agentModelTokens.max(by: { $0.value < $1.value })?.key - results.append(SessionInfo( - rawId: agentName, - displayName: agentName, - detail: nil, - todayTokens: agentTokens, - todayMessages: agentMessages, - isActive: true, - model: dominantModel - )) + var agents: [String: (tokens: Int, messages: Int, models: [String: Int])] = [:] + for (path, dates) in fileDailyContrib { + guard let usage = dates[today], let agentName = fileAgentNames[path] else { continue } + var summary = agents[agentName] ?? (0, 0, [:]) + summary.tokens += usage.tokens + summary.messages += usage.messages + for (model, tokens) in fileModelContrib[path]?[today] ?? [:] { + summary.models[model, default: 0] += tokens } + agents[agentName] = summary } - - return results.sorted { $0.todayTokens > $1.todayTokens } + return agents.compactMap { agentName, summary in + guard summary.tokens > 0 else { return nil } + return SessionInfo( + rawId: agentName, + displayName: agentName, + detail: nil, + todayTokens: summary.tokens, + todayMessages: summary.messages, + isActive: true, + model: summary.models.max(by: { $0.value < $1.value })?.key + ) + }.sorted { $0.todayTokens > $1.todayTokens } } /// 检测 session 是否由 cron 触发 @@ -415,49 +411,4 @@ final class OpenClawUsageService: @unchecked Sendable { return false } - /// 解析单个 session 文件的今日数据 - /// 返回 (tokens, messages, modelTokens: 各模型 → token 用量),供上层按 agent 归并时取代表模型 - private func parseSessionFileToday(path: String, today: String) -> (tokens: Int, messages: Int, modelTokens: [String: Int]) { - guard let stream = InputStream(fileAtPath: path) else { return (0, 0, [:]) } - stream.open() - defer { stream.close() } - - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - var tokens = 0, messages = 0 - var modelTokens: [String: Int] = [:] - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex..() for project in projectDirs { let chatsDir = projectsDir + "/" + project + "/chats" @@ -74,6 +84,7 @@ final class QwenCodeUsageService: @unchecked Sendable { for file in files where file.hasSuffix(".jsonl") { let fullPath = chatsDir + "/" + file + livePaths.insert(fullPath) guard let attrs = try? fm.attributesOfItem(atPath: fullPath), let modDate = attrs[.modificationDate] as? Date else { continue } @@ -88,9 +99,24 @@ final class QwenCodeUsageService: @unchecked Sendable { fileCache[fullPath] = FileMeta(path: fullPath, modDate: modDate) } } + evictFiles(notIn: livePaths) rebuildRecentEntries() } + private func evictFiles(notIn livePaths: Set) { + let removed = Set(fileCache.keys).subtracting(livePaths) + for path in removed { + if let old = fileDailyContrib[path] { subtractDay(old, from: &dailyData) } + if let old = fileHourlyContrib[path] { subtractHour(old, from: &hourlyData) } + if let old = fileCacheContrib[path] { subtractCache(old) } + fileCache.removeValue(forKey: path) + fileDailyContrib.removeValue(forKey: path) + fileHourlyContrib.removeValue(forKey: path) + fileCacheContrib.removeValue(forKey: path) + fileRecentContrib.removeValue(forKey: path) + } + } + private func subtractDay(_ contrib: [String: DayUsage], from data: inout [String: DayUsage]) { for (k, u) in contrib { if var e = data[k] { @@ -122,43 +148,24 @@ final class QwenCodeUsageService: @unchecked Sendable { } private func parseJSONL(path: String) { - guard let stream = InputStream(fileAtPath: path) else { return } - stream.open() - defer { stream.close() } - let today = DateHelper.todayKey() - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() var newDaily: [String: DayUsage] = [:] var newHourly: [String: HourlyUsage] = [:] var newCache: [String: Int] = [:] var newRecent: [RecentEntry] = [] - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. 0 else { continue } let displayId = SessionIdDisplay.format(String(file.dropLast(".jsonl".count))) @@ -285,31 +294,4 @@ final class QwenCodeUsageService: @unchecked Sendable { return results.sorted { $0.todayTokens > $1.todayTokens } } - private func parseJSONLToday(path: String, today: String) -> (tokens: Int, messages: Int) { - guard let stream = InputStream(fileAtPath: path) else { return (0, 0) } - stream.open() - defer { stream.close() } - - let bufSize = AppConfig.Scan.jsonlBufferSize - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } - var lineBuf = Data() - var totalTokens = 0, msgCount = 0 - - while stream.hasBytesAvailable { - let n = stream.read(buf, maxLength: bufSize) - if n <= 0 { break } - lineBuf.append(buf, count: n) - while let nlRange = lineBuf.range(of: Data([0x0A])) { - let lineData = lineBuf[lineBuf.startIndex.. String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone.current - return formatter.string(from: Date()) + dateKey(from: Date()) } /// 解析 ISO8601 UTC 时间戳,返回绝对 Date static func parseISO8601(_ s: String) -> Date? { - let chars = Array(s) - guard chars.count >= 19 else { return nil } - let y = Int(String(chars[0...3])) ?? 0 - let m = Int(String(chars[5...6])) ?? 1 - let d = Int(String(chars[8...9])) ?? 1 - let hr = Int(String(chars[11...12])) ?? 0 - let mn = Int(String(chars[14...15])) ?? 0 - let sc = Int(String(chars[17...18])) ?? 0 - var cal = Calendar(identifier: .gregorian) - cal.timeZone = TimeZone(abbreviation: "UTC")! - var components = DateComponents() - components.year = y; components.month = m; components.day = d - components.hour = hr; components.minute = mn; components.second = sc - return cal.date(from: components) + let bytes = Array(s.utf8) + guard bytes.count >= 19, + bytes[4] == 0x2D, bytes[7] == 0x2D, + (bytes[10] == 0x54 || bytes[10] == 0x20), + bytes[13] == 0x3A, bytes[16] == 0x3A else { return nil } + + func number(_ start: Int, _ count: Int) -> Int? { + var value = 0 + for byte in bytes[start..<(start + count)] { + guard byte >= 0x30, byte <= 0x39 else { return nil } + value = value * 10 + Int(byte - 0x30) + } + return value + } + + guard let year = number(0, 4), let month = number(5, 2), let day = number(8, 2), + let hour = number(11, 2), let minute = number(14, 2), let second = number(17, 2) else { + return nil + } + guard let base = utcCalendar.date(from: DateComponents( + year: year, month: month, day: day, + hour: hour, minute: minute, second: second + )) else { return nil } + + var index = 19 + var fractionalSeconds = 0.0 + if index < bytes.count, bytes[index] == 0x2E { + index += 1 + var scale = 0.1 + let fractionStart = index + while index < bytes.count, bytes[index] >= 0x30, bytes[index] <= 0x39 { + fractionalSeconds += Double(bytes[index] - 0x30) * scale + scale *= 0.1 + index += 1 + } + guard index > fractionStart else { return nil } + } + + var offsetSeconds = 0 + if index < bytes.count { + if bytes[index] == 0x5A || bytes[index] == 0x7A { // Z / z + index += 1 + } else if bytes[index] == 0x2B || bytes[index] == 0x2D { // + / - + let sign = bytes[index] == 0x2B ? 1 : -1 + index += 1 + guard index + 1 < bytes.count, + let offsetHour = number(index, 2) else { return nil } + index += 2 + if index < bytes.count, bytes[index] == 0x3A { index += 1 } + guard index + 1 < bytes.count, + let offsetMinute = number(index, 2), + offsetHour <= 23, offsetMinute <= 59 else { return nil } + index += 2 + offsetSeconds = sign * (offsetHour * 3_600 + offsetMinute * 60) + } else { + return nil + } + } + guard index == bytes.count else { return nil } + return base.addingTimeInterval(fractionalSeconds - Double(offsetSeconds)) } /// 从 ISO8601 UTC 时间戳提取本地日期 key static func localDateKey(from isoString: String) -> String { guard let date = parseISO8601(isoString) else { return "" } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone.current - return formatter.string(from: date) + return dateKey(from: date) } /// 从 ISO8601 UTC 时间戳提取本地小时 key(如 "2026-04-23-22") static func localHourKey(from isoString: String) -> String { guard let date = parseISO8601(isoString) else { return "" } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd-HH" - formatter.timeZone = TimeZone.current - return formatter.string(from: date) + return hourKey(from: date) } /// 从 Date 提取本地日期 key static func dateKey(from date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone.current - return formatter.string(from: date) + let components = Calendar.current.dateComponents([.year, .month, .day], from: date) + return key(components.year, components.month, components.day) } /// 从 Date 提取本地小时 key static func hourKey(from date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd-HH" - formatter.timeZone = TimeZone.current - return formatter.string(from: date) + let components = Calendar.current.dateComponents([.year, .month, .day, .hour], from: date) + return key(components.year, components.month, components.day, components.hour) } /// 当前本地小时 key static func currentHourKey() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd-HH" - formatter.timeZone = TimeZone.current - return formatter.string(from: Date()) + hourKey(from: Date()) + } + + private static func key(_ year: Int?, _ month: Int?, _ day: Int?, _ hour: Int? = nil) -> String { + guard let year, let month, let day else { return "" } + let date = "\(year)-\(padded(month))-\(padded(day))" + guard let hour else { return date } + return date + "-\(padded(hour))" + } + + private static func padded(_ value: Int) -> String { + value < 10 ? "0\(value)" : String(value) } } diff --git a/Tests/TokenClockTests/CodexQuotaServiceTests.swift b/Tests/TokenClockTests/CodexQuotaServiceTests.swift new file mode 100644 index 0000000..2a4fcf4 --- /dev/null +++ b/Tests/TokenClockTests/CodexQuotaServiceTests.swift @@ -0,0 +1,148 @@ +import Foundation +import XCTest +@testable import TokenClock + +final class CodexQuotaServiceTests: XCTestCase { + func testDecodesAppServerRateLimitsAndCredits() throws { + let response = #""" + { + "id": 2, + "result": { + "rateLimits": { + "limitId": "codex", + "limitName": "Codex", + "planType": "pro", + "primary": { + "usedPercent": 25, + "windowDurationMins": 300, + "resetsAt": 2000000000 + }, + "secondary": { + "usedPercent": "70", + "windowDurationMins": 10080, + "resetsAt": 2000003600 + }, + "credits": { + "balance": "12.5", + "unlimited": false + } + }, + "rateLimitResetCredits": { + "availableCount": 2 + } + } + } + """# + let refreshedAt = Date(timeIntervalSince1970: 1_900_000_000) + + let snapshot = try XCTUnwrap(CodexQuotaService.decodeAppServerResponse( + Data(response.utf8), + now: refreshedAt + )) + + XCTAssertEqual(snapshot.status, .available) + XCTAssertEqual(snapshot.source, .appServer) + XCTAssertEqual(snapshot.planType, "pro") + XCTAssertEqual(snapshot.creditBalance, "12.5") + XCTAssertEqual(snapshot.resetCreditCount, 2) + XCTAssertEqual(snapshot.refreshedAt, refreshedAt) + XCTAssertEqual(snapshot.buckets.map(\.windowMinutes), [10_080, 300]) + XCTAssertEqual(snapshot.buckets.map(\.remainingPercent), [30, 75]) + } + + func testDecodesBoundedSessionLogFallbackShape() throws { + let line = #""" + { + "timestamp": "2026-08-06T10:30:00Z", + "payload": { + "rate_limits": { + "plan_type": "team", + "primary": { + "used_percent": 110, + "window_minutes": 300, + "resets_at": 2000000000 + }, + "secondary": { + "used_percent": 35, + "window_minutes": 10080, + "resets_at": 2000003600 + }, + "credits": { + "balance": "4", + "unlimited": true + } + } + } + } + """# + + let snapshot = try XCTUnwrap(CodexQuotaService.decodeSessionLogLine(Data(line.utf8))) + + XCTAssertEqual(snapshot.status, .available) + XCTAssertEqual(snapshot.source, .sessionLog) + XCTAssertEqual(snapshot.planType, "team") + XCTAssertEqual(snapshot.creditBalance, "4") + XCTAssertTrue(snapshot.hasUnlimitedCredits) + XCTAssertEqual(snapshot.buckets.map(\.windowMinutes), [10_080, 300]) + XCTAssertEqual(snapshot.buckets.map(\.remainingPercent), [65, 0]) + } + + func testRejectsAppServerErrorsAndLogsWithoutRateLimits() { + XCTAssertNil(CodexQuotaService.decodeAppServerResponse(Data(#"{"id":2,"error":{"message":"no"}}"#.utf8))) + XCTAssertNil(CodexQuotaService.decodeSessionLogLine(Data(#"{"payload":{}}"#.utf8))) + } + + func testDecodesAndOrdersMultipleAppServerLimitGroups() throws { + let response = #""" + { + "id": 2, + "result": { + "rateLimitsByLimitId": { + "spark": { + "limitName": "Spark", + "primary": { "usedPercent": 10, "windowDurationMins": 1440 } + }, + "codex": { + "limitName": "Codex", + "primary": { "usedPercent": "15", "windowDurationMins": "300" }, + "secondary": { "usedPercent": 40, "windowDurationMins": 10080 } + } + } + } + } + """# + + let snapshot = try XCTUnwrap(CodexQuotaService.decodeAppServerResponse(Data(response.utf8))) + + XCTAssertEqual(snapshot.buckets.map(\.id), ["codex:secondary", "codex:primary", "spark:primary"]) + XCTAssertEqual(snapshot.buckets.map(\.remainingPercent), [60, 85, 90]) + } + + func testBuildsPlatformSpecificCodexExecutableCandidates() { + let windows = CodexQuotaService.executableCandidatePaths( + environment: [ + "CODEX_BINARY": "D:\\Portable\\codex.exe", + "LOCALAPPDATA": "C:\\Users\\neo\\AppData\\Local", + "APPDATA": "C:\\Users\\neo\\AppData\\Roaming", + "Path": "C:\\Tools;D:\\Bin", + ], + platform: .windows, + homeDirectory: "C:\\Users\\neo" + ) + XCTAssertEqual(windows.first, "D:\\Portable\\codex.exe") + XCTAssertTrue(windows.contains("C:\\Users\\neo\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe")) + XCTAssertTrue(windows.contains("C:\\Users\\neo\\AppData\\Local\\OpenAI\\Codex\\bin\\codex.exe")) + XCTAssertTrue(windows.contains("C:\\Tools\\codex.exe")) + XCTAssertTrue(windows.contains("D:\\Bin\\codex.exe")) + XCTAssertFalse(windows.contains { $0.contains(":\\Tools;D:") }) + + let linux = CodexQuotaService.executableCandidatePaths( + environment: ["PATH": "/custom/bin:/usr/bin"], + platform: .unix, + homeDirectory: "/home/neo" + ) + XCTAssertTrue(linux.contains("/home/neo/.local/bin/codex")) + XCTAssertTrue(linux.contains("/custom/bin/codex")) + XCTAssertTrue(linux.contains("/usr/bin/codex")) + } +} diff --git a/Tests/TokenClockTests/UsageServicePerformanceTests.swift b/Tests/TokenClockTests/UsageServicePerformanceTests.swift new file mode 100644 index 0000000..449f254 --- /dev/null +++ b/Tests/TokenClockTests/UsageServicePerformanceTests.swift @@ -0,0 +1,816 @@ +import Foundation +import XCTest +@testable import TokenClock + +final class UsageServicePerformanceTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testCodexAppendOnlyIncrementalScanPreservesTotalsAndEvictsDeletedFile() throws { + let home = try makeTemporaryDirectory() + let sessions = home.appendingPathComponent("sessions/2026/08/06", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + let rollout = sessions.appendingPathComponent( + "rollout-2026-08-06T12-00-00-11111111-2222-3333-4444-555555555555.jsonl" + ) + let timestamp = todayTimestamp() + let first = codexTurn(model: "gpt-5.6") + "\n" + codexUsage(timestamp: timestamp, total: 120, cached: 40) + "\n" + try Data(first.utf8).write(to: rollout) + + let service = CodexUsageService(codexHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 120, messages: 1, cacheRate: 40.0 / 120.0) + + let handle = try FileHandle(forWritingTo: rollout) + try handle.seekToEnd() + try handle.write(contentsOf: Data(codexUsage(timestamp: timestamp, total: 80, cached: 20).utf8)) + try handle.close() + + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 200, messages: 2, cacheRate: 60.0 / 200.0) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 200, messages: 2, cacheRate: 60.0 / 200.0) + + try FileManager.default.removeItem(at: rollout) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + } + + func testCodexPreservesHourlyAndBoundedRecentUsageAcrossAppendAndTruncate() throws { + let home = try makeTemporaryDirectory() + let sessions = home.appendingPathComponent("sessions/current", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + let rollout = sessions.appendingPathComponent( + "rollout-2026-08-06T12-00-00-99999999-2222-3333-4444-555555555555.jsonl" + ) + let now = Date() + let recentTimestamp = isoTimestamp(now) + let staleTimestamp = isoTimestamp(now.addingTimeInterval(-2 * 60 * 60)) + let initial = codexUsageWithReorderedFields(timestamp: staleTimestamp, total: 900, cached: 90) + + "\n" + codexUsageWithReorderedFields(timestamp: recentTimestamp, total: 100, cached: 10) + "\n" + try write(initial, to: rollout) + + let service = CodexUsageService(codexHome: home.path) + service.fullScan() + XCTAssertEqual(service.currentHourTokens(), 100) + XCTAssertEqual(service.recentUsage(minutes: 10).tokens, 100) + XCTAssertEqual(service.recentUsage(minutes: 10).messages, 1) + XCTAssertEqual(service.recentUsage(minutes: 60).tokens, 100) + + let handle = try FileHandle(forWritingTo: rollout) + try handle.seekToEnd() + try handle.write(contentsOf: Data((codexUsage(timestamp: recentTimestamp, total: 50, cached: 5) + "\n").utf8)) + try handle.close() + service.incrementalScan() + XCTAssertEqual(service.currentHourTokens(), 150) + XCTAssertEqual(service.recentUsage(minutes: 10).tokens, 150) + XCTAssertEqual(service.recentUsage(minutes: 10).messages, 2) + + try write(codexUsage(timestamp: recentTimestamp, total: 7, cached: 2) + "\n", to: rollout) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 7, messages: 1, cacheRate: 2.0 / 7.0) + XCTAssertEqual(service.currentHourTokens(), 7) + XCTAssertEqual(service.recentUsage(minutes: 10).tokens, 7) + XCTAssertEqual(service.recentUsage(minutes: 10).messages, 1) + } + + func testOptimizedDateHelperMatchesLegacyDateAndHourKeys() { + for timestamp in [ + "2026-01-01T00:00:00Z", + "2026-03-08T09:59:59.999Z", + "2026-11-01T09:00:00Z", + "2026-12-31T23:59:59Z", + ] { + XCTAssertEqual(DateHelper.localDateKey(from: timestamp), legacyLocalDateKey(from: timestamp)) + XCTAssertEqual(DateHelper.localHourKey(from: timestamp), legacyLocalHourKey(from: timestamp)) + } + } + + func testDateHelperMatchesISO8601ReferenceForFractionsAndOffsets() throws { + for timestamp in [ + "2026-08-06T12:00:00Z", + "2026-08-06T12:00:00.125Z", + "2026-08-06T00:30:00+08:00", + "2026-08-06T23:45:30.500-0730", + "2026-01-01T00:15:00+14:00", + "2026-12-31T23:45:00-12:00", + ] { + let expected = try XCTUnwrap(referenceISO8601Date(timestamp)) + let actual = try XCTUnwrap(DateHelper.parseISO8601(timestamp)) + XCTAssertEqual(actual.timeIntervalSince1970, expected.timeIntervalSince1970, accuracy: 0.000_001) + XCTAssertEqual(DateHelper.localDateKey(from: timestamp), DateHelper.dateKey(from: expected)) + XCTAssertEqual(DateHelper.localHourKey(from: timestamp), DateHelper.hourKey(from: expected)) + } + } + + func testClaudeNestedSessionRemainsVisibleWithoutChangingToolTotal() throws { + let home = try makeTemporaryDirectory() + let project = home.appendingPathComponent("projects/project-a", isDirectory: true) + let nested = project.appendingPathComponent("subagents", isDirectory: true) + let metadata = home.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: metadata, withIntermediateDirectories: true) + + let timestamp = todayTimestamp() + try write(claudeUsage(timestamp: timestamp, input: 10, output: 5, cacheRead: 2, cacheCreate: 1, model: "claude-sonnet-4-5"), + to: project.appendingPathComponent("top-session.jsonl")) + let nestedLog = nested.appendingPathComponent("nested-session.jsonl") + try write(claudeUsage(timestamp: timestamp, input: 20, output: 3, cacheRead: 1, cacheCreate: 0, model: "claude-opus-4-1"), + to: nestedLog) + try write("{\"sessionId\":\"top-session\",\"cwd\":\"/tmp/top\"}", + to: metadata.appendingPathComponent("top.json")) + try write("{\"sessionId\":\"nested-session\",\"cwd\":\"/tmp/nested\"}", + to: metadata.appendingPathComponent("nested.json")) + + let service = ClaudeCodeUsageService(claudeHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 17, messages: 1, cacheRate: 3.0 / 17.0) + XCTAssertEqual(Dictionary(uniqueKeysWithValues: service.todaySessions().map { ($0.rawId, $0.todayTokens) }), [ + "top-session": 17, + "nested-session": 24, + ]) + + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 17, messages: 1, cacheRate: 3.0 / 17.0) + try FileManager.default.removeItem(at: nestedLog) + service.incrementalScan() + XCTAssertEqual(service.todaySessions().map(\.rawId), ["top-session"]) + } + + func testOpenClawCachedDetailsExcludeCronAndEvictRenamedAndDeletedFiles() throws { + let home = try makeTemporaryDirectory() + let sessions = home.appendingPathComponent("agents/main/sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + let timestamp = todayTimestamp() + let live = sessions.appendingPathComponent("live.jsonl") + try write(openClawUsage(timestamp: timestamp, input: 3, output: 4, cacheRead: 5, cacheWrite: 6, model: "gpt-5.6"), to: live) + try write(openClawCronLine() + "\n" + openClawUsage(timestamp: timestamp, input: 100, output: 100, cacheRead: 0, cacheWrite: 0, model: "ignored"), + to: sessions.appendingPathComponent("cron.jsonl")) + + let service = OpenClawUsageService(openclawHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 12, messages: 1, cacheRate: 11.0 / 12.0) + XCTAssertEqual(service.todaySessions().first?.todayTokens, 12) + XCTAssertEqual(service.todaySessions().first?.model, "gpt-5.6") + + let renamed = sessions.appendingPathComponent("renamed.jsonl") + try FileManager.default.moveItem(at: live, to: renamed) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 12, messages: 1, cacheRate: 11.0 / 12.0) + XCTAssertEqual(service.todaySessions().count, 1) + + try FileManager.default.removeItem(at: renamed) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + XCTAssertTrue(service.todaySessions().isEmpty) + } + + func testGeminiCachedDetailsPreserveJSONLPriorityAndFallbackAfterDeletion() throws { + let home = try makeTemporaryDirectory() + let chats = home.appendingPathComponent("tmp/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + let timestamp = todayTimestamp() + let jsonl = chats.appendingPathComponent("session-shared.jsonl") + let json = chats.appendingPathComponent("session-shared.json") + try write( + "{\"sessionId\":\"gemini-jsonl\"}\n" + + geminiEvent(timestamp: timestamp, input: 10, output: 5, cached: 3, thought: 2, model: "gemini-2.5-pro"), + to: jsonl + ) + try write( + "{\"sessionId\":\"gemini-json\",\"messages\":[" + + geminiEvent(timestamp: timestamp, input: 40, output: 1, cached: 4, thought: 0, model: "gemini-2.5-flash") + + "]}", + to: json + ) + + let service = GeminiUsageService(geminiHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 17, messages: 1, cacheRate: 3.0 / 17.0) + XCTAssertEqual(service.todaySessions().map(\.rawId), ["gemini-jsonl"]) + XCTAssertEqual(service.todaySessions().first?.model, "gemini-2.5-pro") + + try FileManager.default.removeItem(at: jsonl) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 41, messages: 1, cacheRate: 4.0 / 41.0) + XCTAssertEqual(service.todaySessions().map(\.rawId), ["gemini-json"]) + XCTAssertEqual(service.todaySessions().first?.model, "gemini-2.5-flash") + } + + func testGeminiSkipsSessionsOutsideRecentLookbackUntilTheyChange() throws { + let home = try makeTemporaryDirectory() + let chats = home.appendingPathComponent("tmp/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + let session = chats.appendingPathComponent("session-stale.jsonl") + try write( + "{\"sessionId\":\"stale-session\"}\n" + + geminiEvent(timestamp: todayTimestamp(), input: 10, output: 5, cached: 3, thought: 2, model: "gemini-2.5-pro"), + to: session + ) + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(-3 * AppConfig.Scan.oneDaySeconds)], + ofItemAtPath: session.path + ) + + let service = GeminiUsageService(geminiHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + + try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: session.path) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 17, messages: 1, cacheRate: 3.0 / 17.0) + } + + func testQwenCachedDetailsPreserveTotalsAndEvictRenamedDeletedFiles() throws { + let home = try makeTemporaryDirectory() + let chats = home.appendingPathComponent("projects/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + let original = chats.appendingPathComponent("qwen-one.jsonl") + try write(geminiEvent(timestamp: todayTimestamp(), input: 10, output: 5, cached: 3, thought: 2, model: "qwen3-coder") + "\n", to: original) + + let service = QwenCodeUsageService(qwenHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 17, messages: 1, cacheRate: 3.0 / 17.0) + XCTAssertEqual(service.todaySessions().first?.rawId, "qwen-one.jsonl") + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 17, messages: 1, cacheRate: 3.0 / 17.0) + + let renamed = chats.appendingPathComponent("qwen-two.jsonl") + try FileManager.default.moveItem(at: original, to: renamed) + service.incrementalScan() + XCTAssertEqual(service.todaySessions().first?.rawId, "qwen-two.jsonl") + try FileManager.default.removeItem(at: renamed) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + XCTAssertTrue(service.todaySessions().isEmpty) + } + + func testCopilotCachedSessionDetailsAndFileEviction() throws { + let home = try makeTemporaryDirectory() + let otel = home.appendingPathComponent("otel", isDirectory: true) + let sessionOne = home.appendingPathComponent("session-state/session-one", isDirectory: true) + try FileManager.default.createDirectory(at: otel, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: sessionOne, withIntermediateDirectories: true) + let timestamp = todayTimestamp() + let otelLog = otel.appendingPathComponent("usage.jsonl") + try write(copilotOtelEvent(timestamp: timestamp, input: 10, output: 5, cached: 2) + "\n", to: otelLog) + try write(copilotSessionEvent(timestamp: timestamp, input: 3, output: 4, cached: 1) + "\n", to: sessionOne.appendingPathComponent("events.jsonl")) + + let service = CopilotUsageService(copilotHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 25, messages: 2, cacheRate: 3.0 / 25.0) + XCTAssertEqual(service.todaySessions().first?.rawId, "session-one") + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 25, messages: 2, cacheRate: 3.0 / 25.0) + + let sessionTwo = home.appendingPathComponent("session-state/session-two", isDirectory: true) + try FileManager.default.moveItem(at: sessionOne, to: sessionTwo) + service.incrementalScan() + XCTAssertEqual(service.todaySessions().first?.rawId, "session-two") + try FileManager.default.removeItem(at: sessionTwo) + try FileManager.default.removeItem(at: otelLog) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + XCTAssertTrue(service.todaySessions().isEmpty) + } + + func testContinueCachedSessionDetailsAndFileEviction() throws { + let home = try makeTemporaryDirectory() + let devData = home.appendingPathComponent("dev_data", isDirectory: true) + let sessions = home.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: devData, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + let timestamp = todayTimestamp() + let devLog = devData.appendingPathComponent("dev.jsonl") + let sessionOne = sessions.appendingPathComponent("continue-one.jsonl") + try write(continueEvent(timestamp: timestamp, input: 10, output: 5) + "\n", to: devLog) + try write(continueEvent(timestamp: timestamp, input: 3, output: 2) + "\n", to: sessionOne) + + let service = ContinueUsageService(continueHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 20, messages: 2, cacheRate: 0) + XCTAssertEqual(service.todaySessions().first?.rawId, "continue-one.jsonl") + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 20, messages: 2, cacheRate: 0) + + let sessionTwo = sessions.appendingPathComponent("continue-two.jsonl") + try FileManager.default.moveItem(at: sessionOne, to: sessionTwo) + service.incrementalScan() + XCTAssertEqual(service.todaySessions().first?.rawId, "continue-two.jsonl") + try FileManager.default.removeItem(at: sessionTwo) + try FileManager.default.removeItem(at: devLog) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + XCTAssertTrue(service.todaySessions().isEmpty) + } + + func testGrokCachedSessionDetailsAndDirectoryRenameEviction() throws { + let home = try makeTemporaryDirectory() + let sessionOne = home.appendingPathComponent("sessions/project-a/session-one", isDirectory: true) + try FileManager.default.createDirectory(at: sessionOne, withIntermediateDirectories: true) + try write(grokEvent(timestamp: todayTimestamp(), tokens: 12) + "\n" + grokEvent(timestamp: todayTimestamp(), tokens: 8) + "\n", + to: sessionOne.appendingPathComponent("updates.jsonl")) + + let service = GrokUsageService(grokHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 20, messages: 2, cacheRate: 0) + XCTAssertEqual(service.todaySessions().first?.rawId, "session-one") + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 20, messages: 2, cacheRate: 0) + + let sessionTwo = home.appendingPathComponent("sessions/project-a/session-two", isDirectory: true) + try FileManager.default.moveItem(at: sessionOne, to: sessionTwo) + service.incrementalScan() + XCTAssertEqual(service.todaySessions().first?.rawId, "session-two") + try FileManager.default.removeItem(at: sessionTwo) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + XCTAssertTrue(service.todaySessions().isEmpty) + } + + func testAiderSharedReaderPreservesTotalsAndClearsMissingLog() throws { + let home = try makeTemporaryDirectory() + let analytics = home.appendingPathComponent("analytics.jsonl") + try write(aiderEvent(time: Date().timeIntervalSince1970, prompt: 7, completion: 3) + "\n", to: analytics) + + let service = AiderUsageService(aiderHome: home.path) + service.fullScan() + assertUsage(service.todayUsage(), tokens: 10, messages: 1, cacheRate: 0) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 10, messages: 1, cacheRate: 0) + + let renamed = home.appendingPathComponent("analytics-renamed.jsonl") + try FileManager.default.moveItem(at: analytics, to: renamed) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 0, messages: 0, cacheRate: 0) + try FileManager.default.moveItem(at: renamed, to: analytics) + service.incrementalScan() + assertUsage(service.todayUsage(), tokens: 10, messages: 1, cacheRate: 0) + } + + func testCodexLargeFixtureBenchmark() throws { + guard ProcessInfo.processInfo.environment["TOKENCLOCK_RUN_BENCHMARKS"] == "1" else { + throw XCTSkip("Set TOKENCLOCK_RUN_BENCHMARKS=1 to run the large JSONL benchmark") + } + + let home = try makeTemporaryDirectory() + let sessions = home.appendingPathComponent("sessions/benchmark", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + let rollout = sessions.appendingPathComponent( + "rollout-2026-08-06T12-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl" + ) + let timestamp = todayTimestamp() + let turn = codexTurn(model: "gpt-5.6") + "\n" + let usage = codexUsage(timestamp: timestamp, total: 10, cached: 4) + "\n" + var fixture = Data(turn.utf8) + for _ in 0..<20_000 { fixture.append(contentsOf: usage.utf8) } + try fixture.write(to: rollout) + + var legacyResult = (tokens: 0, messages: 0, cache: 0) + let legacyFullMilliseconds = elapsedMilliseconds { + legacyResult = legacyCodexScan(rollout) + } + let legacyUnchangedMilliseconds = elapsedMilliseconds { + _ = (try? FileManager.default.attributesOfItem(atPath: rollout.path)[.size]) as? NSNumber + } + + let service = CodexUsageService(codexHome: home.path) + let fullMilliseconds = elapsedMilliseconds { service.fullScan() } + let unchangedMilliseconds = elapsedMilliseconds { service.incrementalScan() } + + let handle = try FileHandle(forWritingTo: rollout) + try handle.seekToEnd() + try handle.write(contentsOf: Data(usage.utf8)) + try handle.close() + let legacyAppendMilliseconds = elapsedMilliseconds { + legacyResult = legacyCodexScan(rollout) + } + let appendMilliseconds = elapsedMilliseconds { service.incrementalScan() } + + let result = service.todayUsage() + XCTAssertEqual(result.tokens, 200_010) + XCTAssertEqual(result.messages, 20_001) + XCTAssertEqual(legacyResult.tokens, result.tokens) + XCTAssertEqual(legacyResult.messages, result.messages) + XCTAssertEqual(legacyResult.cache, 80_004) + print(String(format: + "TOKENCLOCK_BENCHMARK bytes=%d legacy_full_ms=%.3f legacy_unchanged_ms=%.3f legacy_append_ms=%.3f optimized_full_ms=%.3f optimized_unchanged_ms=%.3f optimized_append_ms=%.3f", + fixture.count, + legacyFullMilliseconds, legacyUnchangedMilliseconds, legacyAppendMilliseconds, + fullMilliseconds, unchangedMilliseconds, appendMilliseconds + )) + } + + func testProviderLargeFixtureBenchmark() throws { + guard ProcessInfo.processInfo.environment["TOKENCLOCK_RUN_BENCHMARKS"] == "1" else { + throw XCTSkip("Set TOKENCLOCK_RUN_BENCHMARKS=1 to run provider JSONL benchmarks") + } + let home = try makeTemporaryDirectory() + let timestamp = todayTimestamp() + let iterations = 5_000 + + let qwenURL = home.appendingPathComponent("qwen/projects/project/chats/large.jsonl") + let continueURL = home.appendingPathComponent("continue/sessions/large.jsonl") + let copilotURL = home.appendingPathComponent("copilot/session-state/large/events.jsonl") + let grokURL = home.appendingPathComponent("grok/sessions/project/large/updates.jsonl") + let aiderURL = home.appendingPathComponent("aider/analytics.jsonl") + for url in [qwenURL, continueURL, copilotURL, grokURL, aiderURL] { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + try writeRepeated(geminiEvent(timestamp: timestamp, input: 10, output: 5, cached: 3, thought: 2, model: "qwen3") + "\n", count: iterations, to: qwenURL) + try writeRepeated(continueEvent(timestamp: timestamp, input: 10, output: 5) + "\n", count: iterations, to: continueURL) + try writeRepeated(copilotSessionEvent(timestamp: timestamp, input: 3, output: 4, cached: 1) + "\n", count: iterations, to: copilotURL) + try writeRepeated(grokEvent(timestamp: timestamp, tokens: 12) + "\n", count: iterations, to: grokURL) + try writeRepeated(aiderEvent(time: Date().timeIntervalSince1970, prompt: 7, completion: 3) + "\n", count: iterations, to: aiderURL) + + var legacyTotal = 0 + let qwenLegacy = elapsedMilliseconds { legacyTotal = legacyJSONLTokenScan(qwenURL, tokenExtractor: qwenTokens) } + let qwen = QwenCodeUsageService(qwenHome: home.appendingPathComponent("qwen").path) + let qwenFull = elapsedMilliseconds { qwen.fullScan() } + let qwenDetail = elapsedMilliseconds { _ = qwen.todaySessions() } + XCTAssertEqual(legacyTotal, qwen.todayUsage().tokens) + + let continueLegacy = elapsedMilliseconds { legacyTotal = legacyJSONLTokenScan(continueURL, tokenExtractor: continueTokens) } + let continueService = ContinueUsageService(continueHome: home.appendingPathComponent("continue").path) + let continueFull = elapsedMilliseconds { continueService.fullScan() } + let continueDetail = elapsedMilliseconds { _ = continueService.todaySessions() } + XCTAssertEqual(legacyTotal, continueService.todayUsage().tokens) + + let copilotLegacy = elapsedMilliseconds { legacyTotal = legacyJSONLTokenScan(copilotURL, tokenExtractor: copilotTokens) } + let copilot = CopilotUsageService(copilotHome: home.appendingPathComponent("copilot").path) + let copilotFull = elapsedMilliseconds { copilot.fullScan() } + let copilotDetail = elapsedMilliseconds { _ = copilot.todaySessions() } + XCTAssertEqual(legacyTotal, copilot.todayUsage().tokens) + + let grokLegacy = elapsedMilliseconds { legacyTotal = legacyJSONLTokenScan(grokURL, tokenExtractor: grokTokens) } + let grok = GrokUsageService(grokHome: home.appendingPathComponent("grok").path) + let grokFull = elapsedMilliseconds { grok.fullScan() } + let grokDetail = elapsedMilliseconds { _ = grok.todaySessions() } + XCTAssertEqual(legacyTotal, grok.todayUsage().tokens) + + let aiderLegacy = elapsedMilliseconds { legacyTotal = legacyJSONLTokenScan(aiderURL, tokenExtractor: aiderTokens) } + let aider = AiderUsageService(aiderHome: home.appendingPathComponent("aider").path) + let aiderFull = elapsedMilliseconds { aider.fullScan() } + XCTAssertEqual(legacyTotal, aider.todayUsage().tokens) + + print(String(format: + "TOKENCLOCK_PROVIDER_BENCHMARK lines=%d qwen_legacy_decode_lower_bound_ms=%.3f qwen_full_ms=%.3f qwen_cached_detail_ms=%.3f continue_legacy_decode_lower_bound_ms=%.3f continue_full_ms=%.3f continue_cached_detail_ms=%.3f copilot_legacy_decode_lower_bound_ms=%.3f copilot_full_ms=%.3f copilot_cached_detail_ms=%.3f grok_legacy_decode_lower_bound_ms=%.3f grok_full_ms=%.3f grok_cached_detail_ms=%.3f aider_legacy_decode_lower_bound_ms=%.3f aider_full_ms=%.3f", + iterations, + qwenLegacy, qwenFull, qwenDetail, + continueLegacy, continueFull, continueDetail, + copilotLegacy, copilotFull, copilotDetail, + grokLegacy, grokFull, grokDetail, + aiderLegacy, aiderFull + )) + } + + func testReadOnlyRealDataBenchmark() throws { + guard ProcessInfo.processInfo.environment["TOKENCLOCK_RUN_REAL_DATA_BENCHMARKS"] == "1" else { + throw XCTSkip("Set TOKENCLOCK_RUN_REAL_DATA_BENCHMARKS=1 to scan local provider data") + } + + let codex = CodexUsageService() + let codexFull = elapsedMilliseconds { codex.fullScan() } + let codexDetails = elapsedMilliseconds { _ = codex.todaySessions() } + let codexIncremental = elapsedMilliseconds { codex.incrementalScan() } + + let claude = ClaudeCodeUsageService() + let claudeFull = elapsedMilliseconds { claude.fullScan() } + let claudeDetails = elapsedMilliseconds { _ = claude.todaySessions() } + let claudeIncremental = elapsedMilliseconds { claude.incrementalScan() } + + let openClaw = OpenClawUsageService() + let openClawFull = elapsedMilliseconds { openClaw.fullScan() } + let openClawDetails = elapsedMilliseconds { _ = openClaw.todaySessions() } + let openClawIncremental = elapsedMilliseconds { openClaw.incrementalScan() } + + let gemini = GeminiUsageService() + let geminiFull = elapsedMilliseconds { gemini.fullScan() } + let geminiDetails = elapsedMilliseconds { _ = gemini.todaySessions() } + let geminiIncremental = elapsedMilliseconds { gemini.incrementalScan() } + + let cline = ClineUsageService() + let clineFull = elapsedMilliseconds { cline.fullScan() } + let clineDetails = elapsedMilliseconds { _ = cline.todaySessions() } + let clineIncremental = elapsedMilliseconds { cline.incrementalScan() } + + let antigravity = AntigravityUsageService() + let antigravityFull = elapsedMilliseconds { antigravity.fullScan() } + let antigravityDetails = elapsedMilliseconds { _ = antigravity.todaySessions() } + let antigravityIncremental = elapsedMilliseconds { antigravity.incrementalScan() } + + let hermes = HermesUsageService() + let hermesFull = elapsedMilliseconds { hermes.fullScan() } + let hermesDetails = elapsedMilliseconds { _ = hermes.todaySessions() } + let hermesIncremental = elapsedMilliseconds { hermes.incrementalScan() } + + let openCode = OpenCodeUsageService() + let openCodeFull = elapsedMilliseconds { openCode.fullScan() } + let openCodeDetails = elapsedMilliseconds { _ = openCode.todaySessions() } + let openCodeIncremental = elapsedMilliseconds { openCode.incrementalScan() } + + let qwen = QwenCodeUsageService() + let qwenFull = elapsedMilliseconds { qwen.fullScan() } + let qwenDetails = elapsedMilliseconds { _ = qwen.todaySessions() } + let qwenIncremental = elapsedMilliseconds { qwen.incrementalScan() } + + let copilot = CopilotUsageService() + let copilotFull = elapsedMilliseconds { copilot.fullScan() } + let copilotDetails = elapsedMilliseconds { _ = copilot.todaySessions() } + let copilotIncremental = elapsedMilliseconds { copilot.incrementalScan() } + + let continueService = ContinueUsageService() + let continueFull = elapsedMilliseconds { continueService.fullScan() } + let continueDetails = elapsedMilliseconds { _ = continueService.todaySessions() } + let continueIncremental = elapsedMilliseconds { continueService.incrementalScan() } + + let grok = GrokUsageService() + let grokFull = elapsedMilliseconds { grok.fullScan() } + let grokDetails = elapsedMilliseconds { _ = grok.todaySessions() } + let grokIncremental = elapsedMilliseconds { grok.incrementalScan() } + + let aider = AiderUsageService() + let aiderFull = elapsedMilliseconds { aider.fullScan() } + let aiderIncremental = elapsedMilliseconds { aider.incrementalScan() } + + print(String(format: + "TOKENCLOCK_REAL_BENCHMARK codex_full_ms=%.3f codex_detail_ms=%.3f codex_incremental_ms=%.3f claude_full_ms=%.3f claude_detail_ms=%.3f claude_incremental_ms=%.3f openclaw_full_ms=%.3f openclaw_detail_ms=%.3f openclaw_incremental_ms=%.3f gemini_full_ms=%.3f gemini_detail_ms=%.3f gemini_incremental_ms=%.3f cline_full_ms=%.3f cline_detail_ms=%.3f cline_incremental_ms=%.3f antigravity_full_ms=%.3f antigravity_detail_ms=%.3f antigravity_incremental_ms=%.3f hermes_full_ms=%.3f hermes_detail_ms=%.3f hermes_incremental_ms=%.3f opencode_full_ms=%.3f opencode_detail_ms=%.3f opencode_incremental_ms=%.3f qwen_full_ms=%.3f qwen_detail_ms=%.3f qwen_incremental_ms=%.3f copilot_full_ms=%.3f copilot_detail_ms=%.3f copilot_incremental_ms=%.3f continue_full_ms=%.3f continue_detail_ms=%.3f continue_incremental_ms=%.3f grok_full_ms=%.3f grok_detail_ms=%.3f grok_incremental_ms=%.3f aider_full_ms=%.3f aider_incremental_ms=%.3f", + codexFull, codexDetails, codexIncremental, + claudeFull, claudeDetails, claudeIncremental, + openClawFull, openClawDetails, openClawIncremental, + geminiFull, geminiDetails, geminiIncremental, + clineFull, clineDetails, clineIncremental, + antigravityFull, antigravityDetails, antigravityIncremental, + hermesFull, hermesDetails, hermesIncremental, + openCodeFull, openCodeDetails, openCodeIncremental, + qwenFull, qwenDetails, qwenIncremental, + copilotFull, copilotDetails, copilotIncremental, + continueFull, continueDetails, continueIncremental, + grokFull, grokDetails, grokIncremental, + aiderFull, aiderIncremental + )) + if let rawSeconds = ProcessInfo.processInfo.environment["TOKENCLOCK_REAL_IDLE_SECONDS"], + let seconds = UInt32(rawSeconds), seconds > 0 { + Thread.sleep(forTimeInterval: TimeInterval(seconds)) + } + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenClockPerformanceTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + temporaryDirectories.append(url) + return url + } + + private func todayTimestamp() -> String { + let noon = Calendar.current.startOfDay(for: Date()).addingTimeInterval(12 * 60 * 60) + return ISO8601DateFormatter().string(from: noon) + } + + private func isoTimestamp(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + + private func referenceISO8601Date(_ timestamp: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: timestamp) { return date } + let standard = ISO8601DateFormatter() + standard.formatOptions = [.withInternetDateTime] + return standard.date(from: timestamp) + } + + private func write(_ value: String, to url: URL) throws { + try Data(value.utf8).write(to: url) + } + + private func writeRepeated(_ line: String, count: Int, to url: URL) throws { + let bytes = Data(line.utf8) + var data = Data() + data.reserveCapacity(bytes.count * count) + for _ in 0.. Void) -> Double { + let start = ContinuousClock.now + body() + let duration = start.duration(to: .now) + return Double(duration.components.seconds) * 1_000 + + Double(duration.components.attoseconds) / 1_000_000_000_000_000 + } + + /// Test-only reproduction of the e7611ea Codex line-buffer/date path. It is + /// intentionally not shared with product code and provides before/after + /// timing plus an independent parity result on the same fixture. + private func legacyCodexScan(_ url: URL) -> (tokens: Int, messages: Int, cache: Int) { + guard let stream = InputStream(url: url) else { return (0, 0, 0) } + stream.open() + defer { stream.close() } + let bufferSize = AppConfig.Scan.jsonlBufferSize + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + var lineBuffer = Data() + var totals = (tokens: 0, messages: 0, cache: 0) + + func parse(_ lineData: Data.SubSequence) { + guard let line = String(data: lineData, encoding: .utf8), + line.contains("\"type\":\"token_count\",\"info\""), + let usageRange = line.range(of: "\"last_token_usage\":{") else { return } + let timestamp = legacyTimestamp(from: line) + guard !timestamp.isEmpty, !legacyLocalDateKey(from: timestamp).isEmpty else { return } + let usage = String(line[usageRange.lowerBound...].prefix(500)) + let tokens = legacyExtractInt(usage, key: "\"total_tokens\"") + guard tokens > 0 else { return } + totals.tokens += tokens + totals.messages += 1 + totals.cache += legacyExtractInt(usage, key: "\"cached_input_tokens\"") + } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + if count <= 0 { break } + lineBuffer.append(buffer, count: count) + while let newline = lineBuffer.range(of: Data([0x0A])) { + parse(lineBuffer[lineBuffer.startIndex.. String { + guard let range = line.range(of: "\"timestamp\":\"") else { return "" } + let start = range.upperBound + guard let end = line[start...].firstIndex(of: "\"") else { return "" } + return String(line[start.. String { + legacyLocalKey(from: timestamp, format: "yyyy-MM-dd") + } + + private func legacyLocalHourKey(from timestamp: String) -> String { + legacyLocalKey(from: timestamp, format: "yyyy-MM-dd-HH") + } + + private func legacyLocalKey(from timestamp: String, format: String) -> String { + let characters = Array(timestamp) + guard characters.count >= 19 else { return "" } + let year = Int(String(characters[0...3])) ?? 0 + let month = Int(String(characters[5...6])) ?? 1 + let day = Int(String(characters[8...9])) ?? 1 + let hour = Int(String(characters[11...12])) ?? 0 + let minute = Int(String(characters[14...15])) ?? 0 + let second = Int(String(characters[17...18])) ?? 0 + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + guard let date = calendar.date(from: DateComponents( + year: year, month: month, day: day, + hour: hour, minute: minute, second: second + )) else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.timeZone = .current + return formatter.string(from: date) + } + + private func legacyExtractInt(_ string: String, key: String) -> Int { + guard let range = string.range(of: key) else { return 0 } + let digits = string[range.upperBound...].drop(while: { $0 == ":" || $0 == " " }) + return Int(digits.prefix(while: \.isNumber)) ?? 0 + } + + private func legacyJSONLTokenScan( + _ url: URL, + tokenExtractor: ([String: Any]) -> Int + ) -> Int { + guard let stream = InputStream(url: url) else { return 0 } + stream.open() + defer { stream.close() } + let size = AppConfig.Scan.jsonlBufferSize + let buffer = UnsafeMutablePointer.allocate(capacity: size) + defer { buffer.deallocate() } + var lineBuffer = Data() + var tokens = 0 + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: size) + if count <= 0 { break } + lineBuffer.append(buffer, count: count) + while let newline = lineBuffer.range(of: Data([0x0A])) { + let lineData = lineBuffer[lineBuffer.startIndex.. Int { + guard let values = object["tokens"] as? [String: Any] else { return 0 } + return (values["input"] as? Int ?? 0) + + (values["output"] as? Int ?? 0) + + (values["thought"] as? Int ?? 0) + } + + private func continueTokens(_ object: [String: Any]) -> Int { + guard let values = object["tokens"] as? [String: Any] else { return 0 } + return (values["input"] as? Int ?? 0) + (values["output"] as? Int ?? 0) + } + + private func copilotTokens(_ object: [String: Any]) -> Int { + guard let values = object["usage"] as? [String: Any] else { return 0 } + return (values["inputTokens"] as? Int ?? 0) + + (values["outputTokens"] as? Int ?? 0) + + (values["cacheReadTokens"] as? Int ?? 0) + } + + private func grokTokens(_ object: [String: Any]) -> Int { + object["totalTokens"] as? Int ?? 0 + } + + private func aiderTokens(_ object: [String: Any]) -> Int { + guard let values = object["properties"] as? [String: Any] else { return 0 } + return (values["prompt_tokens"] as? Int ?? 0) + + (values["completion_tokens"] as? Int ?? 0) + } + + private func assertUsage( + _ usage: (tokens: Int, messages: Int, cacheRate: Double), + tokens: Int, + messages: Int, + cacheRate: Double, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(usage.tokens, tokens, file: file, line: line) + XCTAssertEqual(usage.messages, messages, file: file, line: line) + XCTAssertEqual(usage.cacheRate, cacheRate, accuracy: 0.000_001, file: file, line: line) + } + + private func codexTurn(model: String) -> String { + "{\"payload\":{\"type\":\"turn_context\",\"model\":\"\(model)\"}}" + } + + private func codexUsage(timestamp: String, total: Int, cached: Int) -> String { + "{\"timestamp\":\"\(timestamp)\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"total_tokens\":\(total),\"cached_input_tokens\":\(cached)}}}}" + } + + private func codexUsageWithReorderedFields(timestamp: String, total: Int, cached: Int) -> String { + "{\"timestamp\":\"\(timestamp)\",\"payload\":{\"info\":{\"last_token_usage\":{\"total_tokens\":\(total),\"cached_input_tokens\":\(cached)}},\"extra\":true,\"type\":\"token_count\"}}" + } + + private func claudeUsage( + timestamp: String, input: Int, output: Int, cacheRead: Int, cacheCreate: Int, model: String + ) -> String { + "{\"type\":\"assistant\",\"timestamp\":\"\(timestamp)\",\"message\":{\"model\":\"\(model)\",\"usage\":{\"input_tokens\":\(input),\"output_tokens\":\(output),\"cache_read_input_tokens\":\(cacheRead),\"cache_creation_input_tokens\":\(cacheCreate)}}}" + } + + private func openClawUsage( + timestamp: String, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, model: String + ) -> String { + "{\"timestamp\":\"\(timestamp)\",\"message\":{\"role\":\"assistant\",\"model\":\"\(model)\",\"usage\":{\"input\":\(input),\"output\":\(output),\"cacheRead\":\(cacheRead),\"cacheWrite\":\(cacheWrite)}}}" + } + + private func openClawCronLine() -> String { + "{\"message\":{\"role\":\"user\",\"content\":[{\"text\":\"[cron:12345678-1234-1234-1234-123456789abc task]\"}]}}" + } + + private func geminiEvent( + timestamp: String, input: Int, output: Int, cached: Int, thought: Int, model: String + ) -> String { + "{\"type\":\"gemini\",\"timestamp\":\"\(timestamp)\",\"model\":\"\(model)\",\"tokens\":{\"input\":\(input),\"output\":\(output),\"cached\":\(cached),\"thought\":\(thought)}}" + } + + private func copilotOtelEvent(timestamp: String, input: Int, output: Int, cached: Int) -> String { + "{\"startTime\":\"\(timestamp)\",\"attributes\":{\"gen_ai.usage.input_tokens\":\(input),\"gen_ai.usage.output_tokens\":\(output),\"gen_ai.usage.cache_read.input_tokens\":\(cached)}}" + } + + private func copilotSessionEvent(timestamp: String, input: Int, output: Int, cached: Int) -> String { + "{\"type\":\"assistant.usage\",\"timestamp\":\"\(timestamp)\",\"usage\":{\"inputTokens\":\(input),\"outputTokens\":\(output),\"cacheReadTokens\":\(cached)}}" + } + + private func continueEvent(timestamp: String, input: Int, output: Int) -> String { + "{\"timestamp\":\"\(timestamp)\",\"tokens\":{\"input\":\(input),\"output\":\(output)}}" + } + + private func grokEvent(timestamp: String, tokens: Int) -> String { + "{\"timestamp\":\"\(timestamp)\",\"totalTokens\":\(tokens)}" + } + + private func aiderEvent(time: TimeInterval, prompt: Int, completion: Int) -> String { + "{\"event\":\"message_send\",\"time\":\(time),\"properties\":{\"prompt_tokens\":\(prompt),\"completion_tokens\":\(completion)}}" + } +} diff --git a/scripts/audit-linux-catalog.sh b/scripts/audit-linux-catalog.sh new file mode 100755 index 0000000..b53952e --- /dev/null +++ b/scripts/audit-linux-catalog.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +build_root="$(mktemp -d)" +trap 'rm -rf "$build_root"' EXIT + +swiftc \ + -module-cache-path "$build_root/module-cache" \ + -I "$repo_root/Sources/CSQLite" \ + "$repo_root/Sources/TokenClock/Config/SettingsKeys.swift" \ + "$repo_root/Sources/TokenClock/L10n.swift" \ + "$repo_root/Sources/TokenClock/Linux/LinuxProviderCatalog.swift" \ + "$repo_root/Sources/TokenClock/Linux/LinuxPathConfig.swift" \ + "$repo_root/Sources/TokenClock/Linux/LinuxPathDetector.swift" \ + "$repo_root/scripts/linux-catalog-audit.swift" \ + -lsqlite3 \ + -o "$build_root/linux-catalog-audit" + +"$build_root/linux-catalog-audit" diff --git a/scripts/benchmark-linux-providers.sh b/scripts/benchmark-linux-providers.sh new file mode 100755 index 0000000..a56898c --- /dev/null +++ b/scripts/benchmark-linux-providers.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +build_root="$(mktemp -d)" +trap 'rm -rf "$build_root"' EXIT + +sources=( + "$repo_root/Sources/TokenClock/Config/AppConfig.swift" + "$repo_root/Sources/TokenClock/Config/SettingsKeys.swift" + "$repo_root/Sources/TokenClock/Models/TokenUsage.swift" + "$repo_root/Sources/TokenClock/Services/UsageServiceProtocol.swift" + "$repo_root/Sources/TokenClock/Linux/LinuxProviderCatalog.swift" + "$repo_root/Sources/TokenClock/Linux/LinuxPathConfig.swift" + "$repo_root/Sources/TokenClock/Services/CodexUsageService.swift" + "$repo_root/Sources/TokenClock/Services/ClaudeCodeUsageService.swift" + "$repo_root/Sources/TokenClock/Services/ContinueUsageService.swift" + "$repo_root/Sources/TokenClock/Services/CopilotUsageService.swift" + "$repo_root/Sources/TokenClock/Services/GeminiUsageService.swift" + "$repo_root/Sources/TokenClock/Services/GrokUsageService.swift" + "$repo_root/Sources/TokenClock/Services/OpenClawUsageService.swift" + "$repo_root/Sources/TokenClock/Services/QwenCodeUsageService.swift" + "$repo_root/Sources/TokenClock/Services/AiderUsageService.swift" +) +if [[ -f "$repo_root/Sources/TokenClock/Services/JSONLLineReader.swift" ]]; then + sources+=("$repo_root/Sources/TokenClock/Services/JSONLLineReader.swift") +fi + +swiftc \ + -O \ + -module-cache-path "$build_root/module-cache" \ + -I "$repo_root/Sources/CSQLite" \ + "${sources[@]}" \ + "$repo_root/scripts/linux-provider-benchmark.swift" \ + -lsqlite3 \ + -o "$build_root/linux-provider-benchmark" + +"$build_root/linux-provider-benchmark" diff --git a/scripts/linux-catalog-audit.swift b/scripts/linux-catalog-audit.swift new file mode 100644 index 0000000..0577b20 --- /dev/null +++ b/scripts/linux-catalog-audit.swift @@ -0,0 +1,29 @@ +#if os(Linux) +import Foundation +import CSQLite + +@main +struct LinuxCatalogAudit { + static func main() { + let summary = PathDetector.runFullDetection() + print("service\tdeclared\tpathExists\tparserReadable\tsource\tpath\tparserInput") + for provider in LinuxProviderCatalog.Provider.allCases { + let entry = LinuxProviderCatalog.entry(for: provider) + guard let result = summary.results.first(where: { $0.service == entry.service }) else { continue } + print([ + entry.displayName, + String(result.catalogDeclared), + String(result.pathExists), + String(result.parserReadable), + result.source.rawValue, + result.detectedPath, + entry.parserInput, + ].joined(separator: "\t")) + } + fputs( + "declared=\(summary.declaredCount) paths=\(summary.existingPathCount) readable=\(summary.foundCount) total=\(summary.totalCount)\n", + stderr + ) + } +} +#endif diff --git a/scripts/linux-catalog-smoke.swift b/scripts/linux-catalog-smoke.swift new file mode 100644 index 0000000..13168b0 --- /dev/null +++ b/scripts/linux-catalog-smoke.swift @@ -0,0 +1,227 @@ +#if os(Linux) +import Foundation +import Glibc +import CSQLite + +@main +struct LinuxCatalogSmoke { + private static var failures: [String] = [] + + static func main() throws { + guard let testRoot = ProcessInfo.processInfo.environment["TOKENCLOCK_CATALOG_TEST_ROOT"] else { + fatalError("TOKENCLOCK_CATALOG_TEST_ROOT is required") + } + let root = LinuxProviderCatalog.normalizedPath(testRoot) + try? FileManager.default.removeItem(atPath: root) + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + + expect(LinuxProviderCatalog.Provider.allCases.count == 14, "catalog contains 14 providers") + testPathExpansion(root: root) + try testDetectionStates(root: root) + try testDetectionBudget(root: root) + testSettingsKeyCompatibility(root: root) + testPlatformBoundaries() + + if failures.isEmpty { + print("PASS linux catalog smoke (14 providers, expansion, JSONL/JSON/SQLite, TC_* keys)") + return + } + for failure in failures { fputs("FAIL: \(failure)\n", stderr) } + exit(1) + } + + private static func testPathExpansion(root: String) { + setenv("TC_SMOKE_ROOT", root, 1) + setenv("TC_SMOKE_ROOT_LONG", root + "/long", 1) + expect( + LinuxProviderCatalog.expandEnvironmentVariables( + in: "$TC_SMOKE_ROOT/a:${TC_SMOKE_ROOT_LONG}/b" + ) == "\(root)/a:\(root)/long/b", + "prefix-overlapping $VAR and ${VAR} expand independently" + ) + expect( + LinuxProviderCatalog.normalizedPath("~/catalog-home").hasSuffix("/catalog-home"), + "tilde expands" + ) + let originalXDG = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] + setenv("XDG_CONFIG_HOME", "relative-xdg-is-invalid", 1) + expect( + LinuxProviderCatalog.candidates(for: .cline).allSatisfy { + !$0.path.contains("relative-xdg-is-invalid") + }, + "relative XDG base-directory values are ignored" + ) + if let originalXDG { + setenv("XDG_CONFIG_HOME", originalXDG, 1) + } else { + unsetenv("XDG_CONFIG_HOME") + } + } + + private static func testDetectionStates(root: String) throws { + let home = ProcessInfo.processInfo.environment["HOME"] ?? root + + let codexHome = home + "/.codex" + try FileManager.default.createDirectory(atPath: codexHome, withIntermediateDirectories: true) + var summary = PathDetector.runFullDetection() + guard let emptyCodex = summary.results.first(where: { $0.service == "codex" }) else { + failures.append("Codex catalog result exists") + return + } + expect(emptyCodex.catalogDeclared, "Codex catalog declared") + expect(emptyCodex.pathExists, "empty Codex directory reports pathExists") + expect(!emptyCodex.parserReadable, "empty Codex directory is not parserReadable") + + let openclawLog = home + "/.openclaw/agents/main/sessions/smoke.jsonl" + try write("{\"type\":\"message\"}\n", to: openclawLog) + let geminiJSON = home + "/.gemini/tmp/project/chats/session-smoke.json" + try write("{\"messages\":[]}", to: geminiJSON) + let hermesDB = home + "/.hermes/state.db" + try createHermesDatabase(at: hermesDB) + let opencodeDB = root + "/direct/opencode-custom.sqlite" + try createOpenCodeDatabase(at: opencodeDB) + setenv("OPENCODE_DB", opencodeDB, 1) + let copilotOtel = root + "/direct/copilot-otel.jsonl" + try write( + "{\"attributes\":{\"gen_ai.usage.input_tokens\":1},\"timestamp\":\"2026-08-06T00:00:00Z\"}\n", + to: copilotOtel + ) + setenv("COPILOT_OTEL_FILE_EXPORTER_PATH", copilotOtel, 1) + + summary = PathDetector.runFullDetection() + assertReadable("openclaw", in: summary, format: "JSONL") + assertReadable("gemini", in: summary, format: "JSON") + assertReadable("hermes", in: summary, format: "SQLite") + assertReadable("opencode", in: summary, format: "OPENCODE_DB SQLite") + assertReadable("copilot", in: summary, format: "direct OTel JSONL") + expect(PathConfig.opencodeDatabasePath() == opencodeDB, "OPENCODE_DB reaches parser adapter") + expect(PathConfig.copilotOtelFile() == copilotOtel, "Copilot OTel file reaches parser adapter") + expect(summary.declaredCount == 14, "summary declares all 14 providers") + } + + private static func testSettingsKeyCompatibility(root: String) { + let cases: [(SettingsKey, (String) -> Void)] = [ + (.openclawPath, PathConfig.setOpenclawPath), + (.claudeCodePath, PathConfig.setClaudeCodePath), + (.geminiPath, PathConfig.setGeminiPath), + (.codexPath, PathConfig.setCodexPath), + (.hermesPath, PathConfig.setHermesPath), + (.opencodePath, PathConfig.setOpenCodePath), + (.qwenPath, PathConfig.setQwenPath), + (.copilotPath, PathConfig.setCopilotPath), + (.grokPath, PathConfig.setGrokPath), + (.aiderPath, PathConfig.setAiderPath), + (.antigravityPath, PathConfig.setAntigravityPath), + (.clinePath, PathConfig.setClinePath), + (.continuePath, PathConfig.setContinuePath), + (.cursorAgentPath, PathConfig.setCursorAgentPath), + ] + for (index, item) in cases.enumerated() { + let path = root + "/saved/\(index)" + item.1(path) + expect( + UserDefaults.standard.string(forKey: item.0.rawValue) == path, + "\(item.0.rawValue) matches settings-panel persistence key" + ) + UserDefaults.standard.remove(item.0) + } + } + + private static func testDetectionBudget(root: String) throws { + let stressHome = root + "/stress-claude" + let projects = stressHome + "/projects/project" + try FileManager.default.createDirectory(atPath: projects, withIntermediateDirectories: true) + for index in 0...LinuxProviderCatalog.Provider.allCases.count * 600 { + _ = FileManager.default.createFile( + atPath: projects + "/invalid-\(index).jsonl", + contents: Data() + ) + } + PathConfig.setClaudeCodePath(stressHome) + let startedAt = Date() + let result = PathDetector.runFullDetection().results.first { $0.service == "claudeCode" } + let elapsed = Date().timeIntervalSince(startedAt) + expect(result?.pathExists == true, "bounded detector preserves pathExists") + expect(result?.parserReadable == false, "bounded detector rejects unreadable JSONL tree") + expect(elapsed < 1.5, "full catalog detection remains bounded (\(elapsed)s)") + PathConfig.setClaudeCodePath("") + } + + private static func testPlatformBoundaries() { + for provider in LinuxProviderCatalog.Provider.allCases { + for candidate in LinuxProviderCatalog.candidates(for: provider) { + expect(!candidate.path.contains("/Library/Application Support/"), "no macOS candidate for \(provider.rawValue)") + expect(!candidate.path.localizedCaseInsensitiveContains("AppData"), "no Windows candidate for \(provider.rawValue)") + } + } + } + + private static func assertReadable( + _ service: String, + in summary: PathDetector.DetectionSummary, + format: String + ) { + guard let result = summary.results.first(where: { $0.service == service }) else { + failures.append("\(service) result exists for \(format)") + return + } + expect(result.pathExists, "\(service) \(format) path exists") + expect(result.parserReadable, "\(service) \(format) parser readable") + } + + private static func createHermesDatabase(at path: String) throws { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: path).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var database: OpaquePointer? + guard sqlite3_open(path, &database) == SQLITE_OK, let database else { + throw NSError(domain: "LinuxCatalogSmoke", code: 1) + } + defer { sqlite3_close(database) } + let sql = """ + CREATE TABLE sessions ( + started_at REAL, input_tokens INTEGER, output_tokens INTEGER, + cache_read_tokens INTEGER, cache_write_tokens INTEGER, message_count INTEGER + ); + """ + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw NSError(domain: "LinuxCatalogSmoke", code: 2) + } + } + + private static func createOpenCodeDatabase(at path: String) throws { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: path).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var database: OpaquePointer? + guard sqlite3_open(path, &database) == SQLITE_OK, let database else { + throw NSError(domain: "LinuxCatalogSmoke", code: 3) + } + defer { sqlite3_close(database) } + let sql = """ + CREATE TABLE session ( + tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, + tokens_cache_read INTEGER, tokens_cache_write INTEGER, time_created INTEGER + ); + """ + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw NSError(domain: "LinuxCatalogSmoke", code: 4) + } + } + + private static func write(_ value: String, to path: String) throws { + let url = URL(fileURLWithPath: path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try value.write(to: url, atomically: true, encoding: .utf8) + } + + private static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { failures.append(message) } + } +} +#endif diff --git a/scripts/linux-provider-benchmark.swift b/scripts/linux-provider-benchmark.swift new file mode 100644 index 0000000..300f025 --- /dev/null +++ b/scripts/linux-provider-benchmark.swift @@ -0,0 +1,80 @@ +#if os(Linux) +import Foundation + +struct HourlyForecast: Sendable { + let time: String + let tempC: Int + let emoji: String + let description: String +} + +@main +struct LinuxProviderBenchmark { + static func main() { + let codex = CodexUsageService() + let codexFull = elapsed { codex.fullScan() } + let codexDetail = elapsed { _ = codex.todaySessions() } + let codexIncremental = elapsed { codex.incrementalScan() } + + let claude = ClaudeCodeUsageService() + let claudeFull = elapsed { claude.fullScan() } + let claudeDetail = elapsed { _ = claude.todaySessions() } + let claudeIncremental = elapsed { claude.incrementalScan() } + + let openclaw = OpenClawUsageService() + let openclawFull = elapsed { openclaw.fullScan() } + let openclawDetail = elapsed { _ = openclaw.todaySessions() } + let openclawIncremental = elapsed { openclaw.incrementalScan() } + + let gemini = GeminiUsageService() + let geminiFull = elapsed { gemini.fullScan() } + let geminiDetail = elapsed { _ = gemini.todaySessions() } + let geminiIncremental = elapsed { gemini.incrementalScan() } + + let qwen = QwenCodeUsageService() + let qwenFull = elapsed { qwen.fullScan() } + let qwenDetail = elapsed { _ = qwen.todaySessions() } + let qwenIncremental = elapsed { qwen.incrementalScan() } + + let copilot = CopilotUsageService() + let copilotFull = elapsed { copilot.fullScan() } + let copilotDetail = elapsed { _ = copilot.todaySessions() } + let copilotIncremental = elapsed { copilot.incrementalScan() } + + let continueService = ContinueUsageService() + let continueFull = elapsed { continueService.fullScan() } + let continueDetail = elapsed { _ = continueService.todaySessions() } + let continueIncremental = elapsed { continueService.incrementalScan() } + + let grok = GrokUsageService() + let grokFull = elapsed { grok.fullScan() } + let grokDetail = elapsed { _ = grok.todaySessions() } + let grokIncremental = elapsed { grok.incrementalScan() } + + let aider = AiderUsageService() + let aiderFull = elapsed { aider.fullScan() } + let aiderDetail = elapsed { _ = aider.todaySessions() } + let aiderIncremental = elapsed { aider.incrementalScan() } + + print(String(format: "codex_full_ms=%.3f codex_detail_ms=%.3f codex_incremental_ms=%.3f claude_full_ms=%.3f claude_detail_ms=%.3f claude_incremental_ms=%.3f openclaw_full_ms=%.3f openclaw_detail_ms=%.3f openclaw_incremental_ms=%.3f gemini_full_ms=%.3f gemini_detail_ms=%.3f gemini_incremental_ms=%.3f qwen_full_ms=%.3f qwen_detail_ms=%.3f qwen_incremental_ms=%.3f copilot_full_ms=%.3f copilot_detail_ms=%.3f copilot_incremental_ms=%.3f continue_full_ms=%.3f continue_detail_ms=%.3f continue_incremental_ms=%.3f grok_full_ms=%.3f grok_detail_ms=%.3f grok_incremental_ms=%.3f aider_full_ms=%.3f aider_detail_ms=%.3f aider_incremental_ms=%.3f", + codexFull, codexDetail, codexIncremental, + claudeFull, claudeDetail, claudeIncremental, + openclawFull, openclawDetail, openclawIncremental, + geminiFull, geminiDetail, geminiIncremental, + qwenFull, qwenDetail, qwenIncremental, + copilotFull, copilotDetail, copilotIncremental, + continueFull, continueDetail, continueIncremental, + grokFull, grokDetail, grokIncremental, + aiderFull, aiderDetail, aiderIncremental + )) + } + + private static func elapsed(_ body: () -> Void) -> Double { + let start = ContinuousClock.now + body() + let duration = start.duration(to: .now) + return Double(duration.components.seconds) * 1_000 + + Double(duration.components.attoseconds) / 1_000_000_000_000_000 + } +} +#endif diff --git a/scripts/test-linux-catalog.sh b/scripts/test-linux-catalog.sh new file mode 100755 index 0000000..4e3a4d2 --- /dev/null +++ b/scripts/test-linux-catalog.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(mktemp -d)" +trap 'rm -rf "$test_root"' EXIT + +export HOME="$test_root/home" +export XDG_CONFIG_HOME="$test_root/xdg-config" +export XDG_DATA_HOME="$test_root/xdg-data" +export XDG_STATE_HOME="$test_root/xdg-state" +export TOKENCLOCK_CATALOG_TEST_ROOT="$test_root/fixtures" +mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" + +swiftc \ + -module-cache-path "$test_root/module-cache" \ + -I "$repo_root/Sources/CSQLite" \ + "$repo_root/Sources/TokenClock/Config/SettingsKeys.swift" \ + "$repo_root/Sources/TokenClock/L10n.swift" \ + "$repo_root/Sources/TokenClock/Linux/LinuxProviderCatalog.swift" \ + "$repo_root/Sources/TokenClock/Linux/LinuxPathConfig.swift" \ + "$repo_root/Sources/TokenClock/Linux/LinuxPathDetector.swift" \ + "$repo_root/scripts/linux-catalog-smoke.swift" \ + -lsqlite3 \ + -o "$test_root/linux-catalog-smoke" + +"$test_root/linux-catalog-smoke" diff --git a/scripts/test-linux-performance.sh b/scripts/test-linux-performance.sh new file mode 100755 index 0000000..83aee92 --- /dev/null +++ b/scripts/test-linux-performance.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +swift test --filter UsageServicePerformanceTests + +if [[ "${1:-}" == "--benchmark" ]]; then + TOKENCLOCK_RUN_BENCHMARKS=1 swift test \ + --filter UsageServicePerformanceTests +fi From 1f2aa660e6626415ec6e0abdd2bfc7723912b1d7 Mon Sep 17 00:00:00 2001 From: nxc8335 Date: Sat, 8 Aug 2026 01:37:34 -0700 Subject: [PATCH 4/6] Prepare Linux v1.4.0 --- .github/workflows/linux-appimage.yml | 50 ++++++++++++++++++------ Sources/TokenClock/Linux/LinuxApp.swift | 4 +- Sources/TokenClock/Views/AboutView.swift | 2 +- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/workflows/linux-appimage.yml b/.github/workflows/linux-appimage.yml index 6046443..0a0cfc8 100644 --- a/.github/workflows/linux-appimage.yml +++ b/.github/workflows/linux-appimage.yml @@ -1,16 +1,12 @@ -# 在 tag 推送时为 Linux x86_64 构建 AppImage 并附加到对应 release。 +# 从独立维护的 Linux 分支构建 x86_64 AppImage,并附加到已存在的共享 release。 # -# 流程:release.sh(Mac)构建 macOS 双变体 + 建 release + 推 tag → 本 workflow 被 tag 触发 → -# 在 swift:6.0-jammy 容器(glibc 2.35,覆盖 2022+ 发行版)里 checkout normal(含 Linux 源码) -# → 跑 scripts/build-appimage.sh → 把 TokenClock-x86_64.AppImage + .sha256 附加到该 release。 -# 与 release.sh 之间是异步的(AppImage 约在发版后 5–10 分钟出现);softprops/action-gh-release -# 的 create-or-update 语义避免「release 还没建好」的竞态。 +# linux-v1.4.0 映射到共享 release v1.4.0。构建源码和 workflow 都来自这个不可变的 +# Linux tag,因此 Linux catalog、路径和 GTK 界面无需并入 macOS normal/main 分支。 name: linux-appimage on: push: - tags: ['v*'] - workflow_dispatch: # 允许手动触发,便于在不打 tag 时验证 + tags: ['linux-v*'] permissions: contents: write # 附加资产到 release 需要 @@ -21,10 +17,39 @@ jobs: container: image: swift:6.0-jammy steps: - - name: Checkout normal branch (Linux sources + build script) + - name: Checkout tagged Linux source uses: actions/checkout@v4 - with: - ref: normal + + - name: Map Linux tag to shared release tag + id: release + shell: bash + run: | + linux_tag='${{ github.ref_name }}' + if [[ ! "$linux_tag" =~ ^linux-(v[0-9].*)$ ]]; then + echo "Unexpected Linux tag: $linux_tag" >&2 + exit 1 + fi + echo "tag=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + + - name: Require an existing shared release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + encoded_tag="${RELEASE_TAG//\//%2F}" + api="https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/$encoded_tag" + for attempt in $(seq 1 20); do + if curl -fsS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "$api" >/dev/null; then + exit 0 + fi + sleep 15 + done + echo "Shared release $RELEASE_TAG does not exist" >&2 + exit 1 - name: Build AppImage run: | @@ -34,7 +59,8 @@ jobs: - name: Attach AppImage + SHA256 to the release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} + tag_name: ${{ steps.release.outputs.tag }} + fail_on_unmatched_files: true files: | TokenClock-x86_64.AppImage TokenClock-x86_64.AppImage.sha256 diff --git a/Sources/TokenClock/Linux/LinuxApp.swift b/Sources/TokenClock/Linux/LinuxApp.swift index e37dcac..e2da49e 100644 --- a/Sources/TokenClock/Linux/LinuxApp.swift +++ b/Sources/TokenClock/Linux/LinuxApp.swift @@ -478,9 +478,9 @@ final class LinuxApp: @unchecked Sendable { case "about": if let window { if let path = Bundle.module.path(forResource: "glass_disc", ofType: "png") { - path.withCString { tc_gtk_show_about(window, "v1.3.8", $0) } + path.withCString { tc_gtk_show_about(window, "v1.4.0", $0) } } else { - tc_gtk_show_about(window, "v1.3.8", nil) + tc_gtk_show_about(window, "v1.4.0", nil) } } case "quit": diff --git a/Sources/TokenClock/Views/AboutView.swift b/Sources/TokenClock/Views/AboutView.swift index ba3d6a1..bb7a957 100644 --- a/Sources/TokenClock/Views/AboutView.swift +++ b/Sources/TokenClock/Views/AboutView.swift @@ -5,7 +5,7 @@ struct AboutView: View { let onDone: () -> Void // ⚠️ 发版时同步版本号(与 cli/install.sh 的 RELEASE_TAG、cli/tokenclock 的 CLI_VERSION 一致) - private let version = "v1.3.8" + private let version = "v1.4.0" private let issuesURL = URL(string: "https://github.com/Neo-Isshin/TokenClock/issues")! // 作者 GitHub 头像(置顶居中展示)。github.com/.png 会 302 跳转到 avatars.githubusercontent.com 的真实头像。 private let avatarURL = URL(string: "https://github.com/Neo-Isshin.png")! From 679b3237d06882891c45613aa4e1abae8d9568c1 Mon Sep 17 00:00:00 2001 From: nxc8335 Date: Sat, 8 Aug 2026 01:47:05 -0700 Subject: [PATCH 5/6] Fix Linux release workflow --- .github/workflows/linux-appimage.yml | 33 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/linux-appimage.yml b/.github/workflows/linux-appimage.yml index 0a0cfc8..872dcab 100644 --- a/.github/workflows/linux-appimage.yml +++ b/.github/workflows/linux-appimage.yml @@ -7,6 +7,12 @@ name: linux-appimage on: push: tags: ['linux-v*'] + workflow_dispatch: + inputs: + release_tag: + description: 'Existing shared release tag, for example v1.4.0' + required: true + type: string permissions: contents: write # 附加资产到 release 需要 @@ -24,12 +30,21 @@ jobs: id: release shell: bash run: | - linux_tag='${{ github.ref_name }}' - if [[ ! "$linux_tag" =~ ^linux-(v[0-9].*)$ ]]; then - echo "Unexpected Linux tag: $linux_tag" >&2 - exit 1 + if [[ '${{ github.event_name }}' == 'workflow_dispatch' ]]; then + release_tag='${{ inputs.release_tag }}' + if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected shared release tag: $release_tag" >&2 + exit 1 + fi + else + linux_tag='${{ github.ref_name }}' + if [[ ! "$linux_tag" =~ ^linux-(v[0-9].*)$ ]]; then + echo "Unexpected Linux tag: $linux_tag" >&2 + exit 1 + fi + release_tag="${BASH_REMATCH[1]}" fi - echo "tag=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" - name: Require an existing shared release shell: bash @@ -40,10 +55,10 @@ jobs: encoded_tag="${RELEASE_TAG//\//%2F}" api="https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/$encoded_tag" for attempt in $(seq 1 20); do - if curl -fsS \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "$api" >/dev/null; then + if wget -q \ + --header="Authorization: Bearer $GH_TOKEN" \ + --header="Accept: application/vnd.github+json" \ + -O /dev/null "$api"; then exit 0 fi sleep 15 From 73e226720891b7cc98415ef2341ab78b5fa0ce66 Mon Sep 17 00:00:00 2001 From: nxc8335 Date: Sat, 8 Aug 2026 01:48:40 -0700 Subject: [PATCH 6/6] Use GitHub API for Linux release check --- .github/workflows/linux-appimage.yml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/linux-appimage.yml b/.github/workflows/linux-appimage.yml index 872dcab..62922a2 100644 --- a/.github/workflows/linux-appimage.yml +++ b/.github/workflows/linux-appimage.yml @@ -47,24 +47,20 @@ jobs: echo "tag=$release_tag" >> "$GITHUB_OUTPUT" - name: Require an existing shared release - shell: bash + uses: actions/github-script@v7 env: - GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.release.outputs.tag }} - run: | - encoded_tag="${RELEASE_TAG//\//%2F}" - api="https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/$encoded_tag" - for attempt in $(seq 1 20); do - if wget -q \ - --header="Authorization: Bearer $GH_TOKEN" \ - --header="Accept: application/vnd.github+json" \ - -O /dev/null "$api"; then - exit 0 - fi - sleep 15 - done - echo "Shared release $RELEASE_TAG does not exist" >&2 - exit 1 + with: + script: | + try { + await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: process.env.RELEASE_TAG, + }); + } catch (error) { + core.setFailed(`Shared release ${process.env.RELEASE_TAG} does not exist: ${error.message}`); + } - name: Build AppImage run: |