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 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/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/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/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/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(IReadOnlyDictionaryThe 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/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/src/AiUsage/Storage/LegacyWindowLabels.cs b/src/AiUsage/Storage/LegacyWindowLabels.cs index 9b30de0..aacb9c6 100644 --- a/src/AiUsage/Storage/LegacyWindowLabels.cs +++ b/src/AiUsage/Storage/LegacyWindowLabels.cs @@ -36,8 +36,11 @@ internal static void Migrate(AppSettings settings, string trayLabelPrefix) { foreach (var (accountKey, provider) in settings.Providers) { + // A hand-edited or damaged file can hold a null entry ("hiddenWindows": [null]); it names no + // window and would otherwise throw in the lookup below, on every start. if (provider.HiddenWindows is { Count: > 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/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/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..04a70d0 --- /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 = @"D:\Profiles\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/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 ")); + } } 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/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))); + } } 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/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) 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 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)); + } }