From b2e1d190efb85c4cfcf666f434b6b113d47c00b5 Mon Sep 17 00:00:00 2001
From: jinghero <44921326+jinghero@users.noreply.github.com>
Date: Fri, 16 Oct 2026 12:18:08 +0800
Subject: [PATCH 1/3] Fix hourly history display and reclaim memory while in
the tray
---
.github/workflows/ci.yml | 10 ++
.github/workflows/release.yml | 4 +
README.md | 9 +-
src/App/App.axaml.cs | 15 +-
src/App/CodexTpsFloat.App.csproj | 3 +-
src/App/DesktopSession.cs | 161 ++++++++++++++++++++
src/App/HourlyChart.cs | 173 +++++++++++++++++++++
src/App/MainWindow.axaml | 54 +++++--
src/App/MainWindow.axaml.cs | 185 +++++++++++++----------
src/App/packages.lock.json | 11 +-
src/Core/RequestClockReader.cs | 5 +-
src/Core/UsageScanner.cs | 73 +++++++--
tests/AppTests.cs | 249 ++++++++++++++++++++++++++++++-
tests/Program.cs | 41 ++++-
14 files changed, 857 insertions(+), 136 deletions(-)
create mode 100644 src/App/DesktopSession.cs
create mode 100644 src/App/HourlyChart.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6896e5b..299564a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -68,6 +68,16 @@ jobs:
if: runner.os == 'Windows'
run: dotnet run --project tests/TrayLifecycleTests.csproj -c Release --nologo -- --never-show
+ # The shared test job covers Linux. Exercise native runtime/library loading
+ # and the headless window lifecycle on both macOS architectures and Windows.
+ - name: Run core regression tests on Windows and macOS
+ if: runner.os != 'Linux'
+ run: dotnet run --project tests/CoreTests.csproj -c Release --nologo
+
+ - name: Run Avalonia regression tests on Windows and macOS
+ if: runner.os != 'Linux'
+ run: dotnet run --project tests/AppTests.csproj -c Release --nologo
+
- name: Build portable application
if: runner.os == 'Windows'
run: ./build.ps1 -Runtime ${{ matrix.runtime }} -Portable
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a6fb866..8e0302c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,6 +75,10 @@ jobs:
if: runner.os == 'Windows'
run: dotnet run --project tests/TrayLifecycleTests.csproj -c Release --nologo -- --never-show
+ - name: Run Avalonia regression tests on Windows and macOS
+ if: runner.os != 'Linux'
+ run: dotnet run --project tests/AppTests.csproj -c Release --nologo
+
- name: Build portable application
if: runner.os == 'Windows'
run: ./build.ps1 -Runtime ${{ matrix.runtime }} -Portable
diff --git a/README.md b/README.md
index d57d91d..9eae444 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,11 @@ macOS 首次运行时如果被 Gatekeeper 阻止,请在 Finder 中右键 `Code
便携构建使用单文件程序,并关闭内部程序集压缩,以减少运行时解压所需的内存;下载时由外层压缩包压缩。解压后的程序较大,其中包含 .NET 运行时及各平台的界面依赖。
- 默认读取 `CODEX_HOME`;未设置时读取当前用户的 `.codex`。同时统计 `sessions` 与 `archived_sessions`。
-- 托盘图标使用透明底的统计条形图;macOS 菜单栏图标随系统自动适配深浅色。悬停显示当前生成速度估算。Windows 启动后只显示系统托盘图标,单击打开或收起统计面板,右键可打开面板、立即刷新或退出;macOS 点击图标弹出菜单,可选择「打开统计面板」。面板展开时也不出现任务栏窗口按钮或 macOS Dock 图标。macOS 上菜单栏图标可能受系统或第三方工具影响而不可见,因此启动会直接显示面板,之后可用减号收起到菜单栏。Linux 无法可靠检测托盘宿主,启动时直接显示面板并禁用收起,确保没有托盘的桌面也能操作和退出。
+- 托盘图标使用透明底的统计条形图;macOS 菜单栏图标随系统自动适配深浅色。面板打开时悬停显示当前生成速度估算,收起后显示「已暂停」。Windows 启动后只显示系统托盘图标,单击打开或收起统计面板,右键可打开面板、立即刷新或退出;macOS 点击图标弹出菜单,可选择「打开统计面板」。面板展开时也不出现任务栏窗口按钮或 macOS Dock 图标。macOS 上菜单栏图标可能受系统或第三方工具影响而不可见,因此启动会直接显示面板,之后可用减号收起到菜单栏。Linux 无法可靠检测托盘宿主,启动时直接显示面板并禁用收起,确保没有托盘的桌面也能操作和退出。
- 面板可拖动,蓝色菱形切换置顶,减号收起到托盘。电源按钮退出整个工具。
-- 切换 1 分钟、5 分钟、30 分钟、1 小时窗口,或筛选模型。自动刷新支持 5、15、30、60 秒。
+- 切换 1 分钟、5 分钟、30 分钟、1 小时窗口,或筛选模型。面板打开时自动刷新支持 5、15、30、60 秒;缩回托盘后暂停读取和刷新,销毁统计窗口及其渲染器,释放扫描缓存、统计快照与分时图表数据,并执行一次完整回收,托盘提示「已暂停」。重新打开时重建面板、重新扫描并恢复刷新,同时保留日期、模型筛选和展开状态;暂停期间日志仍保留在磁盘,统计不会丢失。托盘的「立即刷新」会打开面板后刷新。
- 展开「今日分时」,可以选择其他日期并导出中文 Markdown 或 JSON 报告。时区固定为北京时间 UTC+8。
+- 分时折线图按时间从左到右显示加权 TPS 和中位数,悬停可查看样本数,无有效计时样本时断线。当天只显示已经开始的小时,历史日期显示完整一天,未来日期不显示时段;导出报告中的小时按时间倒排。
- 「设置」可以选择数据目录。选择包含 `sessions` 的 `.codex` 目录,而不是 `sessions` 自身。
- 「登录启动」默认关闭,仅在手动勾选后设置当前用户的启动项。取消勾选即可移除。
- 第二次启动会打开已有统计面板,不重复运行监视进程。关闭面板前尚未打开过它,也可以从托盘菜单正常退出。Windows 的设置保存在程序旁的 `data/settings.json`;macOS 和 Linux 保存在用户配置目录,避免系统级安装目录不可写。
@@ -42,7 +43,7 @@ macOS 首次运行时如果被 Gatekeeper 阻止,请在 Finder 中右键 `Code
## 数据与运行方式
-工具只读日志,不修改 Codex 配置、日志或请求,不需要 API Key,不调用网络服务。读取在后台线程完成,按行解析 UTF-8 字节,避免整份日志的字符串副本;读取缓冲区归还前会清空。后续刷新只读追加内容;完整 JSONL 尾行才计入。按 `response_id` 跨文件去重;归档、重复刷新不会重复计数。日志截断或替换后重建统计。面板收起时不更新历史表格,重新打开后显示当前统计。
+工具只读日志,不修改 Codex 配置、日志或请求,不需要 API Key,不调用网络服务。读取在后台线程完成,按行解析 UTF-8 字节,避免整份日志的字符串副本;常规读取缓冲区归还缓存池前会清空,超长日志使用的临时大缓冲不驻留缓存池。面板打开期间后续刷新只读追加内容;完整 JSONL 尾行才计入。按 `response_id` 跨文件去重;归档、重复刷新不会重复计数。日志截断、替换或从托盘重新打开后重建统计。面板收起时取消扫描并释放统计数据,托盘保留打开、刷新和退出功能。
如果当前数据目录存在 `logs_*.sqlite`,以只读方式增量读取请求鉴权边界,用于排除失败尝试遗留的输出项。诊断库缺失、被锁定或格式不兼容时仍可使用 JSONL 输出项计时,状态提示中说明降级。边界日志可能已过期,因此仍是估算值。
@@ -94,4 +95,4 @@ git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3
```
-推送标签后,GitHub Actions 会校验标签与项目版本一致,运行核心测试和 Avalonia 无界面回归测试;Windows 额外运行旧版 WPF 隐藏托盘生命周期回归测试。这些测试不覆盖各平台原生托盘行为,后者仍需在各桌面环境进行手动验收。发布矩阵构建 Windows x64、macOS ARM64、macOS x64 和 Linux x64 便携包,解压后执行快照 smoke test,并生成 SHA-256 校验文件。全部平台成功后,工作流创建公开 Release 并上传所有压缩包与校验文件。打包只包含程序、README、隐私说明和第三方许可证,不包含本机设置、日志或调试符号。
+推送标签后,GitHub Actions 会校验标签与项目版本一致,在 Linux、Windows 和两种 macOS 架构上运行核心测试和 Avalonia 无界面回归测试;Windows 额外运行旧版 WPF 隐藏托盘生命周期回归测试。拉取请求也运行相同的平台回归和四平台便携构建。这些测试不覆盖各平台原生托盘行为,后者仍需在各桌面环境进行手动验收。发布矩阵构建 Windows x64、macOS ARM64、macOS x64 和 Linux x64 便携包,解压后执行快照 smoke test,并生成 SHA-256 校验文件。全部平台成功后,工作流创建公开 Release 并上传所有压缩包与校验文件。打包只包含程序、README、隐私说明和第三方许可证,不包含本机设置、日志或调试符号。
diff --git a/src/App/App.axaml.cs b/src/App/App.axaml.cs
index b544886..3094135 100644
--- a/src/App/App.axaml.cs
+++ b/src/App/App.axaml.cs
@@ -24,7 +24,7 @@ public static void ShowExistingPanel()
if (!show.Wait(TimeSpan.FromSeconds(2)))
throw new TimeoutException("统计面板未响应显示请求,请稍后重试。");
}
- MainWindow? window;
+ DesktopSession? session;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -37,20 +37,15 @@ public override void OnFrameworkInitializationCompleted()
}
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
- window = new MainWindow(Options.CapturePath, Options.CodexHome);
+ session = new DesktopSession(() => desktop.Shutdown(), Options.CapturePath, Options.CodexHome);
desktop.Startup += async (_, _) =>
{
- await window.StartAsync();
+ await session.StartAsync();
if (Options.CapturePath == null)
- panelReady.TrySetResult(window.ShowPanel);
- if (Options.CapturePath != null)
- await window.CaptureAndCloseAsync(Options.CapturePath);
- // Avalonia can create a tray icon on macOS without macOS displaying it,
- // so there is no reliable way to detect an unusable menu bar item.
- else if (OperatingSystem.IsMacOS() || !window.HasTray)
- window.ShowPanel();
+ panelReady.TrySetResult(session.ShowPanel);
};
+ desktop.Exit += (_, _) => session.Exit();
base.OnFrameworkInitializationCompleted();
}
diff --git a/src/App/CodexTpsFloat.App.csproj b/src/App/CodexTpsFloat.App.csproj
index 8101cf8..4c399ca 100644
--- a/src/App/CodexTpsFloat.App.csproj
+++ b/src/App/CodexTpsFloat.App.csproj
@@ -17,12 +17,13 @@
-
+
+
diff --git a/src/App/DesktopSession.cs b/src/App/DesktopSession.cs
new file mode 100644
index 0000000..466d1e6
--- /dev/null
+++ b/src/App/DesktopSession.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Runtime;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Rendering.Composition;
+
+namespace CodexTpsFloat.App;
+
+// The tray outlives each statistics window. Keeping a hidden Window would also
+// retain its native handle, composition target and control tree.
+public sealed class DesktopSession
+{
+ readonly Action shutdown;
+ readonly string? capturePath, codexHome;
+ readonly TrayIcon? tray;
+ MainWindow? window;
+ PanelViewState? viewState;
+ Task reclamationTask = Task.CompletedTask;
+ int viewVersion;
+ bool exiting;
+
+ public DesktopSession(Action shutdown, string? capturePath = null, string? codexHome = null)
+ {
+ this.shutdown = shutdown;
+ this.capturePath = capturePath;
+ this.codexHome = codexHome;
+ tray = OperatingSystem.IsBrowser() ? null : CreateTrayIcon();
+ if (tray != null && Application.Current != null)
+ TrayIcon.SetIcons(Application.Current, new TrayIcons { tray });
+ }
+
+ // Linux may create a tray object without a status-notifier host. macOS also
+ // opens the panel at startup because its menu bar item can be suppressed.
+ public bool HasTray => tray != null && !OperatingSystem.IsLinux();
+
+ public async Task StartAsync()
+ {
+ SetToolTip("Codex TPS · 已暂停");
+ if (capturePath != null)
+ {
+ ShowPanel();
+ await window!.CaptureAndCloseAsync(capturePath);
+ }
+ else if (OperatingSystem.IsMacOS() || !HasTray)
+ ShowPanel();
+ }
+
+ public void ShowPanel()
+ {
+ if (exiting) return;
+ if (window == null)
+ {
+ viewVersion++;
+ window = new MainWindow(capturePath, codexHome, this, viewState);
+ window.Closed += PanelClosed;
+ // Start while hidden: initialization is synchronous and performs no scan.
+ _ = window.StartAsync();
+ }
+ window.ShowPanel();
+ }
+
+ public void HidePanel()
+ {
+ if (window == null) return;
+ var panel = window;
+ var compositor = ElementComposition.GetElementVisual(panel)?.Compositor;
+ viewState = panel.RememberView();
+ window = null;
+ panel.Close(); // Dispose the native window and renderer, not just visibility.
+ SetToolTip("Codex TPS · 已暂停");
+ reclamationTask = ReclaimAsync(panel.PendingRefresh, compositor, ++viewVersion);
+ }
+
+ async Task ReclaimAsync(Task pendingRefresh, Compositor? compositor, int version)
+ {
+ await pendingRefresh;
+ pendingRefresh = Task.CompletedTask; // Do not root the closed window through its scan task.
+ // Detaching the control tree queues composition changes after the native
+ // target is disposed. Drain those references before collecting the window.
+ if (compositor != null) await compositor.RequestCommitAsync();
+ await Task.Yield(); // Let Close and the initiating click handler unwind.
+ if (window != null || exiting || version != viewVersion) return;
+ await Task.Run(static () =>
+ {
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true);
+ // Finalizable rendering resources can keep the last control tree alive
+ // through the first collection. Finish them before compacting the heap.
+ GC.WaitForPendingFinalizers();
+ GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
+ // Aggressive also decommits unused heap pages; Forced may retain them
+ // for a future scan even after the objects themselves were collected.
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true);
+ });
+ }
+
+ void PanelClosed(object? sender, EventArgs e)
+ {
+ if (sender is not MainWindow panel) return;
+ panel.Closed -= PanelClosed;
+ // HidePanel detaches it first; a normal close still exits the application.
+ if (ReferenceEquals(window, panel)) Exit();
+ }
+
+ public void Exit()
+ {
+ if (exiting) return;
+ exiting = true;
+ var panel = window;
+ window = null;
+ panel?.Close();
+ tray?.Dispose();
+ if (Application.Current != null)
+ TrayIcon.SetIcons(Application.Current, new TrayIcons());
+ shutdown();
+ }
+
+ internal void SetToolTip(string text)
+ {
+ if (tray != null) tray.ToolTipText = text;
+ }
+
+ TrayIcon? CreateTrayIcon()
+ {
+ try
+ {
+ var menu = new NativeMenu();
+ var open = new NativeMenuItem("打开统计面板");
+ open.Click += (_, _) => ShowPanel();
+ var refresh = new NativeMenuItem("立即刷新");
+ refresh.Click += async (_, _) =>
+ {
+ if (window == null) ShowPanel();
+ else await window.RefreshNowAsync();
+ };
+ var exit = new NativeMenuItem("退出");
+ exit.Click += (_, _) => Exit();
+ menu.Items.Add(open);
+ menu.Items.Add(refresh);
+ menu.Items.Add(exit);
+ var icon = new TrayIcon
+ {
+ Icon = TrayIconFactory.Create(),
+ ToolTipText = "Codex TPS · 已暂停",
+ Menu = menu,
+ IsVisible = true
+ };
+ MacOSProperties.SetIsTemplateIcon(icon, OperatingSystem.IsMacOS());
+ icon.Clicked += (_, _) =>
+ {
+ if (window != null && HasTray) HidePanel();
+ else ShowPanel();
+ };
+ return icon;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/App/HourlyChart.cs b/src/App/HourlyChart.cs
new file mode 100644
index 0000000..a617edf
--- /dev/null
+++ b/src/App/HourlyChart.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Media;
+
+namespace CodexTpsFloat.App;
+
+public sealed class HourlyChart : Canvas
+{
+ static readonly IBrush WeightedBrush = new SolidColorBrush(Color.Parse("#007AFF"));
+ static readonly IBrush MedianBrush = new SolidColorBrush(Color.Parse("#AF52DE"));
+ static readonly IBrush MutedBrush = new SolidColorBrush(Color.Parse("#85858D"));
+ static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#EEEEF2"));
+ static readonly IBrush HoverBrush = new SolidColorBrush(Color.FromArgb(12, 0, 122, 255));
+
+ public IReadOnlyList Hours { get; private set; } = Array.Empty();
+
+ public HourlyChart()
+ {
+ ClipToBounds = true;
+ Background = Brushes.Transparent;
+ SizeChanged += (_, _) => DrawChart();
+ }
+
+ public void SetHours(IReadOnlyList hours)
+ {
+ Hours = hours.OrderBy(h => h.Hour, StringComparer.Ordinal).ToArray();
+ DrawChart();
+ }
+
+ public void Clear()
+ {
+ Hours = Array.Empty();
+ Children.Clear();
+ }
+
+ void Label(string text, double x, double y, double width, TextAlignment alignment = TextAlignment.Center)
+ {
+ var label = new TextBlock
+ {
+ Text = text,
+ Width = width,
+ FontSize = 10,
+ Foreground = MutedBrush,
+ TextAlignment = alignment,
+ IsHitTestVisible = false
+ };
+ SetLeft(label, x);
+ SetTop(label, y);
+ Children.Add(label);
+ }
+
+ static string Value(double? value) => value?.ToString("F1", CultureInfo.InvariantCulture) ?? "—";
+
+ void DrawChart()
+ {
+ Children.Clear();
+ const double left = 38, right = 22, top = 18, bottom = 26;
+ double width = Bounds.Width - left - right, height = Bounds.Height - top - bottom;
+ if (width <= 0 || height <= 0) return;
+
+ double maximum = Hours.SelectMany(h => new[] { h.Weighted, h.Median })
+ .Where(v => v.HasValue).Select(v => v!.Value).DefaultIfEmpty(0).Max();
+ double rawStep = Math.Max(1, maximum) / 4;
+ double magnitude = Math.Pow(10, Math.Floor(Math.Log10(rawStep)));
+ double normalized = rawStep / magnitude;
+ double step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
+ double ceiling = step * 4;
+ double X(int index) => left + (Hours.Count <= 1 ? width / 2 : index * width / (Hours.Count - 1));
+ double Y(double value) => top + height * (1 - value / ceiling);
+
+ Label("TPS", 0, 0, left - 6, TextAlignment.Right);
+ for (int tick = 0; tick <= 4; tick++)
+ {
+ double value = step * tick, y = Y(value);
+ Children.Add(new Line
+ {
+ StartPoint = new Point(left, y),
+ EndPoint = new Point(left + width, y),
+ Stroke = GridBrush,
+ StrokeThickness = 1,
+ IsHitTestVisible = false
+ });
+ string text = value >= 1000
+ ? (value / 1000).ToString("0.#", CultureInfo.InvariantCulture) + "k"
+ : value.ToString("0.##", CultureInfo.InvariantCulture);
+ Label(text, 0, y - 7, left - 7, TextAlignment.Right);
+ }
+ int ticks = Math.Min(5, Hours.Count);
+ for (int tick = 0; tick < ticks; tick++)
+ {
+ int index = ticks == 1 ? 0 : (int)Math.Round(tick * (Hours.Count - 1d) / (ticks - 1));
+ Label(Hours[index].Hour[..5], X(index) - 22, top + height + 7, 44);
+ }
+
+ DrawSeries(h => h.Weighted, WeightedBrush, false);
+ DrawSeries(h => h.Median, MedianBrush, true);
+
+ if (!Hours.Any(h => h.Weighted.HasValue || h.Median.HasValue))
+ Label("暂无有效计时样本", left, top + height / 2 - 8, width);
+
+ for (int index = 0; index < Hours.Count; index++)
+ {
+ double start = index == 0 ? left : (X(index - 1) + X(index)) / 2;
+ double end = index == Hours.Count - 1 ? left + width : (X(index) + X(index + 1)) / 2;
+ var region = new Rectangle { Width = end - start, Height = height, Fill = Brushes.Transparent };
+ SetLeft(region, start);
+ SetTop(region, top);
+ var hour = Hours[index];
+ ToolTip.SetTip(region, $"{hour.Hour}(北京时间)\n样本 {hour.Samples}\n加权 TPS:{Value(hour.Weighted)}\n中位数:{Value(hour.Median)}");
+ region.PointerEntered += (_, _) => region.Fill = HoverBrush;
+ region.PointerExited += (_, _) => region.Fill = Brushes.Transparent;
+ Children.Add(region);
+ }
+
+ void DrawSeries(Func select, IBrush brush, bool dashed)
+ {
+ var segment = new List();
+ var markers = new List();
+ for (int index = 0; index < Hours.Count; index++)
+ {
+ if (select(Hours[index]) is double value)
+ {
+ var point = new Point(X(index), Y(value));
+ segment.Add(point);
+ markers.Add(point);
+ }
+ else FinishSegment();
+ }
+ FinishSegment();
+ foreach (var point in markers)
+ {
+ double diameter = dashed ? 5 : 8;
+ var marker = new Ellipse
+ {
+ Width = diameter,
+ Height = diameter,
+ Fill = dashed ? brush : Brushes.White,
+ Stroke = brush,
+ StrokeThickness = dashed ? 1 : 2,
+ IsHitTestVisible = false
+ };
+ SetLeft(marker, point.X - diameter / 2);
+ SetTop(marker, point.Y - diameter / 2);
+ Children.Add(marker);
+ }
+
+ void FinishSegment()
+ {
+ if (segment.Count > 1)
+ {
+ var line = new Polyline
+ {
+ Points = segment.ToArray(),
+ Stroke = brush,
+ StrokeThickness = 2,
+ StrokeLineCap = PenLineCap.Round,
+ StrokeJoin = PenLineJoin.Round,
+ IsHitTestVisible = false
+ };
+ if (dashed) line.StrokeDashArray = new AvaloniaList { 4, 3 };
+ Children.Add(line);
+ }
+ segment.Clear();
+ }
+ }
+ }
+}
diff --git a/src/App/MainWindow.axaml b/src/App/MainWindow.axaml
index 0ee629b..13419ea 100644
--- a/src/App/MainWindow.axaml
+++ b/src/App/MainWindow.axaml
@@ -1,6 +1,6 @@
+
+
@@ -233,21 +252,26 @@
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs
index 023057e..78ce8eb 100644
--- a/src/App/MainWindow.axaml.cs
+++ b/src/App/MainWindow.axaml.cs
@@ -4,10 +4,10 @@
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
@@ -21,47 +21,61 @@ namespace CodexTpsFloat.App;
public partial class MainWindow : Window
{
readonly DispatcherTimer timer = new();
- readonly TrayIcon? tray;
+ readonly DesktopSession? session;
readonly IAutostartService autostart = Autostart.Create();
readonly SettingsStore settingsStore = OperatingSystem.IsWindows()
? SettingsStore.ForPortableApp(AppContext.BaseDirectory)
: SettingsStore.ForUserProfile();
readonly string? capturePath;
PanelSettings settings;
- UsageScanner scanner;
- RequestClockReader requestClocks;
+ UsageScanner? scanner;
+ RequestClockReader? requestClocks;
UsageSnapshot? snapshot;
+ CancellationTokenSource? refreshCancellation;
+ Task refreshTask = Task.CompletedTask;
+ DateTime lastSnapshotDate = BeijingNow.Date;
bool refreshing, initialized, closing, positioned, refreshPending;
readonly List models = new() { "全部模型" };
public MainWindow() : this(null, null) { }
public MainWindow(string? capturePath = null, string? codexHome = null)
+ : this(capturePath, codexHome, null, null) { }
+
+ internal MainWindow(string? capturePath, string? codexHome, DesktopSession? session, PanelViewState? viewState)
{
this.capturePath = capturePath;
- settings = settingsStore.Load();
- if (codexHome != null) settings.CodexHome = Path.GetFullPath(codexHome);
+ this.session = session;
+ settings = viewState?.Settings ?? settingsStore.Load();
+ if (viewState == null && codexHome != null) settings.CodexHome = Path.GetFullPath(codexHome);
settings.Normalize();
- scanner = new(settings.CodexHome);
- requestClocks = new(settings.CodexHome);
-
InitializeComponent();
InitializeState();
- tray = OperatingSystem.IsBrowser() ? null : CreateTrayIcon();
- HideButton.IsVisible = HasTray;
- if (tray != null && Application.Current != null)
- TrayIcon.SetIcons(Application.Current, new TrayIcons { tray });
+ HideButton.IsVisible = session?.HasTray == true;
+ if (viewState != null)
+ {
+ lastSnapshotDate = viewState.LastSnapshotDate;
+ DateBox.SelectedDate = viewState.Date;
+ if (!models.Contains(viewState.Model)) models.Add(viewState.Model);
+ ModelBox.SelectedItem = viewState.Model;
+ HistoryPanel.IsVisible = viewState.History;
+ HistoryButton.Content = viewState.History ? "分时统计 ⌄" : "今日分时 ›";
+ SettingsPanel.IsVisible = viewState.SettingsExpanded;
+ }
Opened += (_, _) => PositionPanel();
Closing += (_, _) => DisposeWindow();
+ PropertyChanged += VisibilityChanged;
}
static DateTimeOffset BeijingNow => DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
- // Linux can create the object without a status-notifier host. Keep the panel
- // accessible until Avalonia exposes whether the host actually displays it.
- public bool HasTray => tray != null && !OperatingSystem.IsLinux();
+ internal Task PendingRefresh => refreshTask;
+ internal Task RefreshNowAsync() => RefreshAsync();
+ internal PanelViewState RememberView() => new(settings, lastSnapshotDate,
+ DateBox.SelectedDate ?? BeijingNow.Date, ModelBox.SelectedItem as string ?? "全部模型",
+ HistoryPanel.IsVisible, SettingsPanel.IsVisible);
void InitializeState()
{
@@ -89,43 +103,11 @@ void InitializeState()
timer.Tick += async (_, _) => await RefreshAsync();
}
- TrayIcon? CreateTrayIcon()
- {
- try
- {
- var menu = new NativeMenu();
- var open = new NativeMenuItem("打开统计面板");
- open.Click += (_, _) => ShowPanel();
- var refresh = new NativeMenuItem("立即刷新");
- refresh.Click += async (_, _) => await RefreshAsync();
- var exit = new NativeMenuItem("退出");
- exit.Click += (_, _) => Close();
- menu.Items.Add(open);
- menu.Items.Add(refresh);
- menu.Items.Add(exit);
-
- var icon = new TrayIcon
- {
- Icon = TrayIconFactory.Create(),
- ToolTipText = "Codex TPS 悬浮窗",
- Menu = menu,
- IsVisible = true
- };
- // Let AppKit tint the transparent glyph for light/dark menu bars.
- MacOSProperties.SetIsTemplateIcon(icon, OperatingSystem.IsMacOS());
- icon.Clicked += (_, _) => TogglePanel();
- return icon;
- }
- catch (Exception)
- {
- return null;
- }
- }
-
public async Task StartAsync()
{
if (initialized) return;
initialized = true;
+ if (!IsVisible) { SuspendMonitoring(); return; }
timer.Start();
await RefreshAsync();
}
@@ -133,6 +115,7 @@ public async Task StartAsync()
public async Task CaptureAndCloseAsync(string path)
{
ShowPanel();
+ await RefreshAsync();
await Dispatcher.UIThread.InvokeAsync(() =>
{
Capture(path);
@@ -164,31 +147,58 @@ public void ShowPanel()
Activate();
}
- void TogglePanel()
+ async void VisibilityChanged(object? sender, AvaloniaPropertyChangedEventArgs change)
{
- if (IsVisible && HasTray) Hide();
- else ShowPanel();
+ if (change.Property != IsVisibleProperty || !initialized || closing) return;
+ if (!IsVisible) { SuspendMonitoring(); return; }
+ timer.Start();
+ await RefreshAsync();
}
- async Task RefreshAsync()
+ void SuspendMonitoring()
+ {
+ timer.Stop();
+ refreshPending = false;
+ refreshCancellation?.Cancel();
+ scanner = null;
+ requestClocks = null;
+ snapshot = null;
+ HourChart.Clear();
+ HeroValue.Text = "—";
+ StatusText.Text = "已暂停";
+ StatusDot.Fill = Brushes.Gray;
+ ToolTip.SetTip(StatusText, "缩回托盘后暂停统计,打开面板时重新读取日志。");
+ session?.SetToolTip("Codex TPS · 已暂停");
+ }
+
+ Task RefreshAsync()
+ {
+ if (closing || !IsVisible) return Task.CompletedTask;
+ if (refreshing) { refreshPending = true; return refreshTask; }
+ refreshTask = RefreshCoreAsync();
+ return refreshTask;
+ }
+
+ async Task RefreshCoreAsync()
{
- if (closing) return;
- if (refreshing) { refreshPending = true; return; }
refreshing = true;
RefreshButton.IsEnabled = false;
StatusText.Text = "读取中";
+ using var cancellation = new CancellationTokenSource();
+ refreshCancellation = cancellation;
try
{
- var activeScanner = scanner;
- var activeClocks = requestClocks;
+ var activeScanner = scanner ??= new(settings.CodexHome);
+ var activeClocks = requestClocks ??= new(settings.CodexHome);
var next = await Task.Run(() =>
{
- activeScanner.RequestStarts = activeClocks.Refresh();
- return activeScanner.Refresh(BeijingNow);
- });
- if (closing || !ReferenceEquals(activeScanner, scanner)) return;
- bool followToday = snapshot == null || DateBox.SelectedDate == snapshot.GeneratedAt.Date;
+ activeScanner.RequestStarts = activeClocks.Refresh(cancellation.Token);
+ return activeScanner.Refresh(BeijingNow, cancellation.Token);
+ }, cancellation.Token);
+ if (closing || !IsVisible || cancellation.IsCancellationRequested || !ReferenceEquals(activeScanner, scanner)) return;
+ bool followToday = DateBox.SelectedDate == lastSnapshotDate;
snapshot = next;
+ lastSnapshotDate = next.GeneratedAt.Date;
if (followToday) DateBox.SelectedDate = next.GeneratedAt.Date;
string model = ModelBox.SelectedItem as string ?? "全部模型";
@@ -213,17 +223,20 @@ async Task RefreshAsync()
$"{next.Files} 个文件;{next.InvalidLines} 条异常记录;{next.UnsupportedFiles} 个旧格式文件;{next.ReadFailures} 个读取失败。\nJSONL 扫描 {next.ScanMilliseconds:F0} 毫秒。" +
(activeClocks.Warning == null ? "" : "\n" + activeClocks.Warning));
}
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { }
catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException)
{
+ if (closing || !IsVisible || cancellation.IsCancellationRequested) return;
StatusText.Text = "读取失败";
StatusDot.Fill = Brushes.OrangeRed;
ToolTip.SetTip(StatusText, e.Message);
}
finally
{
+ if (ReferenceEquals(refreshCancellation, cancellation)) refreshCancellation = null;
refreshing = false;
RefreshButton.IsEnabled = true;
- if (refreshPending && !closing)
+ if (refreshPending && !closing && IsVisible)
{
refreshPending = false;
await RefreshAsync();
@@ -253,15 +266,15 @@ void RenderMetrics()
TtftValue.Text = m.TurnFirstTokenMs.HasValue
? $"轮次首字 {m.TurnFirstTokenMs.Value / 1000:F1} 秒"
: "轮次首字 —";
- if (tray != null) tray.ToolTipText = $"Codex TPS:{HeroValue.Text} 词元/秒(估算)";
+ session?.SetToolTip($"Codex TPS:{HeroValue.Text} 词元/秒(估算)");
RenderHistory();
}
void RenderHistory()
{
if (snapshot == null || !IsVisible || !HistoryPanel.IsVisible) return;
- HourGrid.ItemsSource = UsageScanner.Hours(
- snapshot, (DateBox.SelectedDate ?? BeijingNow).Date, ModelFilter);
+ HourChart.SetHours(UsageScanner.Hours(
+ snapshot, (DateBox.SelectedDate ?? BeijingNow.Date).Date, ModelFilter));
}
void SaveSettings()
@@ -287,7 +300,8 @@ static bool HasInteractiveAncestor(Visual source) =>
source.FindAncestorOfType