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 @@ - -