From 0531a6491d089cef1d171ac1c172b38303fbb374 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 16:12:55 +0800 Subject: [PATCH 01/49] refactor: Extract platform-independent scanner into CodexTpsFloat.Core --- src/CodexTpsFloat.csproj | 4 ++- src/Core/CodexTpsFloat.Core.csproj | 11 ++++++ src/{ => Core}/RequestClockReader.cs | 0 src/{ => Core}/UsageScanner.cs | 0 src/Core/packages.lock.json | 53 ++++++++++++++++++++++++++++ src/packages.lock.json | 22 ++++-------- tests/CoreTests.csproj | 2 +- 7 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 src/Core/CodexTpsFloat.Core.csproj rename src/{ => Core}/RequestClockReader.cs (100%) rename src/{ => Core}/UsageScanner.cs (100%) create mode 100644 src/Core/packages.lock.json diff --git a/src/CodexTpsFloat.csproj b/src/CodexTpsFloat.csproj index 223ff90..101aac0 100644 --- a/src/CodexTpsFloat.csproj +++ b/src/CodexTpsFloat.csproj @@ -10,6 +10,8 @@ 1.1.3 true app.manifest + true - + + diff --git a/src/Core/CodexTpsFloat.Core.csproj b/src/Core/CodexTpsFloat.Core.csproj new file mode 100644 index 0000000..6895943 --- /dev/null +++ b/src/Core/CodexTpsFloat.Core.csproj @@ -0,0 +1,11 @@ + + + net10.0 + disable + enable + CodexTpsFloat + CodexTpsFloat.Core + true + + + diff --git a/src/RequestClockReader.cs b/src/Core/RequestClockReader.cs similarity index 100% rename from src/RequestClockReader.cs rename to src/Core/RequestClockReader.cs diff --git a/src/UsageScanner.cs b/src/Core/UsageScanner.cs similarity index 100% rename from src/UsageScanner.cs rename to src/Core/UsageScanner.cs diff --git a/src/Core/packages.lock.json b/src/Core/packages.lock.json new file mode 100644 index 0000000..e11fa5f --- /dev/null +++ b/src/Core/packages.lock.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.Data.Sqlite": { + "type": "Direct", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "w37nz3WFmDtRfXxWEwIXX2OgqDRiwhD1JGbFwfpFCF8GkCrV/yO6Y2JF5058J2RGoI2sCcdn02wrk0yEcekWcw==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.13", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.13", + "SQLitePCLRaw.core": "2.1.13" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.13", + "contentHash": "Gge8XsSCOwcbkbdmt2UDAe6kIv9Q0vA6yb3wsTStjjexaMFLTWfc/1gH4kbhJ+zWiXRTRA4lG2sOQNVfir204w==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.13" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "NlDixlC7uHmo+ojXRgkNvN0tQtVGpLDZ3x2OpgNj+G07VHh5cXSszFDrAiC4BpUiPwkcaeuQkMV9WQ123xVtmQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.13", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.13" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "2joofoinuHBFs0nMX3nKuXgxpXryRkCOdM2Ua6HBqQLLy6IW+5f28ZI+HAvMyF8vG7XfNz/lwfBfE6V4juru8Q==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "ZU60dSqoSNRe49KcQDUmOVgdRtjITOssVXFtiISs+zJXDI4D2Sndwpn2NOtzDQNPu0ZAvpoLPQ+GLKv6iEZn8Q==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.13" + } + } + } + } +} \ No newline at end of file diff --git a/src/packages.lock.json b/src/packages.lock.json index 2003c23..b61145f 100644 --- a/src/packages.lock.json +++ b/src/packages.lock.json @@ -3,8 +3,7 @@ "dependencies": { "net10.0-windows7.0": { "Microsoft.Data.Sqlite": { - "type": "Direct", - "requested": "[10.0.13, )", + "type": "Transitive", "resolved": "10.0.13", "contentHash": "w37nz3WFmDtRfXxWEwIXX2OgqDRiwhD1JGbFwfpFCF8GkCrV/yO6Y2JF5058J2RGoI2sCcdn02wrk0yEcekWcw==", "dependencies": { @@ -13,12 +12,6 @@ "SQLitePCLRaw.core": "2.1.13" } }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" - }, "Microsoft.Data.Sqlite.Core": { "type": "Transitive", "resolved": "10.0.13", @@ -53,13 +46,12 @@ "dependencies": { "SQLitePCLRaw.core": "2.1.13" } - } - }, - "net10.0-windows7.0/win-x64": { - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.13", - "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A==" + }, + "codextpsfloat.core": { + "type": "Project", + "dependencies": { + "Microsoft.Data.Sqlite": "[10.0.13, )" + } } } } diff --git a/tests/CoreTests.csproj b/tests/CoreTests.csproj index 4c880ff..d86b045 100644 --- a/tests/CoreTests.csproj +++ b/tests/CoreTests.csproj @@ -1,4 +1,4 @@ Exenet10.0enableenablefalse - + From 5a6bcf2edad1523dd896ab29db07227a32737d8c Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 16:18:00 +0800 Subject: [PATCH 02/49] fix: Detect rewritten logs without relying on Windows file creation time --- src/Core/UsageScanner.cs | 71 +++++++++++++++++++++++++++++++++------- tests/Program.cs | 15 +++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/Core/UsageScanner.cs b/src/Core/UsageScanner.cs index 55ddba9..4cd6716 100644 --- a/src/Core/UsageScanner.cs +++ b/src/Core/UsageScanner.cs @@ -36,14 +36,39 @@ public sealed class UsageScanner { sealed class Cursor { + public const int TailCapacity = 256; public long Offset; - public long Creation; + public byte[]? Tail; + public int TailLength; public string Thread = ""; public string Turn = ""; public bool HasUsage; public bool HasLegacyUsage; + + // Keep a sliding window of the most recently consumed bytes so a replaced or + // rewritten file can be detected without relying on birth time, which Unix + // filesystems often do not expose (and ctime changes on every append there). + public void AppendTail(ReadOnlySpan data) + { + Tail ??= new byte[TailCapacity]; + if (data.Length >= TailCapacity) + { + data[^TailCapacity..].CopyTo(Tail); + TailLength = TailCapacity; + return; + } + int keep = Math.Min(TailLength, TailCapacity - data.Length); + int drop = TailLength - keep; + if (drop > 0) Tail.AsSpan(drop, keep).CopyTo(Tail); + data.CopyTo(Tail.AsSpan(keep)); + TailLength = keep + data.Length; + } } - readonly Dictionary cursors = new(StringComparer.OrdinalIgnoreCase); + // Windows paths are case-insensitive; Unix paths are not, so two logs that differ only + // by case must keep separate cursors there. + static readonly StringComparer PathComparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + readonly Dictionary cursors = new(PathComparer); readonly Dictionary usage = new(); readonly Dictionary turns = new(); readonly string home; @@ -155,6 +180,7 @@ void ReadCompleteLines(FileStream stream, Cursor cursor) catch (Exception e) when (e is JsonException or InvalidOperationException or FormatException) { invalidLines++; } } + cursor.AppendTail(buffer.AsSpan(consumed, newline + 1)); consumed += newline + 1; cursor.Offset += newline + 1; // Byte offsets also cover CRLF and multibyte UTF-8. } @@ -172,6 +198,24 @@ void ReadCompleteLines(FileStream stream, Cursor cursor) } finally { ArrayPool.Shared.Return(buffer, clearArray: true); } } + // Confirms the bytes immediately before the remembered offset are still the ones we + // consumed, so a rewritten or replaced file does not get treated as a plain append. + static bool TailMatches(FileStream stream, Cursor cursor) + { + long start = cursor.Offset - cursor.TailLength; + if (cursor.Tail == null || start < 0 || stream.Length < cursor.Offset) return false; + stream.Seek(start, SeekOrigin.Begin); + Span probe = stackalloc byte[Cursor.TailCapacity]; + var expected = cursor.Tail.AsSpan(0, cursor.TailLength); + int read = 0; + while (read < expected.Length) + { + int count = stream.Read(probe[read..expected.Length]); + if (count <= 0) return false; + read += count; + } + return probe[..expected.Length].SequenceEqual(expected); + } public UsageSnapshot Refresh(DateTimeOffset now) { var watch = System.Diagnostics.Stopwatch.StartNew(); @@ -187,24 +231,27 @@ public UsageSnapshot Refresh(DateTimeOffset now) { try { - var info = new FileInfo(path); if (!cursors.TryGetValue(path, out var cursor)) cursors[path] = cursor = new(); - long creation = info.CreationTimeUtc.Ticks; - if (info.Length < cursor.Offset || (cursor.Creation != 0 && cursor.Creation != creation)) - { - // Rebuild after replacement/truncation; stale usage must not survive a reset. - cursors.Clear(); usage.Clear(); turns.Clear(); invalidLines = 0; - return Refresh(now); - } - cursor.Creation = creation; - if (info.Length == cursor.Offset) continue; using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); if (cursor.Offset == 0 && stream.Length >= 3) { if (stream.ReadByte() == 0xEF && stream.ReadByte() == 0xBB && stream.ReadByte() == 0xBF) + { cursor.Offset = 3; + ReadOnlySpan bom = [0xEF, 0xBB, 0xBF]; + cursor.AppendTail(bom); + } + } + // Only a cursor that already consumed bytes can be invalidated; an empty + // cursor must always fall through to reading so a lone partial line works. + if (cursor.Offset > 0 && (stream.Length < cursor.Offset || !TailMatches(stream, cursor))) + { + // Rebuild after replacement/truncation; stale usage must not survive a reset. + cursors.Clear(); usage.Clear(); turns.Clear(); invalidLines = 0; + return Refresh(now); } + if (stream.Length == cursor.Offset) continue; stream.Seek(cursor.Offset, SeekOrigin.Begin); ReadCompleteLines(stream, cursor); } diff --git a/tests/Program.cs b/tests/Program.cs index 8dde1f3..74a6fa7 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -75,6 +75,21 @@ static void Seed(UsageScanner s, double t = origin) var partial = inc.Refresh(now); Check(partial.InvalidLines == 1 && partial.Records.Count == 1, "坏记录不阻断有效记录"); + // A rewrite that keeps the length identical must still be detected; on Unix systems + // birth time is unavailable, so identity has to come from the consumed bytes. + string replacedRoot = Path.Combine(directory, "replaced"); + Directory.CreateDirectory(Path.Combine(replacedRoot, "sessions")); + string replacedFile = Path.Combine(replacedRoot, "sessions", "same-length.jsonl"); + File.WriteAllText(replacedFile, row + "\n", new UTF8Encoding(false)); + var replaced = new UsageScanner(replacedRoot); + Check(replaced.Refresh(now).Records.Count == 1, "重写前计入一条"); + string other = row.Replace("\"response_id\":\"partial\"", "\"response_id\":\"rewrite\""); + Check(other.Length == row.Length, "重写样本保持等长"); + File.WriteAllText(replacedFile, other + "\n", new UTF8Encoding(false)); + var afterReplace = replaced.Refresh(now); + Check(afterReplace.Records.Count == 1 && afterReplace.Records[0].Usage.Id == "rewrite", + "等长重写后重建游标并采用新内容"); + string streamRoot = Path.Combine(directory, "streaming"); Directory.CreateDirectory(Path.Combine(streamRoot, "sessions")); string streamFile = Path.Combine(streamRoot, "sessions", "unicode.jsonl"); From 96adc08f30588627b4d13bb23c6fa5ba2c2983ac Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 16:20:28 +0800 Subject: [PATCH 03/49] refactor: Move settings persistence and report export into the core library --- src/Core/PanelSettings.cs | 29 ++++++++++++++++++++++ src/Core/ReportExporter.cs | 39 +++++++++++++++++++++++++++++ src/Core/SettingsStore.cs | 50 ++++++++++++++++++++++++++++++++++++++ src/MainWindow.xaml.cs | 45 ++++++---------------------------- tests/Program.cs | 27 +++++++++++++++++++- 5 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 src/Core/PanelSettings.cs create mode 100644 src/Core/ReportExporter.cs create mode 100644 src/Core/SettingsStore.cs diff --git a/src/Core/PanelSettings.cs b/src/Core/PanelSettings.cs new file mode 100644 index 0000000..3d5bd57 --- /dev/null +++ b/src/Core/PanelSettings.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Linq; + +namespace CodexTpsFloat; + +public sealed class PanelSettings +{ + public static readonly int[] AllowedWindowSeconds = { 60, 300, 1800, 3600 }; + public static readonly int[] AllowedRefreshSeconds = { 5, 15, 30, 60 }; + + public string CodexHome { get; set; } = DefaultCodexHome(); + public int WindowSeconds { get; set; } = 300; + public int RefreshSeconds { get; set; } = 15; + public bool Pinned { get; set; } = true; + public double? X { get; set; } + public double? Y { get; set; } + + public static string DefaultCodexHome() => Environment.GetEnvironmentVariable("CODEX_HOME") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); + + /// Replaces values that are out of range or that predate an option set. + public void Normalize() + { + if (!AllowedWindowSeconds.Contains(WindowSeconds)) WindowSeconds = 300; + if (!AllowedRefreshSeconds.Contains(RefreshSeconds)) RefreshSeconds = 15; + if (string.IsNullOrWhiteSpace(CodexHome)) CodexHome = DefaultCodexHome(); + } +} diff --git a/src/Core/ReportExporter.cs b/src/Core/ReportExporter.cs new file mode 100644 index 0000000..da8c86d --- /dev/null +++ b/src/Core/ReportExporter.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace CodexTpsFloat; + +/// Renders the hourly TPS report that the panel offers as Markdown or JSON. +public static class ReportExporter +{ + public static string BuildMarkdown(UsageSnapshot snapshot, DateTime date, string? model, IReadOnlyList hours) + { + var text = new StringBuilder($"# Codex TPS 分时统计\n\n日期:{date:yyyy-MM-dd};北京时间 UTC+8;模型:{model ?? "全部模型"}。\n\n快照:{snapshot.GeneratedAt:yyyy-MM-dd HH:mm:ss zzz}。\n\n| 时段 | 样本 | 中位数 | 加权 TPS |\n|---|---:|---:|---:|\n"); + foreach (var h in hours) + text.AppendLine($"| {h.Hour} | {h.Samples} | {h.Median?.ToString("F2", CultureInfo.InvariantCulture) ?? "—"} | {h.Weighted?.ToString("F2", CultureInfo.InvariantCulture) ?? "—"} |"); + text.AppendLine("\nTPS 是流式阶段估算:输出词元(包含推理)÷(逐次用量记录时间 − 首个模型输出项开始时间)。输入和缓存输入不计入,不等于精确服务端解码速度。\n\n按首个输出项的北京时间小时分组;零持续时间或缺失计时不作为有效样本。并发响应耗时各自相加。不同任务和上下文不可直接用于认定时段性能差异。\n"); + return text.ToString(); + } + public static string BuildJson(UsageSnapshot snapshot, DateTime date, string? model, IReadOnlyList hours) => + JsonSerializer.Serialize(new + { + date = date.ToString("yyyy-MM-dd"), + timezone = "UTC+8", + snapshot.GeneratedAt, + model = model ?? "全部模型", + method = "输出词元(含推理)除以输出项计时区间;非精确解码速度", + hours + }, new JsonSerializerOptions { WriteIndented = true }); + /// Writes a report; the file extension selects Markdown or JSON. + public static void Write(string path, UsageSnapshot snapshot, DateTime date, string? model, IReadOnlyList hours) + { + bool json = Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase); + string content = json ? BuildJson(snapshot, date, model, hours) : BuildMarkdown(snapshot, date, model, hours); + File.WriteAllText(path, content, Encoding.UTF8); + } +} diff --git a/src/Core/SettingsStore.cs b/src/Core/SettingsStore.cs new file mode 100644 index 0000000..87e4174 --- /dev/null +++ b/src/Core/SettingsStore.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace CodexTpsFloat; + +/// +/// Persists panel settings. Portable Windows builds keep the file beside the executable; +/// other platforms use the conventional per-user application data directory, because a +/// signed macOS bundle or a /usr install directory is not writable. +/// +public sealed class SettingsStore +{ + readonly string path; + public SettingsStore(string path) => this.path = path; + public string Path => path; + public static SettingsStore ForPortableApp(string baseDirectory) => + new(System.IO.Path.Combine(baseDirectory, "data", "settings.json")); + public static SettingsStore ForUserProfile(string appName = "CodexTpsFloat") + { + string root = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(root)) + root = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + return new(System.IO.Path.Combine(root, appName, "settings.json")); + } + public PanelSettings Load() + { + try + { + var settings = JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new PanelSettings(); + settings.Normalize(); + return settings; + } + catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) + { return new PanelSettings(); } + } + /// Writes atomically; returns an error message when saving fails. + public string? Save(PanelSettings settings) + { + try + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + string temporary = path + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true })); + File.Move(temporary, path, true); + return null; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return e.Message; } + } +} diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs index 84ed975..64134bd 100644 --- a/src/MainWindow.xaml.cs +++ b/src/MainWindow.xaml.cs @@ -4,8 +4,6 @@ using System.Globalization; using System.IO; using System.Linq; -using System.Text; -using System.Text.Json; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -18,23 +16,14 @@ namespace CodexTpsFloat; -public sealed class PanelSettings -{ - public string CodexHome { get; set; } = Environment.GetEnvironmentVariable("CODEX_HOME") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); - public int WindowSeconds { get; set; } = 300; - public int RefreshSeconds { get; set; } = 15; - public bool Pinned { get; set; } = true; - public double? X { get; set; } - public double? Y { get; set; } -} - public partial class MainWindow : Window { const string StartupName = "CodexTpsFloat"; readonly DispatcherTimer timer = new(); readonly Forms.NotifyIcon tray; - readonly string settingsPath = Path.Combine(AppContext.BaseDirectory, "data", "settings.json"); + readonly SettingsStore settingsStore = OperatingSystem.IsWindows() + ? SettingsStore.ForPortableApp(AppContext.BaseDirectory) + : SettingsStore.ForUserProfile(); readonly string? capturePath; PanelSettings settings; UsageScanner scanner; @@ -44,11 +33,9 @@ public partial class MainWindow : Window public MainWindow(string? capturePath = null, string? codexHome = null) { this.capturePath = capturePath; - try { settings = JsonSerializer.Deserialize(File.ReadAllText(settingsPath)) ?? new(); } - catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) { settings = new(); } + settings = settingsStore.Load(); if (codexHome != null) settings.CodexHome = Path.GetFullPath(codexHome); - if (!new[] { 60, 300, 1800, 3600 }.Contains(settings.WindowSeconds)) settings.WindowSeconds = 300; - if (!new[] { 5, 15, 30, 60 }.Contains(settings.RefreshSeconds)) settings.RefreshSeconds = 15; + settings.Normalize(); scanner = new(settings.CodexHome); requestClocks = new(settings.CodexHome); InitializeComponent(); @@ -183,15 +170,8 @@ void RenderHistory() } void SaveSettings() { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); - string temporary = settingsPath + ".tmp"; - File.WriteAllText(temporary, JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); - File.Move(temporary, settingsPath, true); - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { if (!closing) StatusText.ToolTip = "设置保存失败:" + e.Message; } + string? error = settingsStore.Save(settings); + if (error != null && !closing) StatusText.ToolTip = "设置保存失败:" + error; } void DragHeader(object sender, MouseButtonEventArgs e) { @@ -285,16 +265,7 @@ void ExportClick(object sender, RoutedEventArgs e) try { var hours = UsageScanner.Hours(snapshot, date, ModelFilter); - if (Path.GetExtension(dialog.FileName).Equals(".json", StringComparison.OrdinalIgnoreCase)) - File.WriteAllText(dialog.FileName, JsonSerializer.Serialize(new { date = date.ToString("yyyy-MM-dd"), timezone = "UTC+8", snapshot.GeneratedAt, - model = ModelFilter ?? "全部模型", method = "输出词元(含推理)除以输出项计时区间;非精确解码速度", hours }, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); - else - { - var text = new StringBuilder($"# Codex TPS 分时统计\n\n日期:{date:yyyy-MM-dd};北京时间 UTC+8;模型:{ModelFilter ?? "全部模型"}。\n\n快照:{snapshot.GeneratedAt:yyyy-MM-dd HH:mm:ss zzz}。\n\n| 时段 | 样本 | 中位数 | 加权 TPS |\n|---|---:|---:|---:|\n"); - foreach (var h in hours) text.AppendLine($"| {h.Hour} | {h.Samples} | {h.Median?.ToString("F2", CultureInfo.InvariantCulture) ?? "—"} | {h.Weighted?.ToString("F2", CultureInfo.InvariantCulture) ?? "—"} |"); - text.AppendLine("\nTPS 是流式阶段估算:输出词元(包含推理)÷(逐次用量记录时间 − 首个模型输出项开始时间)。输入和缓存输入不计入,不等于精确服务端解码速度。\n\n按首个输出项的北京时间小时分组;零持续时间或缺失计时不作为有效样本。并发响应耗时各自相加。不同任务和上下文不可直接用于认定时段性能差异。\n"); - File.WriteAllText(dialog.FileName, text.ToString(), Encoding.UTF8); - } + ReportExporter.Write(dialog.FileName, snapshot, date, ModelFilter, hours); MessageBox.Show(this, "报告已保存。", "Codex TPS"); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { MessageBox.Show(this, "导出失败:" + ex.Message, "Codex TPS"); } diff --git a/tests/Program.cs b/tests/Program.cs index 74a6fa7..e305177 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -148,6 +148,31 @@ static void Seed(UsageScanner s, double t = origin) var modern = legacy.Refresh(now); Check(modern.UnsupportedFiles == 1 && modern.Records.Count == 1 && modern.InvalidLines == 0, "同文件出现逐次 token_usage_record 后恢复可计量状态"); + + string settingsDirectory = Path.Combine(directory, "settings"); + var store = new SettingsStore(Path.Combine(settingsDirectory, "settings.json")); + Check(store.Load().WindowSeconds == 300, "缺失设置文件时回退到默认值"); + var custom = new PanelSettings { CodexHome = directory, WindowSeconds = 999, RefreshSeconds = 42, + Pinned = false, X = 12.5, Y = 34.5 }; + store.Save(custom); + var loaded = store.Load(); + Check(loaded.WindowSeconds == 300 && loaded.RefreshSeconds == 15, "越界选项在读取时归一化"); + Check(loaded.Pinned == false && loaded.X == 12.5 && loaded.Y == 34.5 && loaded.CodexHome == directory, + "有效设置项完整往返"); + Check(!File.Exists(store.Path + ".tmp"), "原子保存不残留临时文件"); + + var exportHours = new[] { new HourStats("09:00–10:00", 2, 21.5, 22.25), new HourStats("10:00–11:00", 0, null, null) }; + string markdownPath = Path.Combine(directory, "report.md"); + ReportExporter.Write(markdownPath, snap, now.Date, "model-a", exportHours); + string markdown = File.ReadAllText(markdownPath); + Check(markdown.Contains("| 09:00–10:00 | 2 | 21.50 | 22.25 |") && markdown.Contains("| 10:00–11:00 | 0 | — | — |"), + "Markdown 报告包含有效与空样本行"); + string jsonPath = Path.Combine(directory, "report.json"); + ReportExporter.Write(jsonPath, snap, now.Date, null, exportHours); + using var reportJson = JsonDocument.Parse(File.ReadAllText(jsonPath)); + Check(reportJson.RootElement.GetProperty("timezone").GetString() == "UTC+8" + && reportJson.RootElement.GetProperty("model").GetString() == "全部模型" + && reportJson.RootElement.GetProperty("hours").GetArrayLength() == 2, "JSON 报告字段与导出参数一致"); } finally { Directory.Delete(directory, true); } -Console.WriteLine("通过:统计口径、去重、重试区间、时区、增量刷新、归档、截断、坏记录、BOM、跨缓冲区长行、CRLF、中文字符中间补全尾行与旧格式告警。"); +Console.WriteLine("通过:统计口径、去重、重试区间、时区、增量刷新、归档、截断、等长重写、坏记录、BOM、跨缓冲区长行、CRLF、中文字符中间补全尾行、旧格式告警、设置往返与报告导出。"); From 7a3a71df2ed4c81318639b3f411a7fe28f43d7e2 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 16:44:58 +0800 Subject: [PATCH 04/49] refactor: Move the headless snapshot command into the core library --- src/Core/SnapshotWriter.cs | 40 ++++++++++++++++++++++++++++++++++++++ src/Program.cs | 16 +-------------- 2 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 src/Core/SnapshotWriter.cs diff --git a/src/Core/SnapshotWriter.cs b/src/Core/SnapshotWriter.cs new file mode 100644 index 0000000..9f19262 --- /dev/null +++ b/src/Core/SnapshotWriter.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace CodexTpsFloat; + +/// +/// Renders a machine-readable snapshot of the current usage without starting any UI. +/// Installed builds use this for smoke tests, and it is the only mode that runs headless. +/// +public static class SnapshotWriter +{ + public static int Write(string snapshotPath, string? codexHome) + { + string home = codexHome ?? PanelSettings.DefaultCodexHome(); + var clockReader = new RequestClockReader(home); + var scanner = new UsageScanner(home) { RequestStarts = clockReader.Refresh() }; + var snapshot = scanner.Refresh(DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8))); + string? directory = Path.GetDirectoryName(Path.GetFullPath(snapshotPath)); + if (directory is { Length: > 0 }) Directory.CreateDirectory(directory); + File.WriteAllText(snapshotPath, JsonSerializer.Serialize(new + { + snapshot.GeneratedAt, + snapshot.Files, + snapshot.InvalidLines, + snapshot.UnsupportedFiles, + snapshot.ReadFailures, + snapshot.ScanMilliseconds, + requestClockWarning = clockReader.Warning, + windows = new[] + { + UsageScanner.Window(snapshot, 60), UsageScanner.Window(snapshot, 300), + UsageScanner.Window(snapshot, 1800), UsageScanner.Window(snapshot, 3600) + }, + hours = UsageScanner.Hours(snapshot, snapshot.GeneratedAt.Date) + }, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); + return snapshot.Files == 0 ? 3 : snapshot.ReadFailures > 0 ? 1 : 0; + } +} diff --git a/src/Program.cs b/src/Program.cs index 96ed14f..5659e2e 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,7 +1,5 @@ using System; using System.IO; -using System.Text; -using System.Text.Json; using System.Threading; using System.Windows; @@ -27,19 +25,7 @@ public static int Main(string[] args) try { if (snapshotPath != null) - { - home ??= Environment.GetEnvironmentVariable("CODEX_HOME") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); - var clockReader = new RequestClockReader(home); - var scanner = new UsageScanner(home) { RequestStarts = clockReader.Refresh() }; - var snapshot = scanner.Refresh(DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8))); - Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!); - File.WriteAllText(snapshotPath, JsonSerializer.Serialize(new { snapshot.GeneratedAt, snapshot.Files, snapshot.InvalidLines, - snapshot.UnsupportedFiles, snapshot.ReadFailures, snapshot.ScanMilliseconds, requestClockWarning = clockReader.Warning, - windows = new[] { UsageScanner.Window(snapshot, 60), UsageScanner.Window(snapshot, 300), UsageScanner.Window(snapshot, 1800), UsageScanner.Window(snapshot, 3600) }, - hours = UsageScanner.Hours(snapshot, snapshot.GeneratedAt.Date) }, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); - return snapshot.Files == 0 ? 3 : snapshot.ReadFailures > 0 ? 1 : 0; - } + return SnapshotWriter.Write(snapshotPath, home); using var mutex = new Mutex(true, @"Local\CodexTpsFloat.Panel.v1", out bool first); using var wake = new EventWaitHandle(false, EventResetMode.AutoReset, @"Local\CodexTpsFloat.Show.v1"); if (!first && capture == null) { wake.Set(); return 0; } From 5b571ec89701d1a9bde55ff3853a9211231b817f Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 16:48:43 +0800 Subject: [PATCH 05/49] feat: Add cross-platform autostart and platform path services --- src/Core/Autostart.cs | 120 ++++++++++++++++++++++++++++++++++++++ src/Core/PlatformPaths.cs | 20 +++++++ src/Core/SettingsStore.cs | 7 +-- src/MainWindow.xaml.cs | 21 ++----- tests/Program.cs | 17 +++++- 5 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 src/Core/Autostart.cs create mode 100644 src/Core/PlatformPaths.cs diff --git a/src/Core/Autostart.cs b/src/Core/Autostart.cs new file mode 100644 index 0000000..4eea6a6 --- /dev/null +++ b/src/Core/Autostart.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Runtime.InteropServices; +using System.Text; + +namespace CodexTpsFloat; + +/// +/// Registers the app to start at login. Each platform has its own mechanism: +/// Windows uses the Run key, macOS a LaunchAgent, Linux a desktop autostart entry. +/// +public interface IAutostartService +{ + bool IsEnabled(); + /// Enables or disables autostart; returns an error message on failure. + string? SetEnabled(bool enabled); +} + +public static class Autostart +{ + public static IAutostartService Create(string? executablePath = null, string? configDirectory = null) + { + string exe = executablePath ?? Environment.ProcessPath ?? ""; + string config = configDirectory ?? PlatformPaths.UserConfigDirectory(); + if (OperatingSystem.IsWindows()) return new WindowsAutostart(exe); + if (OperatingSystem.IsMacOS()) return new MacAutostart(exe); + return new LinuxAutostart(exe, config); + } +} + +[SupportedOSPlatform("windows")] +public sealed class WindowsAutostart : IAutostartService +{ + const string StartupName = "CodexTpsFloat"; + readonly string executable; + public WindowsAutostart(string executable) => this.executable = executable; + public bool IsEnabled() + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Run"); + return key?.GetValue(StartupName) is string value && value == $"\"{executable}\""; + } + public string? SetEnabled(bool enabled) + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Run"); + if (enabled) key.SetValue(StartupName, $"\"{executable}\""); + else key.DeleteValue(StartupName, false); + return null; + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException or System.Security.SecurityException) + { return e.Message; } + } +} + +public sealed class MacAutostart : IAutostartService +{ + readonly string executable; + readonly string plistPath; + public MacAutostart(string executable, string? launchAgentsDirectory = null) => + (this.executable, plistPath) = (executable, Path.Combine( + launchAgentsDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents"), + "com.codextpsfloat.plist")); + public bool IsEnabled() => File.Exists(plistPath); + public string? SetEnabled(bool enabled) + { + try + { + if (!enabled) { if (File.Exists(plistPath)) File.Delete(plistPath); return null; } + Directory.CreateDirectory(Path.GetDirectoryName(plistPath)!); + string escaped = System.Security.SecurityElement.Escape(executable) ?? executable; + string plist = $""" + + + + + Labelcom.codextpsfloat + ProgramArguments{escaped} + RunAtLoad + + + """; + File.WriteAllText(plistPath, plist, new UTF8Encoding(false)); + return null; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return e.Message; } + } +} + +public sealed class LinuxAutostart : IAutostartService +{ + readonly string executable; + readonly string desktopPath; + public LinuxAutostart(string executable, string configDirectory) => + (this.executable, desktopPath) = (executable, Path.Combine(configDirectory, "autostart", "CodexTpsFloat.desktop")); + public bool IsEnabled() => File.Exists(desktopPath); + public string? SetEnabled(bool enabled) + { + try + { + if (!enabled) { if (File.Exists(desktopPath)) File.Delete(desktopPath); return null; } + Directory.CreateDirectory(Path.GetDirectoryName(desktopPath)!); + string desktop = $""" + [Desktop Entry] + Type=Application + Name=Codex TPS + Exec="{executable}" + Terminal=false + X-GNOME-Autostart-enabled=true + """; + File.WriteAllText(desktopPath, desktop, new UTF8Encoding(false)); + return null; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return e.Message; } + } +} diff --git a/src/Core/PlatformPaths.cs b/src/Core/PlatformPaths.cs new file mode 100644 index 0000000..3f4926f --- /dev/null +++ b/src/Core/PlatformPaths.cs @@ -0,0 +1,20 @@ +using System; +using System.IO; + +namespace CodexTpsFloat; + +/// Per-user locations that follow each platform's conventions. +public static class PlatformPaths +{ + /// + /// Windows returns %APPDATA%, macOS returns ~/Library/Application Support and Linux + /// returns ~/.config, so this is also the right place to keep user settings. + /// + public static string UserConfigDirectory(string appName = "CodexTpsFloat") + { + string root = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(root)) + root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + return Path.Combine(root, appName); + } +} diff --git a/src/Core/SettingsStore.cs b/src/Core/SettingsStore.cs index 87e4174..6f146ce 100644 --- a/src/Core/SettingsStore.cs +++ b/src/Core/SettingsStore.cs @@ -17,12 +17,7 @@ public sealed class SettingsStore public static SettingsStore ForPortableApp(string baseDirectory) => new(System.IO.Path.Combine(baseDirectory, "data", "settings.json")); public static SettingsStore ForUserProfile(string appName = "CodexTpsFloat") - { - string root = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if (string.IsNullOrEmpty(root)) - root = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); - return new(System.IO.Path.Combine(root, appName, "settings.json")); - } + => new(System.IO.Path.Combine(PlatformPaths.UserConfigDirectory(appName), "settings.json")); public PanelSettings Load() { try diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs index 64134bd..c70bd90 100644 --- a/src/MainWindow.xaml.cs +++ b/src/MainWindow.xaml.cs @@ -18,9 +18,9 @@ namespace CodexTpsFloat; public partial class MainWindow : Window { - const string StartupName = "CodexTpsFloat"; readonly DispatcherTimer timer = new(); readonly Forms.NotifyIcon tray; + readonly IAutostartService autostart = Autostart.Create(); readonly SettingsStore settingsStore = OperatingSystem.IsWindows() ? SettingsStore.ForPortableApp(AppContext.BaseDirectory) : SettingsStore.ForUserProfile(); @@ -44,7 +44,7 @@ public MainWindow(string? capturePath = null, string? codexHome = null) TopmostBox.IsChecked = settings.Pinned; PinButton.Foreground = new SolidColorBrush(settings.Pinned ? Color.FromRgb(0, 122, 255) : Colors.Gray); DateBox.SelectedDate = BeijingNow.Date; - StartupBox.IsChecked = StartupEnabled(); + StartupBox.IsChecked = autostart.IsEnabled(); foreach (var item in CadenceBox.Items.OfType()) item.IsSelected = item.Tag?.ToString() == settings.RefreshSeconds.ToString(CultureInfo.InvariantCulture); foreach (var radio in new[] { MinuteWindow, FiveMinuteWindow, HalfHourWindow, HourWindow }) @@ -240,21 +240,12 @@ async void ChooseHomeClick(object sender, RoutedEventArgs e) settings.CodexHome = dialog.SelectedPath; HomeBox.Text = settings.CodexHome; scanner = new(settings.CodexHome); requestClocks = new(settings.CodexHome); SaveSettings(); await RefreshAsync(); } - static bool StartupEnabled() - { - using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); - return key?.GetValue(StartupName) is string value && value == $"\"{Environment.ProcessPath}\""; - } void StartupClick(object sender, RoutedEventArgs e) { - try - { - using var key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); - if (StartupBox.IsChecked == true) key.SetValue(StartupName, $"\"{Environment.ProcessPath}\""); - else key.DeleteValue(StartupName, false); - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or System.Security.SecurityException) - { StartupBox.IsChecked = StartupEnabled(); MessageBox.Show(this, "登录启动设置失败:" + ex.Message, "Codex TPS"); } + string? error = autostart.SetEnabled(StartupBox.IsChecked == true); + if (error == null) return; + StartupBox.IsChecked = autostart.IsEnabled(); + MessageBox.Show(this, "登录启动设置失败:" + error, "Codex TPS"); } void ExportClick(object sender, RoutedEventArgs e) { diff --git a/tests/Program.cs b/tests/Program.cs index e305177..ddb173f 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -173,6 +173,21 @@ static void Seed(UsageScanner s, double t = origin) Check(reportJson.RootElement.GetProperty("timezone").GetString() == "UTC+8" && reportJson.RootElement.GetProperty("model").GetString() == "全部模型" && reportJson.RootElement.GetProperty("hours").GetArrayLength() == 2, "JSON 报告字段与导出参数一致"); + + string entry = OperatingSystem.IsMacOS() + ? Path.Combine(directory, "LaunchAgents", "com.codextpsfloat.plist") + : Path.Combine(directory, "config", "autostart", "CodexTpsFloat.desktop"); + IAutostartService login = OperatingSystem.IsMacOS() + ? new MacAutostart("/Applications/Codex TPS.app", Path.GetDirectoryName(entry)) + : new LinuxAutostart("/opt/codex tps/CodexTpsFloat", Path.Combine(directory, "config")); + Check(!login.IsEnabled(), "未启用时登录启动为关闭状态"); + Check(login.SetEnabled(true) == null && login.IsEnabled(), "启用登录启动会创建条目"); + string entryText = File.ReadAllText(entry); + Check(OperatingSystem.IsMacOS() + ? entryText.Contains("/Applications/Codex TPS.app") && entryText.Contains("RunAtLoad") + : entryText.Contains("Exec=\"/opt/codex tps/CodexTpsFloat\"") && entryText.StartsWith("[Desktop Entry]"), + "登录启动条目包含可执行路径与自启标记"); + Check(login.SetEnabled(false) == null && !login.IsEnabled() && !File.Exists(entry), "停用登录启动会移除条目"); } finally { Directory.Delete(directory, true); } -Console.WriteLine("通过:统计口径、去重、重试区间、时区、增量刷新、归档、截断、等长重写、坏记录、BOM、跨缓冲区长行、CRLF、中文字符中间补全尾行、旧格式告警、设置往返与报告导出。"); +Console.WriteLine("通过:统计口径、去重、重试区间、时区、增量刷新、归档、截断、等长重写、坏记录、BOM、跨缓冲区长行、CRLF、中文字符中间补全尾行、旧格式告警、设置往返、报告导出与登录启动。"); From 4c1eb4da1d65187d1a29a514d89f6be6a5d1eeac Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 17:08:35 +0800 Subject: [PATCH 06/49] feat: Add cross-platform Avalonia application shell --- src/App/App.axaml | 8 + src/App/App.axaml.cs | 44 +++ src/App/CodexTpsFloat.App.csproj | 27 ++ src/App/MainWindow.axaml | 232 ++++++++++++++ src/App/MainWindow.axaml.cs | 506 +++++++++++++++++++++++++++++++ src/App/Program.cs | 66 ++++ src/App/app.manifest | 6 + src/App/packages.lock.json | 273 +++++++++++++++++ src/Core/SingleInstance.cs | 190 ++++++++++++ 9 files changed, 1352 insertions(+) create mode 100644 src/App/App.axaml create mode 100644 src/App/App.axaml.cs create mode 100644 src/App/CodexTpsFloat.App.csproj create mode 100644 src/App/MainWindow.axaml create mode 100644 src/App/MainWindow.axaml.cs create mode 100644 src/App/Program.cs create mode 100644 src/App/app.manifest create mode 100644 src/App/packages.lock.json create mode 100644 src/Core/SingleInstance.cs diff --git a/src/App/App.axaml b/src/App/App.axaml new file mode 100644 index 0000000..0614db5 --- /dev/null +++ b/src/App/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/App/App.axaml.cs b/src/App/App.axaml.cs new file mode 100644 index 0000000..d5aec84 --- /dev/null +++ b/src/App/App.axaml.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace CodexTpsFloat.App; + +public partial class App : Application +{ + public static CommandLineOptions Options { get; set; } = new(null, null, null); + public static SingleInstance? SingleInstance { get; set; } + public static Action? ShowExistingPanel { get; set; } + MainWindow? window; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + base.OnFrameworkInitializationCompleted(); + return; + } + + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + window = new MainWindow(Options.CapturePath, Options.CodexHome); + desktop.MainWindow = window; + + if (Options.CapturePath == null) + ShowExistingPanel = () => Dispatcher.UIThread.Post(window.ShowPanel); + + desktop.Startup += async (_, _) => + { + await window.StartAsync(); + if (Options.CapturePath != null) + await window.CaptureAndCloseAsync(Options.CapturePath); + }; + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/App/CodexTpsFloat.App.csproj b/src/App/CodexTpsFloat.App.csproj new file mode 100644 index 0000000..599275c --- /dev/null +++ b/src/App/CodexTpsFloat.App.csproj @@ -0,0 +1,27 @@ + + + WinExe + net10.0 + disable + enable + CodexTpsFloat + CodexTpsFloat.App + true + app.manifest + true + true + true + 1.2.0 + + + + + + + + + + + + + diff --git a/src/App/MainWindow.axaml b/src/App/MainWindow.axaml new file mode 100644 index 0000000..6499a6a --- /dev/null +++ b/src/App/MainWindow.axaml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/packages.lock.json b/src/App/packages.lock.json index 194a5bd..8cccd0a 100644 --- a/src/App/packages.lock.json +++ b/src/App/packages.lock.json @@ -35,17 +35,6 @@ "Avalonia.X11": "11.3.12" } }, - "Avalonia.Diagnostics": { - "type": "Direct", - "requested": "[11.3.12, )", - "resolved": "11.3.12", - "contentHash": "fV+CTB/IDsFPqUVu5OQbpFLNeGLv9EmwxEjjuesJkzgT5R7mhkpLdZN3iWNC8gWkb2zfWG6mhu/q9jzDkiNhLA==", - "dependencies": { - "Avalonia": "11.3.12", - "Avalonia.Controls.ColorPicker": "11.3.12", - "Avalonia.Themes.Simple": "11.3.12" - } - }, "Avalonia.Fonts.Inter": { "type": "Direct", "requested": "[11.3.12, )", @@ -64,6 +53,12 @@ "Avalonia": "11.3.12" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + }, "Avalonia.Angle.Windows.Natives": { "type": "Transitive", "resolved": "2.1.25547.20250602", @@ -74,15 +69,6 @@ "resolved": "11.3.2", "contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ==" }, - "Avalonia.Controls.ColorPicker": { - "type": "Transitive", - "resolved": "11.3.12", - "contentHash": "NIqh2J3LUYpKvk+eeIc6jXcPWwuL/Dr5DQF6ZL3qRETkOhGdec1G7D9aMFwxltx0cLhXPnuSpYnKPIBp31OrHw==", - "dependencies": { - "Avalonia": "11.3.12", - "Avalonia.Remote.Protocol": "11.3.12" - } - }, "Avalonia.FreeDesktop": { "type": "Transitive", "resolved": "11.3.12", @@ -119,14 +105,6 @@ "SkiaSharp.NativeAssets.WebAssembly": "2.88.9" } }, - "Avalonia.Themes.Simple": { - "type": "Transitive", - "resolved": "11.3.12", - "contentHash": "Zubwo5oC2mjLGgHbkhqFIGnOLiPX6I8CfIBMknrhLH1YndKNSpm15o6jn5FASa+rshuBsylKA7iypEIPosXjWw==", - "dependencies": { - "Avalonia": "11.3.12" - } - }, "Avalonia.Win32": { "type": "Transitive", "resolved": "11.3.12", @@ -268,6 +246,59 @@ "Microsoft.Data.Sqlite": "[10.0.13, )" } } + }, + "net10.0/osx-arm64": { + "Avalonia.Angle.Windows.Natives": { + "type": "Transitive", + "resolved": "2.1.25547.20250602", + "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A==" + }, + "Avalonia.Native": { + "type": "Transitive", + "resolved": "11.3.12", + "contentHash": "28q2DUSGUBVRpEQWYBo0crokZabPyI7KmA68++nm/qAe/9Zxmxythk/F36swAkI4pIU2ouLIKK0yxiFBy+xvNw==", + "dependencies": { + "Avalonia": "11.3.12" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", + "dependencies": { + "SkiaSharp": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A==" + } } } } \ No newline at end of file diff --git a/src/Core/packages.lock.json b/src/Core/packages.lock.json index e11fa5f..1a00a78 100644 --- a/src/Core/packages.lock.json +++ b/src/Core/packages.lock.json @@ -13,6 +13,12 @@ "SQLitePCLRaw.core": "2.1.13" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + }, "Microsoft.Data.Sqlite.Core": { "type": "Transitive", "resolved": "10.0.13", @@ -48,6 +54,13 @@ "SQLitePCLRaw.core": "2.1.13" } } + }, + "net10.0/osx-arm64": { + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A==" + } } } } \ No newline at end of file From 0bb1a785414a21e63149c6465bb54f7875e16714 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 18:50:06 +0800 Subject: [PATCH 24/49] Replace panel symbols with vector icons --- src/App/MainWindow.axaml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/App/MainWindow.axaml b/src/App/MainWindow.axaml index 53fc3b4..b7c8c0c 100644 --- a/src/App/MainWindow.axaml +++ b/src/App/MainWindow.axaml @@ -76,21 +76,29 @@ From a009f2aa2b2dba14a46a618b9fd2e5128f43b8eb Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 18:54:31 +0800 Subject: [PATCH 25/49] fix: Upgrade Tmds.DBus.Protocol to patched version Reference version 0.21.3 explicitly and update the lock file to resolve GHSA-xrw6-gwf8-vvr9 and eliminate NU1903. --- src/App/CodexTpsFloat.App.csproj | 2 ++ src/App/packages.lock.json | 11 ++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/App/CodexTpsFloat.App.csproj b/src/App/CodexTpsFloat.App.csproj index 176fe93..8101cf8 100644 --- a/src/App/CodexTpsFloat.App.csproj +++ b/src/App/CodexTpsFloat.App.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/App/packages.lock.json b/src/App/packages.lock.json index 8cccd0a..4d01eaa 100644 --- a/src/App/packages.lock.json +++ b/src/App/packages.lock.json @@ -59,6 +59,12 @@ "resolved": "10.0.11", "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" }, + "Tmds.DBus.Protocol": { + "type": "Direct", + "requested": "[0.21.3, )", + "resolved": "0.21.3", + "contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw==" + }, "Avalonia.Angle.Windows.Natives": { "type": "Transitive", "resolved": "2.1.25547.20250602", @@ -235,11 +241,6 @@ "SQLitePCLRaw.core": "2.1.13" } }, - "Tmds.DBus.Protocol": { - "type": "Transitive", - "resolved": "0.21.2", - "contentHash": "ScSMrUrrw8px4kK1Glh0fZv/HQUlg1078bNXNPfRPKQ3WbRzV9HpsydYEOgSoMK5LWICMf2bMwIFH0pGjxjcMA==" - }, "codextpsfloat.core": { "type": "Project", "dependencies": { From 15a2f773d63c802d856ef9604813a2cc289dc48c Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 18:54:54 +0800 Subject: [PATCH 26/49] fix: Render a transparent macOS tray icon --- README.md | 2 +- src/App/MainWindow.axaml.cs | 28 ++++++++++------------------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ccdc1d4..844d957 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ macOS 首次运行时如果被 Gatekeeper 阻止,请在 Finder 中右键 `Code 便携构建使用单文件程序,并关闭内部程序集压缩,以减少运行时解压所需的内存;下载时由外层压缩包压缩。解压后的程序较大,其中包含 .NET 运行时及各平台的界面依赖。 - 默认读取 `CODEX_HOME`;未设置时读取当前用户的 `.codex`。同时统计 `sessions` 与 `archived_sessions`。 -- 启动后只显示系统托盘图标;悬停显示当前生成速度估算。单击图标打开或收起统计面板,右键可打开面板、立即刷新或退出。面板展开时也不出现任务栏窗口按钮或 macOS Dock 图标。macOS 上菜单栏图标可能受系统或第三方工具影响而不可见,因此首次启动会直接显示面板,之后可用减号收起到菜单栏。 +- 托盘图标使用透明底的统计条形图;macOS 菜单栏图标随系统自动适配深浅色。悬停显示当前生成速度估算。Windows 和 Linux 启动后只显示系统托盘图标,单击打开或收起统计面板,右键可打开面板、立即刷新或退出;macOS 点击图标弹出菜单,可选择「打开统计面板」。面板展开时也不出现任务栏窗口按钮或 macOS Dock 图标。macOS 上菜单栏图标可能受系统或第三方工具影响而不可见,因此启动会直接显示面板,之后可用减号收起到菜单栏。 - 面板可拖动,蓝色菱形切换置顶,减号收起到托盘。电源按钮退出整个工具。 - 切换 1 分钟、5 分钟、30 分钟、1 小时窗口,或筛选模型。自动刷新支持 5、15、30、60 秒。 - 展开「今日分时」,可以选择其他日期并导出中文 Markdown 或 JSON 报告。时区固定为北京时间 UTC+8。 diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index c9cd9aa..7a6868c 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -12,7 +12,6 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Media.Imaging; -using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Threading; using Avalonia.VisualTree; @@ -107,6 +106,8 @@ void InitializeState() 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; } @@ -495,24 +496,15 @@ static class TrayIconFactory { public static WindowIcon Create() { - const int size = 32; - var pixels = new byte[size * size * 4]; - for (int y = 0; y < size; y++) + // An 18-point glyph rendered at 2x stays sharp on Retina displays. + // Leave transparent padding so the menu bar shows bars, not a solid square. + using var bitmap = new RenderTargetBitmap(new PixelSize(36, 36), new Vector(192, 192)); + using (var drawing = bitmap.CreateDrawingContext()) { - for (int x = 0; x < size; x++) - { - int index = (y * size + x) * 4; - pixels[index] = 0x00; - pixels[index + 1] = 0x7A; - pixels[index + 2] = 0xFF; - pixels[index + 3] = 0xFF; - } - } - using var bitmap = new WriteableBitmap( - new PixelSize(size, size), new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Unpremul); - using (var buffer = bitmap.Lock()) - { - System.Runtime.InteropServices.Marshal.Copy(pixels, 0, buffer.Address, pixels.Length); + IBrush brush = OperatingSystem.IsMacOS() ? Brushes.Black : new SolidColorBrush(Color.Parse("#007AFF")); + drawing.DrawRectangle(brush, null, new Rect(2, 10, 3, 6), 0.75, 0.75); + drawing.DrawRectangle(brush, null, new Rect(7.5, 6, 3, 10), 0.75, 0.75); + drawing.DrawRectangle(brush, null, new Rect(13, 2, 3, 14), 0.75, 0.75); } using var stream = new MemoryStream(); bitmap.Save(stream); From 8ed1cb85054248db5bd7c5af42003accb88dfca3 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 19:20:56 +0800 Subject: [PATCH 27/49] ci: Add cross-platform build workflow --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c72a3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + pull_request: + push: + branches: + - '**' + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Run core regression tests + run: dotnet run --project tests/CoreTests.csproj -c Release --nologo + + build: + needs: test + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + runtime: win-x64 + shell: pwsh + - runner: macos-15 + runtime: osx-arm64 + shell: bash + - runner: macos-15-intel + runtime: osx-x64 + shell: bash + - runner: ubuntu-latest + runtime: linux-x64 + shell: bash + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + shell: ${{ matrix.shell }} + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Run hidden tray lifecycle tests (Windows only) + if: runner.os == 'Windows' + run: dotnet run --project tests/TrayLifecycleTests.csproj -c Release --nologo -- --never-show + + - name: Build portable application + if: runner.os == 'Windows' + run: ./build.ps1 -Runtime ${{ matrix.runtime }} -Portable + + - name: Build portable application + if: runner.os != 'Windows' + run: PORTABLE=1 ./build.sh ${{ matrix.runtime }} + + - name: Upload build artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: CodexTpsFloat-ci-${{ matrix.runtime }}-${{ github.sha }} + path: app + if-no-files-found: error + retention-days: 14 From e0d3debaa35392bc995d320453b63bbda8ec9a27 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 20:41:36 +0800 Subject: [PATCH 28/49] fix: Restore refresh cadence selection and refine window segments --- src/App/MainWindow.axaml | 53 ++++++++++++++++++++++++++++--------- src/App/MainWindow.axaml.cs | 5 +++- src/Core/packages.lock.json | 13 --------- src/MainWindow.xaml | 4 +-- src/MainWindow.xaml.cs | 5 +++- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/App/MainWindow.axaml b/src/App/MainWindow.axaml index b7c8c0c..0ee629b 100644 --- a/src/App/MainWindow.axaml +++ b/src/App/MainWindow.axaml @@ -15,6 +15,45 @@ FontFamily="{DynamicResource InterFont}" FontSize="12" Foreground="#262629"> + + + + + + + + + + + + + + + + + + + + + + + - - + @@ -18,7 +18,7 @@ - + diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs index c70bd90..c3d9b2f 100644 --- a/src/MainWindow.xaml.cs +++ b/src/MainWindow.xaml.cs @@ -45,8 +45,11 @@ public MainWindow(string? capturePath = null, string? codexHome = null) PinButton.Foreground = new SolidColorBrush(settings.Pinned ? Color.FromRgb(0, 122, 255) : Colors.Gray); DateBox.SelectedDate = BeijingNow.Date; StartupBox.IsChecked = autostart.IsEnabled(); + // Assign the selection instead of toggling IsSelected on every item: clearing a later + // item's IsSelected would also clear the selection just set on an earlier one. foreach (var item in CadenceBox.Items.OfType()) - item.IsSelected = item.Tag?.ToString() == settings.RefreshSeconds.ToString(CultureInfo.InvariantCulture); + if (item.Tag?.ToString() == settings.RefreshSeconds.ToString(CultureInfo.InvariantCulture)) + CadenceBox.SelectedItem = item; foreach (var radio in new[] { MinuteWindow, FiveMinuteWindow, HalfHourWindow, HourWindow }) if (radio.Tag?.ToString() == settings.WindowSeconds.ToString(CultureInfo.InvariantCulture)) radio.IsChecked = true; timer.Interval = TimeSpan.FromSeconds(settings.RefreshSeconds); From 64a606ebeee854389adbb1ebcf8dc150702f7d94 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Thu, 15 Oct 2026 23:45:45 +0800 Subject: [PATCH 29/49] fix: Exclude Avalonia sources from the legacy WPF project --- src/CodexTpsFloat.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodexTpsFloat.csproj b/src/CodexTpsFloat.csproj index 6a9e4d9..2faed30 100644 --- a/src/CodexTpsFloat.csproj +++ b/src/CodexTpsFloat.csproj @@ -14,5 +14,5 @@ true - + From d2830e1ca7a14b5e02e24e72707a636cad4f2e46 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:10:58 +0800 Subject: [PATCH 30/49] fix: Start the Avalonia UI on an STA thread --- src/App/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/App/Program.cs b/src/App/Program.cs index 92a9b7a..9653879 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -7,6 +7,7 @@ namespace CodexTpsFloat.App; public static class Program { + [STAThread] public static int Main(string[] args) { var options = CommandLineOptions.Parse(args); From fcf2071402e2c1d8d73f0ac0158df15e314741ed Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:10:58 +0800 Subject: [PATCH 31/49] fix: Preserve the legacy Windows wake event name --- src/Core/SingleInstance.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/SingleInstance.cs b/src/Core/SingleInstance.cs index 9dad6a8..4f1bd08 100644 --- a/src/Core/SingleInstance.cs +++ b/src/Core/SingleInstance.cs @@ -59,7 +59,7 @@ static SingleInstance CreateWindows(string name, Action? showExisting) var mutex = new Mutex(true, scopedName, out bool first); try { - var wake = new EventWaitHandle(false, EventResetMode.AutoReset, scopedName + ".Show"); + var wake = new EventWaitHandle(false, EventResetMode.AutoReset, $"Local\\{name}.Show.v1"); RegisteredWaitHandle? registration = null; if (first && showExisting != null) registration = ThreadPool.RegisterWaitForSingleObject(wake, From bbd40caa94c4c1a3479e397392403d8573f6bc07 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:11:11 +0800 Subject: [PATCH 32/49] fix: Keep capture sessions from persisting settings --- src/App/MainWindow.axaml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index a214265..f73104e 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -257,6 +257,7 @@ void RenderHistory() void SaveSettings() { + if (capturePath != null) return; string? error = settingsStore.Save(settings); if (error != null && !closing) ToolTip.SetTip(StatusText, "设置保存失败:" + error); } From 8c3fac2f6f697a1cf81381d0ca3f46171706ae27 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:11:11 +0800 Subject: [PATCH 33/49] fix: Preserve saved coordinates when exiting an unopened panel --- src/App/MainWindow.axaml.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index f73104e..c7b9f24 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -31,7 +31,7 @@ public partial class MainWindow : Window UsageScanner scanner; RequestClockReader requestClocks; UsageSnapshot? snapshot; - bool refreshing, initialized, closing; + bool refreshing, initialized, closing, positioned; readonly List models = new() { "全部模型" }; public MainWindow() : this(null, null) { } @@ -151,6 +151,7 @@ void PositionPanel() Position = new PixelPoint( (int)Math.Round(Math.Clamp(x, areaLeft, Math.Max(areaLeft, areaRight - Width)) * scale), (int)Math.Round(Math.Clamp(y, areaTop, Math.Max(areaTop, areaBottom - Bounds.Height)) * scale)); + positioned = true; } public void ShowPanel() @@ -483,7 +484,7 @@ void DisposeWindow() if (closing) return; closing = true; timer.Stop(); - if (double.IsFinite(Position.X) && double.IsFinite(Position.Y)) + if (positioned && double.IsFinite(Position.X) && double.IsFinite(Position.Y)) { double scale = Screens.ScreenFromWindow(this)?.Scaling ?? 1; settings.X = Position.X / scale; From a84e6dbc1d87c3f6e815a3af4102e3afd75db50b Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:11:18 +0800 Subject: [PATCH 34/49] fix: Handle snapshot and single-instance startup errors --- src/App/Program.cs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/App/Program.cs b/src/App/Program.cs index 9653879..a35eaf8 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -10,22 +10,22 @@ public static class Program [STAThread] public static int Main(string[] args) { - var options = CommandLineOptions.Parse(args); - if (options == null) return 2; + try + { + var options = CommandLineOptions.Parse(args); + if (options == null) return 2; - if (options.SnapshotPath != null) - return SnapshotWriter.Write(options.SnapshotPath, options.CodexHome); + if (options.SnapshotPath != null) + return SnapshotWriter.Write(options.SnapshotPath, options.CodexHome); - using var singleInstance = SingleInstance.Create( - "CodexTpsFloat", () => App.ShowExistingPanel?.Invoke()); - if (!singleInstance.IsFirstInstance && options.CapturePath == null) - { - singleInstance.SignalExistingInstance(); - return 0; - } + using var singleInstance = SingleInstance.Create( + "CodexTpsFloat", () => App.ShowExistingPanel?.Invoke()); + if (!singleInstance.IsFirstInstance && options.CapturePath == null) + { + singleInstance.SignalExistingInstance(); + return 0; + } - try - { App.Options = options; App.SingleInstance = singleInstance; BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); From c0242720f9abf20a694eeeac617c28cd76c4a673 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:11:41 +0800 Subject: [PATCH 35/49] fix: Keep the Linux panel accessible without a tray host --- README.md | 2 +- src/App/MainWindow.axaml.cs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 844d957..4c91fa0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ macOS 首次运行时如果被 Gatekeeper 阻止,请在 Finder 中右键 `Code 便携构建使用单文件程序,并关闭内部程序集压缩,以减少运行时解压所需的内存;下载时由外层压缩包压缩。解压后的程序较大,其中包含 .NET 运行时及各平台的界面依赖。 - 默认读取 `CODEX_HOME`;未设置时读取当前用户的 `.codex`。同时统计 `sessions` 与 `archived_sessions`。 -- 托盘图标使用透明底的统计条形图;macOS 菜单栏图标随系统自动适配深浅色。悬停显示当前生成速度估算。Windows 和 Linux 启动后只显示系统托盘图标,单击打开或收起统计面板,右键可打开面板、立即刷新或退出;macOS 点击图标弹出菜单,可选择「打开统计面板」。面板展开时也不出现任务栏窗口按钮或 macOS Dock 图标。macOS 上菜单栏图标可能受系统或第三方工具影响而不可见,因此启动会直接显示面板,之后可用减号收起到菜单栏。 +- 托盘图标使用透明底的统计条形图;macOS 菜单栏图标随系统自动适配深浅色。悬停显示当前生成速度估算。Windows 启动后只显示系统托盘图标,单击打开或收起统计面板,右键可打开面板、立即刷新或退出;macOS 点击图标弹出菜单,可选择「打开统计面板」。面板展开时也不出现任务栏窗口按钮或 macOS Dock 图标。macOS 上菜单栏图标可能受系统或第三方工具影响而不可见,因此启动会直接显示面板,之后可用减号收起到菜单栏。Linux 无法可靠检测托盘宿主,启动时直接显示面板并禁用收起,确保没有托盘的桌面也能操作和退出。 - 面板可拖动,蓝色菱形切换置顶,减号收起到托盘。电源按钮退出整个工具。 - 切换 1 分钟、5 分钟、30 分钟、1 小时窗口,或筛选模型。自动刷新支持 5、15、30、60 秒。 - 展开「今日分时」,可以选择其他日期并导出中文 Markdown 或 JSON 报告。时区固定为北京时间 UTC+8。 diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index c7b9f24..bb77393 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -49,7 +49,7 @@ public MainWindow(string? capturePath = null, string? codexHome = null) InitializeState(); tray = OperatingSystem.IsBrowser() ? null : CreateTrayIcon(); - HideButton.IsVisible = tray != null; + HideButton.IsVisible = HasTray; if (tray != null && Application.Current != null) TrayIcon.SetIcons(Application.Current, new TrayIcons { tray }); @@ -59,7 +59,9 @@ public MainWindow(string? capturePath = null, string? codexHome = null) static DateTimeOffset BeijingNow => DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)); - public bool HasTray => tray != null; + // 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(); void InitializeState() { @@ -164,7 +166,7 @@ public void ShowPanel() void TogglePanel() { - if (IsVisible) Hide(); + if (IsVisible && HasTray) Hide(); else ShowPanel(); } @@ -317,7 +319,7 @@ void TopmostClick(object? sender, RoutedEventArgs e) void HideClick(object? sender, RoutedEventArgs e) { - if (tray != null) Hide(); + if (HasTray) Hide(); } void ExitClick(object? sender, RoutedEventArgs e) => Close(); From 702fe6462f2dc0853fb9d0cc64004d9d6863de7e Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:11:41 +0800 Subject: [PATCH 36/49] fix: Refresh the selected directory after in-flight scans --- src/App/MainWindow.axaml.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index bb77393..fdb6f36 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -31,7 +31,7 @@ public partial class MainWindow : Window UsageScanner scanner; RequestClockReader requestClocks; UsageSnapshot? snapshot; - bool refreshing, initialized, closing, positioned; + bool refreshing, initialized, closing, positioned, refreshPending; readonly List models = new() { "全部模型" }; public MainWindow() : this(null, null) { } @@ -172,7 +172,8 @@ void TogglePanel() async Task RefreshAsync() { - if (refreshing || closing) return; + if (closing) return; + if (refreshing) { refreshPending = true; return; } refreshing = true; RefreshButton.IsEnabled = false; StatusText.Text = "读取中"; @@ -185,7 +186,7 @@ async Task RefreshAsync() activeScanner.RequestStarts = activeClocks.Refresh(); return activeScanner.Refresh(BeijingNow); }); - if (closing) return; + if (closing || !ReferenceEquals(activeScanner, scanner)) return; bool followToday = snapshot == null || DateBox.SelectedDate == snapshot.GeneratedAt.Date; snapshot = next; if (followToday) DateBox.SelectedDate = next.GeneratedAt.Date; @@ -222,6 +223,11 @@ async Task RefreshAsync() { refreshing = false; RefreshButton.IsEnabled = true; + if (refreshPending && !closing) + { + refreshPending = false; + await RefreshAsync(); + } } } From 5e2ba799e10bfc2c2b1891deae8f2c26a9409fe3 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:12:00 +0800 Subject: [PATCH 37/49] ci: Label tray lifecycle checks as legacy WPF coverage --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c72a3a..ef5f42b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: with: dotnet-version: '10.0.x' - - name: Run hidden tray lifecycle tests (Windows only) + - name: Run legacy WPF hidden tray lifecycle tests (Windows only) if: runner.os == 'Windows' run: dotnet run --project tests/TrayLifecycleTests.csproj -c Release --nologo -- --never-show diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e90e6ab..60aa524 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: - name: Run core regression tests run: dotnet run --project tests/CoreTests.csproj -c Release --nologo - - name: Run hidden tray lifecycle tests (Windows only) + - name: Run legacy WPF hidden tray lifecycle tests (Windows only) if: runner.os == 'Windows' run: dotnet run --project tests/TrayLifecycleTests.csproj -c Release --nologo -- --never-show diff --git a/README.md b/README.md index 4c91fa0..dfa513f 100644 --- a/README.md +++ b/README.md @@ -92,4 +92,4 @@ git tag -a v1.1.4 -m "Release v1.1.4" git push origin v1.1.4 ``` -推送标签后,GitHub Actions 会校验标签与项目版本一致,运行核心测试;Windows 额外运行隐藏托盘生命周期测试。发布矩阵构建 Windows x64、macOS ARM64、macOS x64 和 Linux x64 便携包,解压后执行快照 smoke test,并生成 SHA-256 校验文件。全部平台成功后,工作流创建公开 Release 并上传所有压缩包与校验文件。打包只包含程序、README、隐私说明和第三方许可证,不包含本机设置、日志或调试符号。 +推送标签后,GitHub Actions 会校验标签与项目版本一致,运行核心测试;Windows 额外运行旧版 WPF 隐藏托盘生命周期回归测试;该测试不覆盖 Avalonia 发布入口的托盘行为,后者仍需在各桌面环境进行手动验收。发布矩阵构建 Windows x64、macOS ARM64、macOS x64 和 Linux x64 便携包,解压后执行快照 smoke test,并生成 SHA-256 校验文件。全部平台成功后,工作流创建公开 Release 并上传所有压缩包与校验文件。打包只包含程序、README、隐私说明和第三方许可证,不包含本机设置、日志或调试符号。 From 9de23d57e6a1dc334d656d518b89c537f0ab00be Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:12:41 +0800 Subject: [PATCH 38/49] fix: Escape Linux desktop autostart executable paths --- src/Core/Autostart.cs | 18 ++++++++++++++++-- tests/Program.cs | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Core/Autostart.cs b/src/Core/Autostart.cs index 88015ab..70198ac 100644 --- a/src/Core/Autostart.cs +++ b/src/Core/Autostart.cs @@ -92,6 +92,20 @@ public MacAutostart(string executable, string? launchAgentsDirectory = null) => public sealed class LinuxAutostart : IAutostartService { + // Exec quoting is decoded after desktop-entry string escapes. + static string EncodeExecutable(string path) + { + if (path.Contains('=') || path.Contains('\0')) + throw new ArgumentException("登录启动路径不能包含等号或空字符。"); + var quoted = new StringBuilder(); + foreach (char c in path) + { + if (c is '\\' or '"' or '`' or '$') quoted.Append('\\'); + quoted.Append(c == '%' ? "%%" : c.ToString()); + } + return quoted.ToString().Replace("\\", "\\\\").Replace("\n", "\\n") + .Replace("\r", "\\r").Replace("\t", "\\t"); + } readonly string executable; readonly string desktopPath; public LinuxAutostart(string executable, string autostartDirectory) => @@ -107,13 +121,13 @@ public LinuxAutostart(string executable, string autostartDirectory) => [Desktop Entry] Type=Application Name=Codex TPS - Exec="{executable}" + Exec="{EncodeExecutable(executable)}" Terminal=false X-GNOME-Autostart-enabled=true """; File.WriteAllText(desktopPath, desktop, new UTF8Encoding(false)); return null; } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return e.Message; } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException) { return e.Message; } } } diff --git a/tests/Program.cs b/tests/Program.cs index 602d49d..a769136 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -218,6 +218,24 @@ static void Seed(UsageScanner s, double t = origin) "登录启动条目包含可执行路径与自启标记"); Check(login.SetEnabled(false) == null && !login.IsEnabled() && !File.Exists(entry), "停用登录启动会移除条目"); + string linuxEntryDirectory = Path.Combine(directory, "linux-escaping"); + var escapedLogin = new LinuxAutostart("/opt/a\"b\\c%d$e`f\napp", linuxEntryDirectory); + Check(escapedLogin.SetEnabled(true) == null, "Linux 特殊字符路径可写入自启动条目"); + string execLine = File.ReadAllLines(Path.Combine(linuxEntryDirectory, "CodexTpsFloat.desktop")) + .Single(line => line.StartsWith("Exec=")); + // Decode desktop-entry escapes, then Exec quoting and literal percent codes. + string decodedExec = execLine[6..^1].Replace("\\n", "\n").Replace("\\\\", "\\"); + var decodedPath = new StringBuilder(); + for (int i = 0; i < decodedExec.Length; i++) + { + if (decodedExec[i] == '\\') i++; + else if (decodedExec[i] == '%' && decodedExec[i + 1] == '%') i++; + decodedPath.Append(decodedExec[i]); + } + Check(decodedPath.ToString() == "/opt/a\"b\\c%d$e`f\napp", "Exec 两层转义后保留原始路径且不插入新条目"); + Check(new LinuxAutostart("/opt/a=b/app", linuxEntryDirectory).SetEnabled(true) != null, + "拒绝 Desktop Entry 规范禁止的可执行路径"); + string instanceName = "codex-tps-test-" + Guid.NewGuid().ToString("N") + new string('x', 80); string instanceDirectory = Path.Combine(directory, "instance"); string readyPath = Path.Combine(directory, "instance-ready"); From 7f1740fa50e88c42560b1a3268baf3658fbd3c77 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:12:41 +0800 Subject: [PATCH 39/49] fix: Propagate Unix lock failures unrelated to contention --- src/Core/SingleInstance.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/SingleInstance.cs b/src/Core/SingleInstance.cs index 4f1bd08..2ba00a1 100644 --- a/src/Core/SingleInstance.cs +++ b/src/Core/SingleInstance.cs @@ -93,7 +93,7 @@ static SingleInstance CreateUnix(string name, Action? showExisting, string? stat FileShare.None); owns = true; } - catch (IOException) + catch (IOException error) when ((error.HResult & 0xffff) is 11 or 32 or 33 or 35) { } From 714a409df55855c5287659af88cd9609e3b2aba4 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Fri, 16 Oct 2026 01:12:41 +0800 Subject: [PATCH 40/49] fix: Exclude interactive event sources from panel dragging --- src/App/MainWindow.axaml.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index fdb6f36..2926ff7 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -284,10 +284,10 @@ void DragPanel(object? sender, PointerPressedEventArgs e) // macOS three-finger drag and ordinary mouse drag both end up here, so the whole // panel is a drag surface except for controls that need the press themselves. static bool HasInteractiveAncestor(Visual source) => - source.FindAncestorOfType