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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 和中位数,悬停可查看样本数,无有效计时样本时断线。当天只显示已经开始的小时,历史日期显示完整一天,未来日期不显示时段;图表、导出报告和快照 JSON 中的小时均按时间正序排列。
- 「设置」可以选择数据目录。选择包含 `sessions` 的 `.codex` 目录,而不是 `sessions` 自身。
- 「登录启动」默认关闭,仅在手动勾选后设置当前用户的启动项。取消勾选即可移除。
- 第二次启动会打开已有统计面板,不重复运行监视进程。关闭面板前尚未打开过它,也可以从托盘菜单正常退出。Windows 的设置保存在程序旁的 `data/settings.json`;macOS 和 Linux 保存在用户配置目录,避免系统级安装目录不可写。
Expand All @@ -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 输出项计时,状态提示中说明降级。边界日志可能已过期,因此仍是估算值。

Expand Down Expand Up @@ -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、隐私说明和第三方许可证,不包含本机设置、日志或调试符号。
16 changes: 6 additions & 10 deletions src/App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -37,20 +37,16 @@ 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.ShutdownRequested += (_, _) => session.Dispose();
desktop.Exit += (_, _) => session.Dispose();

base.OnFrameworkInitializationCompleted();
}
Expand Down
3 changes: 2 additions & 1 deletion src/App/CodexTpsFloat.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
<PackageReference Include="Avalonia.Desktop" Version="11.3.12"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12"/>
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.12"/>
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.12" Condition="'$(Configuration)' == 'Debug'"/>
<!-- Pin the patched version of Avalonia.FreeDesktop's transitive dependency. -->
<PackageReference Include="Tmds.DBus.Protocol" Version="0.21.3"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Core/CodexTpsFloat.Core.csproj"/>
<!-- Prefer a smaller heap and automatic LOH compaction for the tray utility. -->
<RuntimeHostConfigurationOption Include="System.GC.ConserveMemory" Value="5"/>
</ItemGroup>
</Project>
167 changes: 167 additions & 0 deletions src/App/DesktopSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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 : IDisposable
{
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;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
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;
Dispose();
shutdown();
}

public void Dispose()
{
if (exiting) return;
exiting = true;
var panel = window;
window = null;
panel?.Close();
tray?.Dispose();
if (Application.Current != null)
TrayIcon.SetIcons(Application.Current, new TrayIcons());
}

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;
}
}
}
Loading
Loading