From bad0b8dc41ea2810efc8eac41cee54326545a851 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:00:07 +0000 Subject: [PATCH 1/7] Count real Codex token lines and harden the token index Codex rollouts carry no cache_write_input_tokens total, so every real token_count line was skipped and Codex token usage stayed empty. The field is now optional; files an older build walked without counting a line are read once more while this PC has no Codex usage yet. A file cut between its stat and its open is no longer read again from byte 0, a schema version that is not an integer reads as unknown instead of throwing, and a custom range is clamped and drawn per week past a year. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- src/AiUsage/Stats/CodexUsageLogParser.cs | 11 +- src/AiUsage/Stats/StatsIndexer.cs | 21 ++++ src/AiUsage/Stats/StatsStore.cs | 18 +++- src/AiUsage/Stats/StatsViewModel.cs | 20 ++-- tests/AiUsage.Tests/StatsIndexTests.cs | 113 +++++++++++++++++++++ tests/AiUsage.Tests/StatsViewModelTests.cs | 8 ++ 6 files changed, 180 insertions(+), 11 deletions(-) diff --git a/src/AiUsage/Stats/CodexUsageLogParser.cs b/src/AiUsage/Stats/CodexUsageLogParser.cs index cabfd06..33243af 100644 --- a/src/AiUsage/Stats/CodexUsageLogParser.cs +++ b/src/AiUsage/Stats/CodexUsageLogParser.cs @@ -112,15 +112,18 @@ public bool TryParseLine(string line, out UsageEvent result) timestampProperty.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var timestamp)) return false; - // A line missing any of the four running totals says nothing reliable: reading the gap - // as zero would make the next complete line count its whole total as new. Such a line - // is skipped and the previous totals stay. + // A line missing any of the three running totals Codex writes says nothing reliable: + // reading the gap as zero would make the next complete line count its whole total as new. + // Such a line is skipped and the previous totals stay. if (!TryGetLong(total, "input_tokens", out var input) || !TryGetLong(total, "output_tokens", out var output) - || !TryGetLong(total, "cache_write_input_tokens", out var cacheCreation) || !TryGetLong(total, "cached_input_tokens", out var cacheRead)) return false; + // Codex's own rollouts carry no cache-write total at all; requiring it dropped every real + // line. Where it is missing the previous total stands, so it adds nothing either way. + var cacheCreation = TryGetLong(total, "cache_write_input_tokens", out var cacheWrite) ? cacheWrite : _cumulativeCacheCreation; + // Codex resets its running totals not only across files but also mid file, after a // context compaction - each of the four counters is checked against its own previous // value independently, never against the others' sum, so one counter dropping while the diff --git a/src/AiUsage/Stats/StatsIndexer.cs b/src/AiUsage/Stats/StatsIndexer.cs index 40ce167..6a539ee 100644 --- a/src/AiUsage/Stats/StatsIndexer.cs +++ b/src/AiUsage/Stats/StatsIndexer.cs @@ -347,6 +347,14 @@ private static void WalkDirectory( using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); openLength = stream.Length; + if (IsShorterThanRead(existing, openLength)) + { + // Replaced or cut between the stat above and this open: handled like a shrunk file + // rather than read again from the start (the reader falls back to 0 past the end). + _store.SetSourceFile(existing with { Offset = openLength, Size = openLength, WriteTimeUtc = info.LastWriteTimeUtc }); + return (false, 0, 0); + } + consumedOffset = CompleteLineReader.Read(stream, offset, acceptTrailingLineWithoutNewline, MaxRecordBytes, LogOversizedRecord, line => { if (ClaudeUsageLogParser.TryParse(line, out var usageEvent)) @@ -447,6 +455,14 @@ private static void WalkDirectory( using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); openLength = stream.Length; + if (IsShorterThanRead(existing, openLength)) + { + // Replaced or cut between the stat above and this open: handled like a shrunk file + // rather than read again from the start (the reader falls back to 0 past the end). + _store.SetSourceFile(existing with { Offset = openLength, Size = openLength, WriteTimeUtc = info.LastWriteTimeUtc }); + return (false, 0, 0); + } + consumedOffset = CompleteLineReader.Read(stream, offset, acceptTrailingLineWithoutNewline, MaxRecordBytes, LogOversizedRecord, line => { if (parser.TryParseLine(line, out var usageEvent)) @@ -768,6 +784,11 @@ private bool TryFindState(string path, Dictionary return _store.TryGetSourceFile(path, out state); } + /// True when the opened file is shorter than what was already read of it - checked on the + /// open stream, since the file can change after it was listed. + internal static bool IsShorterThanRead([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] StatsSourceFileState? existing, long openLength) => + existing is not null && openLength < existing.Offset; + private static FileInfo? TryStat(string path) { try diff --git a/src/AiUsage/Stats/StatsStore.cs b/src/AiUsage/Stats/StatsStore.cs index e79a34b..26f33a1 100644 --- a/src/AiUsage/Stats/StatsStore.cs +++ b/src/AiUsage/Stats/StatsStore.cs @@ -112,7 +112,9 @@ internal static void SnapshotIndex(string sourcePath, string destinationPath) using var version = connection.CreateCommand(); version.CommandText = "SELECT MAX(version) FROM schema_version"; var value = version.ExecuteScalar(); - return value is null or DBNull ? null : (int)Math.Min(int.MaxValue, Convert.ToInt64(value, CultureInfo.InvariantCulture)); + // Only an integer is a version; a file from elsewhere (a backup zip is never trusted) can + // hold text, a real or a blob there, which Convert would throw on past every caller's catch. + return value is long number ? (int)Math.Clamp(number, int.MinValue, int.MaxValue) : null; } catch (SqliteException) { @@ -702,6 +704,19 @@ value TEXT NOT NULL if (originalVersion is null or < 4) Execute(connection, $"INSERT OR IGNORE INTO meta (key, value) VALUES ('{SessionsBackfilledKey}', '1')"); + // Builds before this one required a cache-write total Codex never writes, so every Codex file + // was walked to its end without counting a line. Such a file still has all four running + // totals at zero and is read again from the start, once - only while this PC has no Codex + // usage at all, so nothing that was counted can be counted a second time. + Execute(connection, $""" + DELETE FROM source_file WHERE provider = 'codex' + AND cumulative_input_tokens = 0 AND cumulative_output_tokens = 0 + AND cumulative_cache_creation_tokens = 0 AND cumulative_cache_read_tokens = 0 + AND NOT EXISTS (SELECT 1 FROM usage WHERE provider = 'codex' AND machine = '') + AND NOT EXISTS (SELECT 1 FROM meta WHERE key = '{CodexRereadKey}'); + INSERT OR IGNORE INTO meta (key, value) VALUES ('{CodexRereadKey}', '1'); + """); + using (var writeVersion = connection.CreateCommand()) { writeVersion.CommandText = "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, $version)"; @@ -713,6 +728,7 @@ value TEXT NOT NULL } private const string SessionsBackfilledKey = "sessions_backfilled"; + private const string CodexRereadKey = "codex_uncounted_files_reread"; private static void Execute(SqliteConnection connection, string sql) { diff --git a/src/AiUsage/Stats/StatsViewModel.cs b/src/AiUsage/Stats/StatsViewModel.cs index 90f3b9c..3eb3e60 100644 --- a/src/AiUsage/Stats/StatsViewModel.cs +++ b/src/AiUsage/Stats/StatsViewModel.cs @@ -113,8 +113,8 @@ public void RestoreSelection( { if (customFrom is { } first && customTo is { } last) { - _customFrom = first <= last ? first : last; - _customTo = first <= last ? last : first; + _customFrom = ClampCustomDay(first <= last ? first : last); + _customTo = ClampCustomDay(first <= last ? last : first); } if (RangeChoices.Any(choice => choice.Value == range) && (range != CustomRange || (customFrom is not null && customTo is not null))) @@ -167,8 +167,10 @@ private async Task ApplyCustomRange() /// Applies a custom range of the two days, whichever way round they come. internal async Task SetCustomRange(DateOnly from, DateOnly to) { - _customFrom = from; - _customTo = to; + // A date typed into the picker can be anything down to the year 1, where the previous period + // (from.AddDays(-1)) throws and the chart would draw a bar for every day since. + _customFrom = ClampCustomDay(from); + _customTo = ClampCustomDay(to); SelectedDay = null; SelectedRange = CustomRange; UpdateCustomChoiceLabel(); @@ -300,7 +302,7 @@ public Choice? SelectedColorByChoice private StatsGrouping selectedGrouping = StatsGrouping.Day; /// True when the day grouping draws one column per calendar week instead of one per - /// day: only for a whole history that spans more than a year. Up to 366 days every day gets its + /// day: only for a whole history or a custom range that spans more than a year. Up to 366 days every day gets its /// own column, the 12 month range included. The table under the day grouping stays per day /// regardless. [ObservableProperty] @@ -1136,7 +1138,7 @@ private void RecomputeFrom(IReadOnlyList source) // up. The earliest day actually stored is the first one worth drawing. var gapFillFrom = GapFillStart(from, inRange, to); - var weeklyPerDay = SelectedRange == "All" && to.DayNumber - gapFillFrom.DayNumber + 1 > WeeklyPerDayThresholdDays; + var weeklyPerDay = SelectedRange is "All" or CustomRange && to.DayNumber - gapFillFrom.DayNumber + 1 > WeeklyPerDayThresholdDays; IsWeeklyPerDay = weeklyPerDay; // The table under the day grouping stays per day; only the bars are bundled per week. @@ -1599,6 +1601,12 @@ internal static StatsPeriod ResolvePeriod(string range, DateOnly today, DateOnly } } + internal static readonly DateOnly EarliestCustomDay = new(2000, 1, 1); + internal static readonly DateOnly LatestCustomDay = new(2099, 12, 31); + + internal static DateOnly ClampCustomDay(DateOnly day) => + day < EarliestCustomDay ? EarliestCustomDay : day > LatestCustomDay ? LatestCustomDay : day; + private static StatsPeriod SameLengthBefore(DateOnly from, DateOnly to) { var previousTo = from.AddDays(-1); diff --git a/tests/AiUsage.Tests/StatsIndexTests.cs b/tests/AiUsage.Tests/StatsIndexTests.cs index 8dffde3..4028555 100644 --- a/tests/AiUsage.Tests/StatsIndexTests.cs +++ b/tests/AiUsage.Tests/StatsIndexTests.cs @@ -1319,4 +1319,117 @@ private static string ClaudeLine(string timestamp, string model, long input, lon "{\"type\":\"assistant\",\"timestamp\":\"" + timestamp + "\",\"message\":{\"model\":\"" + model + "\",\"usage\":{\"input_tokens\":" + input + ",\"output_tokens\":" + output + ",\"cache_creation_input_tokens\":" + cacheCreation + ",\"cache_read_input_tokens\":" + cacheRead + "}}}"; + + // Codex's own rollouts write no cache-write total; requiring one dropped every real line. + [Fact] + public void CodexUsageLogParser_counts_a_real_line_without_a_cache_write_total() + { + const string first = """ + {"timestamp":"2026-09-01T09:30:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1200,"cached_input_tokens":200,"output_tokens":90,"reasoning_output_tokens":10,"total_tokens":1290}}}} + """; + const string second = """ + {"timestamp":"2026-09-01T09:31:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1500,"cached_input_tokens":300,"output_tokens":100,"reasoning_output_tokens":10,"total_tokens":1600}}}} + """; + + var parser = new CodexUsageLogParser(); + Assert.True(parser.TryParseLine(first, out var one)); + Assert.Equal(1000, one.InputTokens); + Assert.Equal(90, one.OutputTokens); + Assert.Equal(200, one.CacheReadTokens); + Assert.Equal(0, one.CacheCreationTokens); + + Assert.True(parser.TryParseLine(second, out var two)); + Assert.Equal(200, two.InputTokens); + Assert.Equal(10, two.OutputTokens); + Assert.Equal(100, two.CacheReadTokens); + } + + [Fact] + public void CodexUsageLogParser_keeps_the_previous_cache_write_total_when_a_line_lacks_it() + { + const string withField = """ + {"timestamp":"2026-09-01T09:30:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":0,"cache_write_input_tokens":40,"output_tokens":10}}}} + """; + const string without = """ + {"timestamp":"2026-09-01T09:31:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":150,"cached_input_tokens":0,"output_tokens":20}}}} + """; + const string withAgain = """ + {"timestamp":"2026-09-01T09:32:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":160,"cached_input_tokens":0,"cache_write_input_tokens":45,"output_tokens":25}}}} + """; + + var parser = new CodexUsageLogParser(); + Assert.True(parser.TryParseLine(withField, out _)); + Assert.True(parser.TryParseLine(without, out var gap)); + Assert.Equal(0, gap.CacheCreationTokens); + Assert.True(parser.TryParseLine(withAgain, out var after)); + Assert.Equal(5, after.CacheCreationTokens); // 45 - 40, never the whole 45 again + } + + // Files an older build walked without counting a line (all four totals still zero) are read + // again once; a file that did count something, and any Claude file, keep their place. The v4 and + // v5 migration tests above cover an index that holds Codex usage, where nothing is read again. + [Fact] + public void Opening_an_older_index_rereads_Codex_files_that_counted_nothing_once() + { + using var source = TestPaths.CreateDisposableDirectory("stats-codex-reread-src"); + var store = new StatsStore(source); + store.SetSourceFile(new StatsSourceFileState("codex-uncounted", "codex", 500, 500, DateTime.UtcNow)); + store.SetSourceFile(new StatsSourceFileState("codex-counted", "codex", 500, 500, DateTime.UtcNow, CumulativeInputTokens: 10)); + store.SetSourceFile(new StatsSourceFileState("claude-file", "claude", 500, 500, DateTime.UtcNow)); + SqliteConnection.ClearAllPools(); + var sourceDb = Path.Combine(source, "stats.db"); + using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = sourceDb, Pooling = false }.ToString())) + { + connection.Open(); + using var forget = connection.CreateCommand(); + forget.CommandText = "DELETE FROM meta WHERE key = 'codex_uncounted_files_reread'"; + forget.ExecuteNonQuery(); + } + + // A copy at a new path, so this process opens it as an older build's file. + using var target = TestPaths.CreateDisposableDirectory("stats-codex-reread"); + File.Copy(sourceDb, Path.Combine(target, "stats.db")); + var reopened = new StatsStore(target); + + Assert.Null(reopened.GetSourceFile("codex-uncounted")); + Assert.Equal(500, reopened.GetSourceFile("codex-counted")!.Offset); + Assert.Equal(500, reopened.GetSourceFile("claude-file")!.Offset); + + // Once only: a file walked by this build keeps its place on every later open. + reopened.SetSourceFile(new StatsSourceFileState("codex-new", "codex", 300, 300, DateTime.UtcNow)); + SqliteConnection.ClearAllPools(); + using var again = TestPaths.CreateDisposableDirectory("stats-codex-reread-again"); + File.Copy(Path.Combine(target, "stats.db"), Path.Combine(again, "stats.db")); + Assert.Equal(300, new StatsStore(again).GetSourceFile("codex-new")!.Offset); + } + + [Fact] + public void A_file_shorter_than_its_read_offset_when_opened_is_not_read_from_the_start() + { + var state = new StatsSourceFileState("file-a", "codex", 8000, 8000, DateTime.UtcNow); + + Assert.True(StatsIndexer.IsShorterThanRead(state, 5000)); + Assert.False(StatsIndexer.IsShorterThanRead(state, 8000)); + Assert.False(StatsIndexer.IsShorterThanRead(state, 10000)); + Assert.False(StatsIndexer.IsShorterThanRead(null, 0)); + } + + [Theory] + [InlineData("'x'")] + [InlineData("1e300")] + [InlineData("x'00'")] + public void A_schema_version_that_is_no_integer_reads_as_unknown_instead_of_throwing(string value) + { + using var directory = TestPaths.CreateDisposableDirectory("stats-bad-version"); + var path = Path.Combine(directory, "stats.db"); + using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString())) + { + connection.Open(); + using var create = connection.CreateCommand(); + create.CommandText = $"CREATE TABLE schema_version (version); INSERT INTO schema_version VALUES ({value});"; + create.ExecuteNonQuery(); + } + + Assert.Null(StatsStore.TryReadSchemaVersion(path)); + } } diff --git a/tests/AiUsage.Tests/StatsViewModelTests.cs b/tests/AiUsage.Tests/StatsViewModelTests.cs index 3175bbe..5dd3f5a 100644 --- a/tests/AiUsage.Tests/StatsViewModelTests.cs +++ b/tests/AiUsage.Tests/StatsViewModelTests.cs @@ -1705,4 +1705,12 @@ public async Task SessionRowsFollowTheRangeAndLeaveTheOtherGroupingsEmpty() Assert.True(viewModel.IsBarChart); Assert.True(viewModel.ShowPlainTable); } + + [Fact] + public void A_custom_day_is_kept_within_a_range_the_charts_can_draw() + { + Assert.Equal(StatsViewModel.EarliestCustomDay, StatsViewModel.ClampCustomDay(DateOnly.MinValue)); + Assert.Equal(StatsViewModel.LatestCustomDay, StatsViewModel.ClampCustomDay(DateOnly.MaxValue)); + Assert.Equal(new DateOnly(2026, 9, 1), StatsViewModel.ClampCustomDay(new DateOnly(2026, 9, 1))); + } } From 4d821b8115702fce28c46d9792a2dd56bfee5e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:00:07 +0000 Subject: [PATCH 2/7] Honor CODEX_HOME and keep web read failures inside the provider Codex keeps its sessions under CODEX_HOME when set. A cancellation the caller did not ask for (a hidden session closed under a running read) is a failed read instead of an exception out of the provider. Account names are not cut inside a surrogate pair, and the signer certificate is disposed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- src/AiUsage/Models/AppSettings.cs | 7 ++++- src/AiUsage/Providers/CodexPaths.cs | 10 +++++++- src/AiUsage/Providers/WebUsageSource.cs | 4 ++- src/AiUsage/Web/AuthenticodeSignature.cs | 2 +- tests/AiUsage.Tests/CodexPathsTests.cs | 30 ++++++++++++++++++++++ tests/AiUsage.Tests/WebUsageSourceTests.cs | 26 +++++++++++++++++++ 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/AiUsage.Tests/CodexPathsTests.cs diff --git a/src/AiUsage/Models/AppSettings.cs b/src/AiUsage/Models/AppSettings.cs index 4a0d144..2fa3d62 100644 --- a/src/AiUsage/Models/AppSettings.cs +++ b/src/AiUsage/Models/AppSettings.cs @@ -347,7 +347,12 @@ public sealed class ProviderSettings public static string NormalizeAccountName(string? name) { var trimmed = (name ?? "").Trim(); - return trimmed.Length > MaxAccountNameLength ? trimmed[..MaxAccountNameLength].TrimEnd() : trimmed; + if (trimmed.Length <= MaxAccountNameLength) + return trimmed; + + // Never half of a character made of two code units (an emoji): that half is no character. + var cut = char.IsHighSurrogate(trimmed[MaxAccountNameLength - 1]) ? MaxAccountNameLength - 1 : MaxAccountNameLength; + return trimmed[..cut].TrimEnd(); } public ThresholdSettings Thresholds { get; set; } = new(); diff --git a/src/AiUsage/Providers/CodexPaths.cs b/src/AiUsage/Providers/CodexPaths.cs index a5b662d..6fb2823 100644 --- a/src/AiUsage/Providers/CodexPaths.cs +++ b/src/AiUsage/Providers/CodexPaths.cs @@ -4,7 +4,15 @@ namespace AiUsage.Providers; /// folder for its rate limits and the statistics index reads both. internal static class CodexPaths { - private static string Root => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); + private static string Root => ResolveRoot( + Environment.GetEnvironmentVariable("CODEX_HOME"), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Directory.Exists); + + /// Codex itself keeps everything under CODEX_HOME when that is set; ~/.codex otherwise, + /// and also when the variable names a folder that does not exist. + internal static string ResolveRoot(string? codexHome, string userProfile, Func folderExists) => + !string.IsNullOrWhiteSpace(codexHome) && folderExists(codexHome.Trim()) + ? codexHome.Trim() + : Path.Combine(userProfile, ".codex"); public static string Sessions => Path.Combine(Root, "sessions"); diff --git a/src/AiUsage/Providers/WebUsageSource.cs b/src/AiUsage/Providers/WebUsageSource.cs index 7bbd3aa..04fe90d 100644 --- a/src/AiUsage/Providers/WebUsageSource.cs +++ b/src/AiUsage/Providers/WebUsageSource.cs @@ -403,7 +403,9 @@ private bool IsPathAndStatus(string? entry) { envelopeJson = await _executeScript(script, ct); } - catch (Exception ex) when (ex is not OperationCanceledException) + // Only the caller's own cancellation propagates: a hidden session closed under a running read + // throws one of its own, which is a failed read like any other. + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { return (null, (WebUsageResult.Failed, null)); } diff --git a/src/AiUsage/Web/AuthenticodeSignature.cs b/src/AiUsage/Web/AuthenticodeSignature.cs index f1e168b..d4c020d 100644 --- a/src/AiUsage/Web/AuthenticodeSignature.cs +++ b/src/AiUsage/Web/AuthenticodeSignature.cs @@ -40,7 +40,7 @@ private static List ReadSignerNames(string path) try { #pragma warning disable SYSLIB0057 // No replacement reads the signer of a signed file; WinVerifyTrust above has already proven the signature. - var certificate = X509Certificate.CreateFromSignedFile(path); + using var certificate = X509Certificate.CreateFromSignedFile(path); #pragma warning restore SYSLIB0057 using var certificate2 = new X509Certificate2(certificate); var names = new List(); diff --git a/tests/AiUsage.Tests/CodexPathsTests.cs b/tests/AiUsage.Tests/CodexPathsTests.cs new file mode 100644 index 0000000..b74c26f --- /dev/null +++ b/tests/AiUsage.Tests/CodexPathsTests.cs @@ -0,0 +1,30 @@ +using AiUsage.Providers; +using Xunit; + +namespace AiUsage.Tests; + +public class CodexPathsTests +{ + private const string Profile = @"C:\Users\someone"; + + [Fact] + public void CODEX_HOME_is_where_Codex_keeps_its_sessions_when_it_is_set() + { + Assert.Equal(@"D:\codex", CodexPaths.ResolveRoot(@"D:\codex", Profile, _ => true)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Without_CODEX_HOME_the_folder_in_the_profile_is_read(string? codexHome) + { + Assert.Equal(Path.Combine(Profile, ".codex"), CodexPaths.ResolveRoot(codexHome, Profile, _ => true)); + } + + [Fact] + public void A_CODEX_HOME_naming_a_missing_folder_falls_back_to_the_profile() + { + Assert.Equal(Path.Combine(Profile, ".codex"), CodexPaths.ResolveRoot(@"D:\gone", Profile, _ => false)); + } +} diff --git a/tests/AiUsage.Tests/WebUsageSourceTests.cs b/tests/AiUsage.Tests/WebUsageSourceTests.cs index 8d6b5eb..6d8503a 100644 --- a/tests/AiUsage.Tests/WebUsageSourceTests.cs +++ b/tests/AiUsage.Tests/WebUsageSourceTests.cs @@ -402,4 +402,30 @@ public async Task No_email_field_at_all_leaves_the_account_label_null() Assert.Null(result.AccountLabel); } + + // A hidden session closed under a running read throws a cancellation of its own; only the + // caller's own cancellation may leave the source, anything else is a failed read. + [Fact] + public async Task A_cancellation_the_caller_did_not_ask_for_is_a_failed_read() + { + var source = Source((_, _) => throw new OperationCanceledException("The hidden session was closed.")); + + var result = await source.FetchAsync(new AppSettings(), _ => { }, CancellationToken.None); + + Assert.Equal(WebUsageOutcome.Failed, result.Outcome); + } + + [Fact] + public async Task The_callers_own_cancellation_still_propagates() + { + using var cts = new CancellationTokenSource(); + var source = Source((_, ct) => + { + cts.Cancel(); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(""); + }); + + await Assert.ThrowsAnyAsync(() => source.FetchAsync(new AppSettings(), _ => { }, cts.Token)); + } } From 89f52994a3e73edd9bee6c847d78f27dc9252c30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:00:07 +0000 Subject: [PATCH 3/7] Survive null hidden windows, flush atomic writes, export window labels A null entry in hiddenWindows threw on every start and on import. An empty monitor rectangle no longer counts as covered by full screen. Atomic writes flush to disk before the rename. The history CSV gains a label column so several Other series can be told apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- src/AiUsage/Io/AtomicFile.cs | 9 ++++++- src/AiUsage/Services/FullscreenWatcher.cs | 4 +++- src/AiUsage/Services/HistoryExport.cs | 6 +++-- src/AiUsage/Storage/LegacyWindowLabels.cs | 5 +++- tests/AiUsage.Tests/FullscreenWatcherTests.cs | 7 ++++++ tests/AiUsage.Tests/HistoryExportTests.cs | 24 ++++++++++++++++--- tests/AiUsage.Tests/SettingsStoreTests.cs | 24 +++++++++++++++++++ 7 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/AiUsage/Io/AtomicFile.cs b/src/AiUsage/Io/AtomicFile.cs index eaa7ee3..469470a 100644 --- a/src/AiUsage/Io/AtomicFile.cs +++ b/src/AiUsage/Io/AtomicFile.cs @@ -11,7 +11,14 @@ public static class AtomicFile public static void WriteAllBytes(string path, byte[] bytes) { var tempPath = $"{path}.{Environment.ProcessId}.tmp"; - File.WriteAllBytes(tempPath, bytes); + using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + // The rename is journaled but the content is not: without this, a power loss right after + // the move can leave the target empty instead of holding either version. + stream.Flush(flushToDisk: true); + } + File.Move(tempPath, path, overwrite: true); } } diff --git a/src/AiUsage/Services/FullscreenWatcher.cs b/src/AiUsage/Services/FullscreenWatcher.cs index 196f6f6..cc28f78 100644 --- a/src/AiUsage/Services/FullscreenWatcher.cs +++ b/src/AiUsage/Services/FullscreenWatcher.cs @@ -83,7 +83,9 @@ internal static bool IsFullscreen(bool foregroundIsOwn, string? className, Scree if (foregroundIsOwn || IsShellWindowClass(className)) return false; - return foreground.Covers(monitor); + // An empty monitor rectangle (no foreground window, or the monitor lookup failed) is no screen + // to cover - the empty foreground rectangle would otherwise "cover" it. + return monitor.Right > monitor.Left && monitor.Bottom > monitor.Top && foreground.Covers(monitor); } /// The desktop and the taskbar: they fill the screen without being a full-screen app. diff --git a/src/AiUsage/Services/HistoryExport.cs b/src/AiUsage/Services/HistoryExport.cs index 918486e..656e9c6 100644 --- a/src/AiUsage/Services/HistoryExport.cs +++ b/src/AiUsage/Services/HistoryExport.cs @@ -11,7 +11,7 @@ namespace AiUsage.Services; /// public static class HistoryExport { - private const string Header = "provider,window,timestamp_utc,percent,resets_at_utc,tokens"; + private const string Header = "provider,window,timestamp_utc,percent,resets_at_utc,tokens,label"; public static string ToCsv(IReadOnlyDictionary> byProvider) { @@ -28,7 +28,9 @@ public static string ToCsv(IReadOnlyDictionary 0 } hidden) - provider.HiddenWindows = hidden.Select(label => Migrate(accountKey, label)).Distinct(StringComparer.Ordinal).ToList(); + provider.HiddenWindows = hidden.Where(label => label is not null).Select(label => Migrate(accountKey, label)) + .Distinct(StringComparer.Ordinal).ToList(); } // A "Label:Chat" choice belongs to Copilot only when the icon follows a Copilot account or the diff --git a/tests/AiUsage.Tests/FullscreenWatcherTests.cs b/tests/AiUsage.Tests/FullscreenWatcherTests.cs index 8cbb521..bb07d71 100644 --- a/tests/AiUsage.Tests/FullscreenWatcherTests.cs +++ b/tests/AiUsage.Tests/FullscreenWatcherTests.cs @@ -91,4 +91,11 @@ public void Hiding_on_full_screen_is_on_by_default_and_survives_a_round_trip() Assert.False(reload.Load().HideOnFullscreen); } + + // Without a foreground window both rectangles are empty, and an empty one used to "cover" the other. + [Fact] + public void Without_a_foreground_window_or_a_monitor_nothing_is_full_screen() + { + Assert.False(FullscreenWatcher.IsFullscreen(false, null, default, default, AcceptsNotifications)); + } } diff --git a/tests/AiUsage.Tests/HistoryExportTests.cs b/tests/AiUsage.Tests/HistoryExportTests.cs index 5a0ee85..8cb598a 100644 --- a/tests/AiUsage.Tests/HistoryExportTests.cs +++ b/tests/AiUsage.Tests/HistoryExportTests.cs @@ -13,7 +13,7 @@ public class HistoryExportTests [Fact] public void ToCsv_writes_the_header_row_first() => Assert.StartsWith( - "provider,window,timestamp_utc,percent,resets_at_utc,tokens\r\n", + "provider,window,timestamp_utc,percent,resets_at_utc,tokens,label\r\n", HistoryExport.ToCsv(new Dictionary>())); [Fact] @@ -26,7 +26,7 @@ public void ToCsv_writes_one_fully_populated_row() var row = HistoryExport.ToCsv(byProvider).Split("\r\n")[1]; - Assert.Equal("codex,FiveHour,2026-03-15T14:30:00.0000000Z,42.5,2026-03-15T16:30:00.0000000Z,12345", row); + Assert.Equal("codex,FiveHour,2026-03-15T14:30:00.0000000Z,42.5,2026-03-15T16:30:00.0000000Z,12345,", row); } [Fact] @@ -40,7 +40,7 @@ public void ToCsv_leaves_reset_and_tokens_empty_rather_than_the_word_null() var csv = HistoryExport.ToCsv(byProvider); var row = csv.Split("\r\n")[1]; - Assert.Equal("codex,Weekly,2026-03-15T14:30:00.0000000Z,10,,", row); + Assert.Equal("codex,Weekly,2026-03-15T14:30:00.0000000Z,10,,,", row); Assert.DoesNotContain("null", csv); } @@ -79,4 +79,22 @@ public void ToCsv_escapes_a_provider_id_with_quotes_and_a_formula_prefix() Assert.StartsWith("\"'=cmd\"\"x\",Weekly,", row); } + + [Fact] + public void ToCsv_writes_the_window_label_so_two_other_series_stay_apart() + { + var byProvider = new Dictionary> + { + ["cursor"] = + [ + new HistoryPoint(1, Timestamp, WindowKind.Other, 10, null, Label: "Window_CursorModels"), + new HistoryPoint(1, Timestamp, WindowKind.Other, 20, null, Label: "Window_CursorGrok"), + ], + }; + + var rows = HistoryExport.ToCsv(byProvider).Split("\r\n"); + + Assert.EndsWith(",Window_CursorModels", rows[1]); + Assert.EndsWith(",Window_CursorGrok", rows[2]); + } } diff --git a/tests/AiUsage.Tests/SettingsStoreTests.cs b/tests/AiUsage.Tests/SettingsStoreTests.cs index 48eac2e..87db2cb 100644 --- a/tests/AiUsage.Tests/SettingsStoreTests.cs +++ b/tests/AiUsage.Tests/SettingsStoreTests.cs @@ -1402,4 +1402,28 @@ public ValueTask DisposeAsync() } } } + + [Fact] + public void Load_drops_a_null_hidden_window_instead_of_failing_to_start() + { + var directory = TempDirectory(); + File.WriteAllText(Path.Combine(directory, "settings.json"), + """{"schemaVersion":1,"providers":{"copilot":{"hiddenWindows":[null,"Chat"]}}}"""); + using var store = new SettingsStore(directory); + + var loaded = store.Load(); + + Assert.Equal(["Window_CopilotChat"], loaded.Providers["copilot"].HiddenWindows); + } + + [Fact] + public void An_account_name_is_never_cut_in_the_middle_of_an_emoji() + { + var name = new string('a', ProviderSettings.MaxAccountNameLength - 1) + "\U0001F600"; + + var normalized = ProviderSettings.NormalizeAccountName(name); + + Assert.Equal(new string('a', ProviderSettings.MaxAccountNameLength - 1), normalized); + Assert.Equal("Work", ProviderSettings.NormalizeAccountName(" Work ")); + } } From 488a75d9f6fee1d649071a67d879fe68dcfefa6a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:00:07 +0000 Subject: [PATCH 4/7] Fix tray-start polling, double account adds and UI language refresh A --tray start marked the window visible, so it polled at the open window pace. A double click on Copilot's add account added the same login twice. The top resize grip now has the same limits as the bottom one. The update notice and tile chart captions follow a language switch, the top projects percentage uses the number format, and the value axis of the bar chart is no bar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- src/AiUsage/App.xaml.cs | 6 ++++++ src/AiUsage/ViewModels/MainViewModel.cs | 1 + .../ViewModels/ProviderTileViewModel.cs | 14 +++++++++++-- src/AiUsage/ViewModels/SettingsViewModel.cs | 8 ++++++- .../ViewModels/UpdateNoticeViewModel.cs | 3 +++ src/AiUsage/Views/Controls/StatsBarChart.cs | 9 ++++---- .../Views/Controls/StatsHorizontalBarChart.cs | 2 +- src/AiUsage/Views/MainWindow.xaml.cs | 8 ++++--- tests/AiUsage.Tests/SettingsViewModelTests.cs | 21 ++++++++++++++++++- tests/AiUsage.Tests/StatsWindowTests.cs | 8 +++++++ tests/AiUsage.Tests/UpdateNoticeTests.cs | 12 +++++++++++ 11 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/AiUsage/App.xaml.cs b/src/AiUsage/App.xaml.cs index 5f7cfdf..eb720fc 100644 --- a/src/AiUsage/App.xaml.cs +++ b/src/AiUsage/App.xaml.cs @@ -250,6 +250,12 @@ void DeleteLeftoverOnce(object? sender, EventArgs args) if (ShouldShowMainWindow(Dispatcher.HasShutdownStarted)) _mainWindow.Show(); } + else + { + // The view model starts out as "window visible"; left that way, a tray-only start polls + // at the open-window pace and the first show from the tray skips its catch-up refresh. + _mainWindow.ViewModel.WindowVisible = false; + } // The one visible trace of a restore: where the previous data went, or that nothing changed. if (_restoreOutcome != RestoreOutcome.NoPending) diff --git a/src/AiUsage/ViewModels/MainViewModel.cs b/src/AiUsage/ViewModels/MainViewModel.cs index d5e1446..0adb9f3 100644 --- a/src/AiUsage/ViewModels/MainViewModel.cs +++ b/src/AiUsage/ViewModels/MainViewModel.cs @@ -741,6 +741,7 @@ private void RefreshLocalizedText() if (displayText is not null) choice.Label = displayText; } + Update.RefreshLocalizedText(); OnPropertyChanged(nameof(EyeButtonAccessibleName)); OnPropertyChanged(nameof(NewerVersionWarningText)); } diff --git a/src/AiUsage/ViewModels/ProviderTileViewModel.cs b/src/AiUsage/ViewModels/ProviderTileViewModel.cs index 98b5f0c..00dd0f1 100644 --- a/src/AiUsage/ViewModels/ProviderTileViewModel.cs +++ b/src/AiUsage/ViewModels/ProviderTileViewModel.cs @@ -954,8 +954,16 @@ public void RefreshLocalizedText() OnPropertyChanged(nameof(SettingsRowText)); OnPropertyChanged(nameof(DetailsMenuHeader)); OnPropertyChanged(nameof(LastSuccessAgeText)); + // The chart's legend and hover caption are otherwise only rebuilt with the next history load. + FiveHourLabelText = StatusTextMap.Resolve(_slotOneLabelKey); + WeeklyLabelText = StatusTextMap.Resolve(_slotTwoLabelKey); + OnPropertyChanged(nameof(ChartSummaryText)); } + // The keys the chart's two slot captions were resolved from, kept for a language switch. + private string _slotOneLabelKey = "Window_FiveHour"; + private string _slotTwoLabelKey = "Window_Weekly"; + /// is optional (defaults to null, meaning "no marker on /// any row") purely so every existing caller that has no thresholds handy keeps compiling /// unchanged; a caller that does hold the provider's passes them @@ -1413,8 +1421,10 @@ public void UpdateHistory( FiveHourValues = slotOneValues; WeeklyValues = slotTwoValues; - FiveHourLabelText = StatusTextMap.Resolve(slotOneLabel ?? "Window_FiveHour"); - WeeklyLabelText = StatusTextMap.Resolve(slotTwoLabel ?? "Window_Weekly"); + _slotOneLabelKey = slotOneLabel ?? "Window_FiveHour"; + _slotTwoLabelKey = slotTwoLabel ?? "Window_Weekly"; + FiveHourLabelText = StatusTextMap.Resolve(_slotOneLabelKey); + WeeklyLabelText = StatusTextMap.Resolve(_slotTwoLabelKey); RangeStart = rangeStart; RangeEnd = rangeEnd; // Mirrors the weekly series when it has anything to show, otherwise the five-hour one - diff --git a/src/AiUsage/ViewModels/SettingsViewModel.cs b/src/AiUsage/ViewModels/SettingsViewModel.cs index 4e4cfec..5fd0f62 100644 --- a/src/AiUsage/ViewModels/SettingsViewModel.cs +++ b/src/AiUsage/ViewModels/SettingsViewModel.cs @@ -434,7 +434,13 @@ private static string LevelPercent(double level) => /// does, same split as every other provider-card control that binds /// to directly. [RelayCommand] - private void AddAccount(string providerId) => Main.AddAccountCommand.Execute(providerId); + private void AddAccount(string providerId) + { + // Execute alone skips the async command's own "still running" check, and a Copilot add waits + // on the GitHub CLI first - a quick second click would add the same login twice. + if (Main.AddAccountCommand.CanExecute(providerId)) + Main.AddAccountCommand.Execute(providerId); + } /// Asks whether to remove an account and whether its recorded history goes too. The /// Settings window sets the real confirm dialog; without one nothing is confirmed, so a missing diff --git a/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs b/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs index 5a1f0b3..9c344e4 100644 --- a/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs +++ b/src/AiUsage/ViewModels/UpdateNoticeViewModel.cs @@ -74,6 +74,9 @@ public UpdateNoticeViewModel(AppSettings settings, Action save, Fun public void Dismiss() => Dismissed = true; + /// A language switch: the notice text is built from a resource string. + public void RefreshLocalizedText() => OnPropertyChanged(nameof(NoticeText)); + /// Runs the daily check (see ) and refreshes /// the notice from what it found. Returns the release the feed answered with, null when the /// check was off, not due or failed. diff --git a/src/AiUsage/Views/Controls/StatsBarChart.cs b/src/AiUsage/Views/Controls/StatsBarChart.cs index 1e42685..8a7b124 100644 --- a/src/AiUsage/Views/Controls/StatsBarChart.cs +++ b/src/AiUsage/Views/Controls/StatsBarChart.cs @@ -438,12 +438,13 @@ internal static IReadOnlyList YAxisTicks(long maxTotal) /// The bar index under pixel of a chart area wide holding equal slots (the same slot width itself divides the area into) - clamped to a valid index rather than -1, since - /// every pixel column belongs to exactly one slot. Pure so hover hit testing is unit testable - /// without a visual tree or a live mouse. + /// cref="Columns"/> itself divides the area into) - clamped to a valid index, since every pixel + /// column of the area belongs to exactly one slot. A negative x lies left of the area, over the + /// value axis labels, and is no bar (-1). Pure so hover hit testing is unit testable without a + /// visual tree or a live mouse. internal static int NearestBarIndex(double x, double width, int barCount) { - if (barCount <= 0 || width <= 0) + if (barCount <= 0 || width <= 0 || x < 0) return -1; var slot = width / barCount; diff --git a/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs b/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs index e8becd8..94d3da3 100644 --- a/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs +++ b/src/AiUsage/Views/Controls/StatsHorizontalBarChart.cs @@ -717,7 +717,7 @@ private void DrawRichRow( } var figureText = new FormattedText( - $"{StatsAggregator.ShortenTokenCount(row.Total, ThousandSuffix, MillionSuffix, BillionSuffix)} · {StatsBarChart.PercentLabel(row.Percent, culture)}", + $"{StatsAggregator.ShortenTokenCount(row.Total, ThousandSuffix, MillionSuffix, BillionSuffix)} · {StatsBarChart.PercentLabel(row.Percent, CultureInfo.CurrentCulture)}", culture, FlowDirection.LeftToRight, regular, 11, TextBrush, dpi); var figureX = Math.Max(textX, width - figureText.Width); drawingContext.DrawText(figureText, new Point(figureX, y + (textAreaHeight - figureText.Height) / 2)); diff --git a/src/AiUsage/Views/MainWindow.xaml.cs b/src/AiUsage/Views/MainWindow.xaml.cs index 494cf78..70c6526 100644 --- a/src/AiUsage/Views/MainWindow.xaml.cs +++ b/src/AiUsage/Views/MainWindow.xaml.cs @@ -1261,9 +1261,11 @@ private void GrowUp(double dy) _heightIsAutomatic = false; ViewModel.HeightAutomatic = false; } - // Same stale-ceiling fix as GrowDown - see its own comment. - MaxHeight = CurrentArea().Height; - var newHeight = Math.Max(MinHeight, Height - dy); + // Same stale-ceiling fix and the same limits as GrowDown - see its own comment. Top moves + // only by what the height actually changed, so a capped drag never slides the window away. + var workAreaHeight = CurrentArea().Height; + MaxHeight = workAreaHeight; + var newHeight = WindowPlacementService.GrowManualHeight(Height, -dy, MinHeight, workAreaHeight, MeasureTallestContentHeight(), _zoom); Top += Height - newHeight; Height = newHeight; } diff --git a/tests/AiUsage.Tests/SettingsViewModelTests.cs b/tests/AiUsage.Tests/SettingsViewModelTests.cs index ac32de1..bd7c79c 100644 --- a/tests/AiUsage.Tests/SettingsViewModelTests.cs +++ b/tests/AiUsage.Tests/SettingsViewModelTests.cs @@ -710,7 +710,7 @@ public void ExportHistoryWritesACsvFileStartingWithTheHeaderThroughTheFakePathPr vm.ExportHistoryCommand.Execute(null); Assert.True(File.Exists(exportPath)); - Assert.StartsWith("provider,window,timestamp_utc,percent,resets_at_utc,tokens\r\n", File.ReadAllText(exportPath)); + Assert.StartsWith("provider,window,timestamp_utc,percent,resets_at_utc,tokens,label\r\n", File.ReadAllText(exportPath)); } [Fact] @@ -1769,4 +1769,23 @@ public void A_reset_never_changes_this_PCs_id_or_the_imported_list() Assert.Equal(OwnPc, settings.MachineId); Assert.Equal(OtherPc, Assert.Single(settings.ImportedMachines).Id); } + + // The Copilot add waits on the GitHub CLI first; a second click meanwhile added the same login twice. + [Fact] + public async Task A_second_add_account_click_while_the_first_still_runs_adds_nothing() + { + var (vm, _) = Build(); + var accounts = new TaskCompletionSource>(); + vm.Main.GetGitHubAccountsAsync = _ => accounts.Task; + var before = vm.Main.Tiles.Count; + + vm.AddAccountCommand.Execute("copilot"); + vm.AddAccountCommand.Execute("copilot"); + accounts.SetResult([new("github.com", "second-user", Active: false)]); + if (vm.Main.AddAccountCommand.ExecutionTask is { } running) + await running; + + Assert.Equal(before + 1, vm.Main.Tiles.Count); + vm.Dispose(); + } } diff --git a/tests/AiUsage.Tests/StatsWindowTests.cs b/tests/AiUsage.Tests/StatsWindowTests.cs index 48fd448..3aa9157 100644 --- a/tests/AiUsage.Tests/StatsWindowTests.cs +++ b/tests/AiUsage.Tests/StatsWindowTests.cs @@ -1461,4 +1461,12 @@ public void The_bundled_other_projects_bar_takes_the_muted_brush() Assert.Same(muted, brushes[0]); } + + // Left of the bars lie the value axis labels: hovering or clicking them is no bar. + [Fact] + public void NearestBarIndexFindsNoBarLeftOfTheChartArea() + { + Assert.Equal(-1, StatsBarChart.NearestBarIndex(x: -5, width: 200, barCount: 4)); + Assert.Equal(0, StatsBarChart.NearestBarIndex(x: 0, width: 200, barCount: 4)); + } } diff --git a/tests/AiUsage.Tests/UpdateNoticeTests.cs b/tests/AiUsage.Tests/UpdateNoticeTests.cs index 14b1eba..dd4c5c1 100644 --- a/tests/AiUsage.Tests/UpdateNoticeTests.cs +++ b/tests/AiUsage.Tests/UpdateNoticeTests.cs @@ -172,6 +172,18 @@ public async Task InstallingWithoutAReachableOrNewerReleaseRunsNothing() Assert.Equal(UpdateOutcome.DownloadFailed, (await notNewer.InstallAsync(CancellationToken.None)).Outcome); Assert.Empty(host.Ran); } + + [Fact] + public void A_language_switch_rebuilds_the_notice_text() + { + var update = new UpdateNoticeViewModel(new AppSettings { KnownLatestTag = "v9.0.0" }, _ => { }); + var changed = new List(); + update.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + update.RefreshLocalizedText(); + + Assert.Contains(nameof(UpdateNoticeViewModel.NoticeText), changed); + } } public class UpdateSigningKeyGuardTests From be6271143133706c343c7e0601d4a8e74e8599f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:00:07 +0000 Subject: [PATCH 5/7] Fix relaunch after update, uninstall shortcut and build.bat quoting A per-user in-app update passes /RELAUNCH so Setup starts the app again even when it already closed. Uninstall removes the shortcut from the user's desktop instead of the Public one, portable setup writes no log to the PC, and build.bat quotes paths echoed inside blocks so the Program Files (x86) path of Inno Setup no longer breaks it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- build.bat | 9 +++++---- installer/AiUsage.iss | 24 +++++++++++++++++++++--- src/AiUsage/Services/UpdateHost.cs | 14 +++++++++++--- tests/AiUsage.Tests/UpdateHostTests.cs | 12 ++++++------ 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/build.bat b/build.bat index 185ad2f..e734bf2 100644 --- a/build.bat +++ b/build.bat @@ -78,13 +78,13 @@ rem after a "for /f" command-substitution loop like the one above it (a known cm rem GOTO :error, used everywhere else in this file, is unaffected and keeps working normally. if exist "%STAGE%" rmdir /s /q "%STAGE%" if exist "%STAGE%" ( - echo ERROR: could not clear %STAGE% + echo ERROR: could not clear "%STAGE%" echo Something is still using it - close any running app or Explorer window there. goto :error ) mkdir "%STAGE%" if errorlevel 1 ( - echo ERROR: could not create %STAGE% + echo ERROR: could not create "%STAGE%" goto :error ) @@ -98,7 +98,7 @@ if exist "%DIST%" ( mkdir "%DIST%" ) if not exist "%DIST%" ( - echo ERROR: could not create %DIST% + echo ERROR: could not create "%DIST%" goto :error ) for %%f in ("%DIST%\AI-Usage.exe" "%DIST%\SHA256SUMS.txt") do ( @@ -171,7 +171,8 @@ if not defined ISCC if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" set "ISCC=%P if not defined ISCC ( echo WARNING: Inno Setup ^(ISCC.exe^) not found - skipping the installer. ) else ( - echo Using %ISCC% + rem Quoted: the default "Program Files (x86)" path ends this block early at its ")" otherwise. + echo Using "%ISCC%" "%ISCC%" /DAppVersion=%VERSION% "%ROOT%installer\AiUsage.iss" if errorlevel 1 goto :error ) diff --git a/installer/AiUsage.iss b/installer/AiUsage.iss index 3e1720c..0aad90b 100644 --- a/installer/AiUsage.iss +++ b/installer/AiUsage.iss @@ -215,7 +215,9 @@ Filename: "{code:GetAppExePath}"; Flags: nowait runasoriginaluser; \ ; Same hive limitation as autostart above: on a per-machine install approved by a different ; administrator account, the shortcut was written to the installing user's desktop, which an ; elevated uninstall running under that administrator's own account cannot see. Not worked around. -Type: files; Name: "{autodesktop}\{#AppName}.lnk" +; {userdesktop}, not {autodesktop}: on a per-machine install the latter is the Public desktop, where +; the app never writes its shortcut. +Type: files; Name: "{userdesktop}\{#AppName}.lnk" [Registry] ; Creates nothing - the app writes the value itself (see [Run]). This entry exists only so @@ -650,9 +652,24 @@ end; { The auto-relaunch Check in [Run] reads this - only relevant to the standard (non-portable), silent path where no "launch now" checkbox was ever shown. } +function HasSwitch(const Name: String): Boolean; +var + I: Integer; +begin + Result := False; + for I := 1 to ParamCount do + if CompareText(ParamStr(I), Name) = 0 then + begin + Result := True; + Exit; + end; +end; + +{ /RELAUNCH comes from the app's own in-app update: the app has usually ended before the window + lookup in InitializeSetup runs, so that lookup alone would leave the tray icon gone. } function WasAppRunning: Boolean; begin - Result := AppWasRunning; + Result := AppWasRunning or HasSwitch('/RELAUNCH'); end; { Deletes every file in Folder matching "setup-*.log" beyond the newest KeepCount, sorted by name @@ -692,7 +709,8 @@ procedure CurStepChanged(CurStep: TSetupStep); var LogFolder, DestFile: String; begin - if CurStep = ssDone then + { Portable mode promises that nothing else is written to this PC. } + if (CurStep = ssDone) and not IsPortableMode then begin LogFolder := ExpandConstant('{localappdata}\{#AppName}\setup'); if ForceDirectories(LogFolder) and FileExists(ExpandConstant('{log}')) then diff --git a/src/AiUsage/Services/UpdateHost.cs b/src/AiUsage/Services/UpdateHost.cs index 5b99f43..f048cc0 100644 --- a/src/AiUsage/Services/UpdateHost.cs +++ b/src/AiUsage/Services/UpdateHost.cs @@ -152,7 +152,10 @@ internal static ProcessStartInfo BuildSetupStartInfo(string setupPath, string ar /// The setup's command line. The setup does not reuse the previous install scope on its /// own, so the scope of the running copy is passed along. The uninstall entries the setup wrote /// say which scope owns this folder (it works for a folder the user picked); only when neither - /// names it does a copy under Program Files count as per-machine and any other as per-user. + /// names it does a copy under Program Files count as per-machine and any other as per-user. A + /// per-user setup also gets : this copy has usually ended by the time the + /// setup looks for its window, so the setup would not know to start the new version again. A + /// per-machine setup runs elevated from the start and would start it elevated, so it gets none. internal static string BuildSetupArguments(string? exePath) => BuildSetupArguments(exePath, [ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @@ -167,16 +170,21 @@ internal static string BuildSetupArguments(string? exePath, IEnumerable if (SameFolder(readInstallLocation(true), directory)) return "/SILENT /ALLUSERS"; if (SameFolder(readInstallLocation(false), directory)) - return "/SILENT /CURRENTUSER"; + return PerUserArguments; } var perMachine = directory is not null && programFilesRoots .Where(root => !string.IsNullOrEmpty(root)) .Any(root => (directory + Path.DirectorySeparatorChar).StartsWith( root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); - return perMachine ? "/SILENT /ALLUSERS" : "/SILENT /CURRENTUSER"; + return perMachine ? "/SILENT /ALLUSERS" : PerUserArguments; } + /// Read by the setup script (WasAppRunning): start the app again once the update is done. + internal const string RelaunchSwitch = "/RELAUNCH"; + + private const string PerUserArguments = "/SILENT /CURRENTUSER " + RelaunchSwitch; + private static bool SameFolder(string? recorded, string directory) => !string.IsNullOrWhiteSpace(recorded) && string.Equals(recorded.Trim().TrimEnd(Path.DirectorySeparatorChar), directory.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase); diff --git a/tests/AiUsage.Tests/UpdateHostTests.cs b/tests/AiUsage.Tests/UpdateHostTests.cs index 56961a9..9e05de1 100644 --- a/tests/AiUsage.Tests/UpdateHostTests.cs +++ b/tests/AiUsage.Tests/UpdateHostTests.cs @@ -22,15 +22,15 @@ public void A_per_user_copy_updates_for_the_current_user() var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var exe = Path.Combine(local, "Programs", "AI-Usage", "AI-Usage.exe"); - Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(exe)); - Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(exe, Roots)); + Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(exe)); + Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(exe, Roots)); } [Fact] public void A_folder_that_only_shares_the_Program_Files_prefix_is_per_user() { - Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(@"C:\Program Files Extra\AI-Usage\AI-Usage.exe", Roots)); - Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(null, Roots)); + Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(@"C:\Program Files Extra\AI-Usage\AI-Usage.exe", Roots)); + Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(null, Roots)); } [Fact] @@ -46,14 +46,14 @@ public void A_per_user_install_in_a_Program_Files_folder_stays_per_user_when_onl { var exe = @"C:\Program Files\Own\AI-Usage.exe"; - Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(exe, Roots, perMachine => perMachine ? null : @"C:\Program Files\Own")); + Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(exe, Roots, perMachine => perMachine ? null : @"C:\Program Files\Own")); } [Fact] public void Without_a_registry_entry_the_Program_Files_prefix_decides() { Assert.Equal("/SILENT /ALLUSERS", UpdateHost.BuildSetupArguments(@"C:\Program Files\AI-Usage\AI-Usage.exe", Roots, _ => null)); - Assert.Equal("/SILENT /CURRENTUSER", UpdateHost.BuildSetupArguments(@"D:\Tools\AI-Usage\AI-Usage.exe", Roots, _ => null)); + Assert.Equal("/SILENT /CURRENTUSER /RELAUNCH", UpdateHost.BuildSetupArguments(@"D:\Tools\AI-Usage\AI-Usage.exe", Roots, _ => null)); } private static byte[] PeFile(ushort machine, int peOffset = 0x80, int length = 0x200) From ab3b501c1d079fff7633c28b3fae7a4aa70c987c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:00:07 +0000 Subject: [PATCH 6/7] List the audit fixes in the changelog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f568f0..80ae779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,21 @@ Backups, history from a second PC, real Windows notifications and a much deeper - The token usage window no longer clips text in its smallest size, and its charts use the "Terminal" theme's font. - Dates use the same month abbreviations everywhere. - The 1.1.0 weekday or hour chart choice carries over into the new groupings. +- Token usage counts Codex again: real Codex logs carry no cache write total, and every line without one was skipped. Codex logs read so far without a single counted line are read once more. +- Codex reads its sessions from `CODEX_HOME` when that is set. +- Started into the notification area, the widget fetches at the slower hidden-window pace, and the first time it is shown it fetches right away. +- A quick double click on "Add account" for Copilot no longer adds the same GitHub account twice. +- Dragging the top edge of the widget stops at the content and the screen height like the bottom edge, instead of sliding the window off the screen. +- A settings file with an empty entry in the hidden windows no longer stops the app from starting, and a backup with a damaged token index is refused instead of closing the app. +- The widget no longer hides itself as if a full-screen app were running when no window has the focus. +- The update notice, the chart legend of a tile and the percentages in the top projects follow a language and number format change at once. +- Hovering or clicking the value labels of a token usage chart no longer picks the first column. +- A custom token usage range far in the past no longer freezes the window or throws, and ranges longer than a year are drawn per week. +- The history export names each window, so several Cursor bars can be told apart. +- An account name cut to its maximum length no longer ends in half an emoji. +- Settings and history are flushed to disk before they replace the old file, so a power cut cannot leave an empty file behind. +- After an in-app update the app starts again even when it closed before the setup looked for it, and uninstalling removes the desktop shortcut of a per-machine install. +- The portable setup no longer writes a setup log to the PC, and `build.bat` no longer stops at the installer step when Inno Setup is installed under "Program Files (x86)". ### Security From c63fb5967c09212c054ca107d7698a9edf77843c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:04:18 +0000 Subject: [PATCH 7/7] Keep a local user path out of the CODEX_HOME test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015EdKSSA9Z5o4FHYiLZajUL --- tests/AiUsage.Tests/CodexPathsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AiUsage.Tests/CodexPathsTests.cs b/tests/AiUsage.Tests/CodexPathsTests.cs index b74c26f..04a70d0 100644 --- a/tests/AiUsage.Tests/CodexPathsTests.cs +++ b/tests/AiUsage.Tests/CodexPathsTests.cs @@ -5,7 +5,7 @@ namespace AiUsage.Tests; public class CodexPathsTests { - private const string Profile = @"C:\Users\someone"; + private const string Profile = @"D:\Profiles\someone"; [Fact] public void CODEX_HOME_is_where_Codex_keeps_its_sessions_when_it_is_set()