diff --git a/.gitignore b/.gitignore index 14ad387..580ca7a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,19 @@ Thumbs.db *.sqlite *.sqlite3 *.har +*.ppk +*.jks +*.keystore +id_rsa* +id_ed25519* + +# Build logs record the whole environment, signing key variables included +*.binlog + +# IDE state +.idea/ +*.suo + +# Local tool settings and the app's own backups +.claude/settings.local.json +ai-usage-backup*.zip diff --git a/installer/AiUsage.iss b/installer/AiUsage.iss index e8f05c0..146244f 100644 --- a/installer/AiUsage.iss +++ b/installer/AiUsage.iss @@ -148,20 +148,20 @@ en.RuntimeComponent=Required component en.RuntimeNeeded=AI-Usage needs the .NET 10 Desktop Runtime from Microsoft. Setup downloads and installs it now (about 60 MB). en.RuntimeInstalling=Downloading and installing the .NET 10 Desktop Runtime. Windows asks for permission once the download is done. en.RuntimeFailed=The .NET Desktop Runtime could not be installed. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and start setup again. -en.UpdateDirDescription={#AppName} is already installed on this PC and will be updated in the folder shown below. Click Next to continue, or Browse to choose a different folder. +en.UpdateDirDescription={#AppName} is already installed on this PC and will be updated in the folder shown below. en.RemoveDataQuestion=Also remove {#AppName}'s saved settings, history and browser sign-ins for the current user (%APPDATA%\{#AppName}\ and %LOCALAPPDATA%\{#AppName}\)? en.DowngradeQuestion=Version {0} is already installed, and this installer carries the older version {1}. Continue anyway? de.AutostartTask={#AppName} automatisch bei der Windows-Anmeldung starten de.DesktopShortcutTask=Verknüpfung auf dem Desktop erstellen de.LaunchAfter={#AppName} jetzt starten de.PortableCheck=Portable -de.PortableDirDescription=Wähle einen Ordner für die portable Version von {#AppName}. Setup schlägt einen eigenen Ordner neben diesem Installationsprogramm vor und legt {#AppExeName} darin ab. Sonst wird nichts auf diesem PC gespeichert. -de.SourceLink=Quelltext und Fehlermeldungen: github.com/wbgcoding/AI-Usage +de.PortableDirDescription=Wählen Sie einen Ordner für die portable Version von {#AppName}. Setup schlägt einen eigenen Ordner neben diesem Installationsprogramm vor und legt {#AppExeName} darin ab. Sonst wird nichts auf diesem PC gespeichert. +de.SourceLink=Quelltext und Fehlerberichte: github.com/wbgcoding/AI-Usage de.RuntimeComponent=Erforderliche Komponente de.RuntimeNeeded=AI-Usage braucht die .NET 10 Desktop Runtime von Microsoft. Das Setup lädt sie jetzt herunter und installiert sie (etwa 60 MB). de.RuntimeInstalling=Die .NET 10 Desktop Runtime wird heruntergeladen und installiert. Windows fragt nach der Erlaubnis, sobald der Download fertig ist. -de.RuntimeFailed=Die .NET Desktop Runtime ließ sich nicht installieren. Installiere sie von https://dotnet.microsoft.com/download/dotnet/10.0 und starte das Setup erneut. -de.UpdateDirDescription={#AppName} ist auf diesem PC bereits installiert und wird im unten angezeigten Ordner aktualisiert. Klicke auf Weiter, oder auf Durchsuchen, um einen anderen Ordner zu wählen. +de.RuntimeFailed=Die .NET Desktop Runtime ließ sich nicht installieren. Installieren Sie sie von https://dotnet.microsoft.com/download/dotnet/10.0 und starten Sie das Setup erneut. +de.UpdateDirDescription={#AppName} ist auf diesem PC bereits installiert und wird im unten angezeigten Ordner aktualisiert. de.RemoveDataQuestion=Sollen auch die gespeicherten Einstellungen, der Verlauf und die Browser-Anmeldungen von {#AppName} für den aktuellen Benutzer (%APPDATA%\{#AppName}\ und %LOCALAPPDATA%\{#AppName}\) entfernt werden? de.DowngradeQuestion=Version {0} ist bereits installiert, dieses Installationsprogramm enthält die ältere Version {1}. Trotzdem fortfahren? @@ -209,7 +209,11 @@ Filename: "{code:GetAppExePath}"; Description: "{cm:LaunchAfter}"; \ ; to offer the checkbox above, and only when the app was actually closed as part of this run ; (WasAppRunning, captured before CloseApplications acted). Filename: "{code:GetAppExePath}"; Flags: nowait runasoriginaluser; \ - Check: WasAppRunning and WizardSilent and not IsPortableMode + Check: WasAppRunning and WizardSilent and not IsPortableMode and not IsAdmin +; A setup the app itself started elevated has no original user to fall back to, so runasoriginaluser +; would bring the app back with the admin token. The shell starts it with the signed-in user's own. +Filename: "{win}\explorer.exe"; Parameters: """{code:GetAppExePath}"""; Flags: nowait; \ + Check: WasAppRunning and WizardSilent and not IsPortableMode and IsAdmin [UninstallDelete] ; Same hive limitation as autostart above: on a per-machine install approved by a different @@ -225,6 +229,9 @@ Type: files; Name: "{userdesktop}\{#AppName}.lnk" ; runasoriginaluser flag, so it would look at the elevating account's hive rather than the user's. Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: none; \ ValueName: "AI-Usage"; Flags: uninsdeletevalue; Check: not IsPortableMode +; Task Manager records a disabled autostart entry here; the app clears it itself when re-enabled. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; ValueType: none; \ + ValueName: "AI-Usage"; Flags: dontcreatekey uninsdeletevalue; Check: not IsPortableMode ; The app registers its notification identity itself on the first alert; this entry only removes it. Root: HKCU; Subkey: "Software\Classes\AppUserModelId\BGCoding.AI-Usage"; ValueType: none; \ Flags: dontcreatekey uninsdeletekey; Check: not IsPortableMode @@ -268,8 +275,9 @@ var RuntimePage: TOutputMsgWizardPage; RuntimeProgress: TOutputMarqueeProgressWizardPage; -function FindWindowExA(hParent: LongInt; hChildAfter: HWND; lpClassName: LongInt; lpWindowName: String): HWND; - external 'FindWindowExA@user32.dll stdcall'; +{ The W form: Inno passes a String as UTF-16, which the A form would read as a one-letter title. } +function FindWindowExW(hParent: LongInt; hChildAfter: HWND; lpClassName: LongInt; lpWindowName: String): HWND; + external 'FindWindowExW@user32.dll stdcall'; function GetClassNameW(Wnd: HWND; ClassName: String; MaxCount: Integer): Integer; external 'GetClassNameW@user32.dll stdcall'; @@ -533,13 +541,13 @@ var Len: Integer; begin Result := False; - Wnd := FindWindowExA(0, 0, 0, '{#AppName}'); + Wnd := FindWindowExW(0, 0, 0, '{#AppName}'); while (Wnd <> 0) and not Result do begin SetLength(ClassName, 256); Len := GetClassNameW(Wnd, ClassName, 256); Result := (Len > 0) and (Pos('HwndWrapper[{#AppName}', Copy(ClassName, 1, Len)) = 1); - Wnd := FindWindowExA(0, Wnd, 0, '{#AppName}'); + Wnd := FindWindowExW(0, Wnd, 0, '{#AppName}'); end; end; @@ -556,9 +564,9 @@ begin AllowSetForegroundWindow(ASFW_ANY); { The one moment "was the app running" can still be answered honestly - CloseApplications acts - between here and CurStepChanged. A plain window-title lookup (the app's own title, matching - SingleInstanceService's lookup) rather than a process check, so a portable copy running from - an unrelated folder does not falsely arm the relaunch. } + between here and CurStepChanged. A window lookup (the app's own title and WPF host) rather than + a process check, so a portable copy running from an unrelated folder does not falsely arm the + relaunch. } AppWasRunning := IsAppWindowOpen; { An equal version continues as a repair, an offered version that is newer is the ordinary @@ -787,7 +795,8 @@ end; { The data folder the app was moved to in Settings: the full path in location.txt of the default data folder, or '' when there is none or it is not a plain absolute path. Never a drive or share root, a folder that holds the default data folders, the user profile folder itself, nor anything inside - Windows or Program Files. } + Windows, Program Files or ProgramData: an elevated uninstall must not delete files there that + another account could have planted the marker beside. } function MovedDataDir(const PointerFile, DataDir, LocalDir: String): String; var Raw: AnsiString; @@ -814,7 +823,8 @@ begin if (Profile <> '') and (CompareText(Path, Profile) = 0) then Exit; if IsSameOrUnder(Path, ExpandConstant('{win}')) or IsSameOrUnder(Path, GetEnv('ProgramFiles')) - or IsSameOrUnder(Path, GetEnv('ProgramFiles(x86)')) or IsSameOrUnder(Path, GetEnv('ProgramW6432')) then + or IsSameOrUnder(Path, GetEnv('ProgramFiles(x86)')) or IsSameOrUnder(Path, GetEnv('ProgramW6432')) + or IsSameOrUnder(Path, ExpandConstant('{commonappdata}')) then Exit; Result := Path; end; diff --git a/installer/RuntimeSetup.ps1 b/installer/RuntimeSetup.ps1 index 5b41ddf..80c8793 100644 --- a/installer/RuntimeSetup.ps1 +++ b/installer/RuntimeSetup.ps1 @@ -246,6 +246,17 @@ function Save-Download([string]$Address, [string]$Destination) { } } +# The installer is started with administrator rights, so its folder must hold nothing but the +# installer: a DLL planted beside it would be loaded into the elevated process. +function Get-FolderProblem([string]$Work, [string]$Installer) { + $reparse = [IO.FileAttributes]::ReparsePoint + if ((Get-Item -LiteralPath $Work -Force).Attributes -band $reparse) { return 'the download folder is a link' } + $entries = @(Get-ChildItem -LiteralPath $Work -Force) + if ($entries.Count -ne 1 -or $entries[0].Name -ne [IO.Path]::GetFileName($Installer)) { return 'the download folder holds more than the installer' } + if ($entries[0].Attributes -band $reparse) { return 'the installer is a link' } + return $null +} + function Invoke-Main { if ($VerifyFile) { $guard = $null @@ -275,6 +286,10 @@ function Invoke-Main { $guard = $null try { # A folder that did not exist a moment ago, named at random: nothing can be waiting in it. + if ((Test-Path -LiteralPath $Folder) -and ((Get-Item -LiteralPath $Folder -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + Write-Log 'installer refused: the temporary folder is a link' + return $ExitSignature + } $work = Join-Path $Folder ('runtime-' + [Guid]::NewGuid().ToString('N')) [void][IO.Directory]::CreateDirectory($work) $file = Join-Path $work 'windowsdesktop-runtime.exe' @@ -293,6 +308,7 @@ function Invoke-Main { $problem = Get-SignatureProblem $file if (-not $problem) { $problem = Get-ProductProblem $file } + if (-not $problem) { $problem = Get-FolderProblem $work $file } if ($problem) { Write-Log "installer refused: $problem" return $ExitSignature diff --git a/src/AiUsage/Providers/ClaudeDiscoveryScript.cs b/src/AiUsage/Providers/ClaudeDiscoveryScript.cs index 7746d85..1ada1b7 100644 --- a/src/AiUsage/Providers/ClaudeDiscoveryScript.cs +++ b/src/AiUsage/Providers/ClaudeDiscoveryScript.cs @@ -89,15 +89,17 @@ public static string Discover() if (typeof uuid !== 'string' || !/^[0-9a-fA-F-]{36}$/.test(uuid)) return {status: 'failed'}; const candidates = [{{candidates}}].map(p => p.replace('{0}', uuid)); const results = []; + let sawRateLimit = false; for (const path of candidates) { const res = await fetch(path, {method: 'GET', credentials: 'include'}); + if (res.status === 429) { sawRateLimit = true; continue; } if (!res.ok) continue; const ct = res.headers.get('content-type') || ''; const text = await res.text(); if (ct.includes('json')) results.push({path: path, body: text}); } if (results.length > 0) return {status: 'ok', results: results, plan: planOf(org)}; - return {status: 'failed'}; + return {status: sawRateLimit ? 'blocked' : 'failed'}; } catch (e) { return {status: 'failed'}; } diff --git a/src/AiUsage/Providers/ClaudeProvider.cs b/src/AiUsage/Providers/ClaudeProvider.cs index 36541cc..a9b0f3a 100644 --- a/src/AiUsage/Providers/ClaudeProvider.cs +++ b/src/AiUsage/Providers/ClaudeProvider.cs @@ -150,6 +150,10 @@ private ClaudeProvider( // that part still moves off the caller's thread even though the provider as a whole opts out. public bool RunsOnUiThread => true; + // True while the Claude Code sign-in gives the numbers: then only its plain request is paid, and + // the web session's floor does not apply. + private volatile bool _localLoginAnsweredLast; + /// Settings-driven (this account's own value, else , /// default 5 min) rather than hardcoded, so picks up a change without ever knowing this /// provider has a web path at all. The web session still holds a five-minute floor underneath the @@ -159,10 +163,6 @@ private ClaudeProvider( /// cref="SettingsRanges.MinRemoteRefreshMinutes"/>. No settings at all (the local-only test seam, /// which reaches neither remote path) falls back to five minutes when a local login exists, or the /// scheduler's per-tick default (null) otherwise. - // True while the Claude Code sign-in gives the numbers: then only its plain request is paid, and - // the web session's floor does not apply. - private volatile bool _localLoginAnsweredLast; - public TimeSpan? MinRefreshInterval { get @@ -246,7 +246,7 @@ public async Task FetchAsync(CancellationToken ct) { try { - tokens = _tokenReader.SumTokens(newestFile); + tokens = _tokenReader.SumTokens(newestFile, ct); lastEventAt = _tokenReader.LastEventAt; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) diff --git a/src/AiUsage/Providers/CodexProvider.cs b/src/AiUsage/Providers/CodexProvider.cs index 0db7b60..e83d61c 100644 --- a/src/AiUsage/Providers/CodexProvider.cs +++ b/src/AiUsage/Providers/CodexProvider.cs @@ -54,6 +54,10 @@ public sealed partial class CodexProvider : IUsageProvider // current walk, so it never outgrows MaxFilesToCheck. private readonly Dictionary _filesWithoutUsableLine = new(StringComparer.OrdinalIgnoreCase); + // A local read abandoned by a timed-out refresh may still be running when the retry starts; the + // file memo and the plausibility baseline are only ever updated by one read at a time. + private readonly Lock _localReadGate = new(); + /// Session files opened for reading lines over this provider's lifetime - internal so a /// test can prove an unchanged file is not read a second time. internal int FilesOpened { get; private set; } @@ -139,8 +143,8 @@ internal CodexProvider( public bool RunsOnUiThread => _webSource is not null; /// Both sources are offered to the shared chooser (available beats nothing, more windows - /// beat fewer, newer beats older), never merged. Without a web session that is a single result - - /// wrapping it still routes it through the same rule every other provider follows. + /// beat fewer, newer beats older), never merged. Without a web session the local read is the + /// answer as it stands. public async Task FetchAsync(CancellationToken ct) { var fetchedAt = _now(); @@ -219,6 +223,12 @@ public async Task FetchAsync(CancellationToken ct) private readonly record struct LocalRead(ProviderSnapshot Snapshot, bool FromSessionFile); private LocalRead FetchCore(DateTimeOffset fetchedAt, CancellationToken ct) + { + lock (_localReadGate) + return FetchCoreLocked(fetchedAt, ct); + } + + private LocalRead FetchCoreLocked(DateTimeOffset fetchedAt, CancellationToken ct) { var diagnostics = new ProviderDiagnostics().SearchedIn(_sessionsRoot); var filesChecked = 0; @@ -291,6 +301,8 @@ private LocalRead FetchCore(DateTimeOffset fetchedAt, CancellationToken ct) var lineCutoff = fetchedAt - OldestFileToOpen; foreach (var line in ReverseLineReader.ReadLinesReversed(file.FullName)) { + ct.ThrowIfCancellationRequested(); + // Newest-first within the file: the first line past the age bound ends it. if (SessionLineAge.IsOlderThan(line, lineCutoff)) break; @@ -319,7 +331,7 @@ private LocalRead FetchCore(DateTimeOffset fetchedAt, CancellationToken ct) } foundUsableLine = true; - break; // this file's newest usable event is found - move on to the next file + break; } if (!foundUsableLine) diff --git a/src/AiUsage/Providers/CopilotProvider.cs b/src/AiUsage/Providers/CopilotProvider.cs index 02791e7..ddd5adf 100644 --- a/src/AiUsage/Providers/CopilotProvider.cs +++ b/src/AiUsage/Providers/CopilotProvider.cs @@ -82,9 +82,7 @@ public async Task FetchAsync(CancellationToken ct) var result = await _read(ct); - // Copilot has exactly one source (the GitHub CLI's own sign-in), so the chooser has nothing - // to pick between - wrapping the single result still routes it through the same rule every - // other provider follows, rather than a source-specific exception. + // One source only (the GitHub CLI's own sign-in), so there is nothing to choose between. return BuildSnapshot(result, fetchedAt); } @@ -127,13 +125,6 @@ private ProviderSnapshot BuildSnapshot(CopilotFetch result, DateTimeOffset fetch } } - private ProviderSnapshot Empty(DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => new( - ProviderId: AccountKey, - Windows: [], - PlanType: null, - SourceKind: SourceKind.None, - FetchedAt: fetchedAt, - DataTimestamp: null, - Status: status, - Error: error); + private ProviderSnapshot Empty(DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => + ProviderSnapshots.Empty(AccountKey, fetchedAt, status, error); } diff --git a/src/AiUsage/Providers/CursorProvider.cs b/src/AiUsage/Providers/CursorProvider.cs index 8f28f0b..444b27e 100644 --- a/src/AiUsage/Providers/CursorProvider.cs +++ b/src/AiUsage/Providers/CursorProvider.cs @@ -114,13 +114,6 @@ public async Task FetchAsync(CancellationToken ct) _ => ProviderSnapshots.Unavailable(AccountKey, fetchedAt), }; - private ProviderSnapshot Empty(DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => new( - ProviderId: AccountKey, - Windows: [], - PlanType: null, - SourceKind: SourceKind.None, - FetchedAt: fetchedAt, - DataTimestamp: null, - Status: status, - Error: error); + private ProviderSnapshot Empty(DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => + ProviderSnapshots.Empty(AccountKey, fetchedAt, status, error); } diff --git a/src/AiUsage/Providers/GeminiProvider.cs b/src/AiUsage/Providers/GeminiProvider.cs index c574315..518d909 100644 --- a/src/AiUsage/Providers/GeminiProvider.cs +++ b/src/AiUsage/Providers/GeminiProvider.cs @@ -103,11 +103,11 @@ internal GeminiProvider( // itself. public bool RunsOnUiThread => _webSource is not null; + public bool HasLocalSource => _read is not null; + // Two lightweight reads against a remote endpoint - hold to the same web-session floor Claude // uses rather than the local-file default, so it is not polled every tick; the user's own // interval for this account can only make it longer. - public bool HasLocalSource => _read is not null; - public TimeSpan? MinRefreshInterval => ProviderRegistry.ReadInterval(_settings, _accountKey); // Only the primary account (the one with the Antigravity CLI's own local sign-in) can hold a @@ -122,8 +122,8 @@ internal GeminiProvider( }; /// Both sources are offered to the shared chooser (available beats nothing, more windows - /// beat fewer, newer beats older), never merged. Without a web session that is a single result - - /// wrapping it still routes it through the same rule every other provider follows. + /// beat fewer, newer beats older), never merged. Without a web session the local read is the + /// answer as it stands. public async Task FetchAsync(CancellationToken ct) { var snapshot = await FetchSourcesAsync(ct); @@ -246,13 +246,6 @@ private ProviderSnapshot BuildSnapshot(AntigravityUsage usage, DateTimeOffset fe } } - private ProviderSnapshot Empty(DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => new( - ProviderId: AccountKey, - Windows: [], - PlanType: null, - SourceKind: SourceKind.None, - FetchedAt: fetchedAt, - DataTimestamp: null, - Status: status, - Error: error); + private ProviderSnapshot Empty(DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => + ProviderSnapshots.Empty(AccountKey, fetchedAt, status, error); } diff --git a/src/AiUsage/Providers/LocalLogin/AntigravityLocalLogin.cs b/src/AiUsage/Providers/LocalLogin/AntigravityLocalLogin.cs index 22e5e2d..f2b1baf 100644 --- a/src/AiUsage/Providers/LocalLogin/AntigravityLocalLogin.cs +++ b/src/AiUsage/Providers/LocalLogin/AntigravityLocalLogin.cs @@ -84,10 +84,19 @@ public static async Task FetchAsync(CancellationToken ct) return refreshToken is null ? AntigravityUsage.NotSignedIn : AntigravityUsage.Failed; } - var load = await LocalLoginHttp.SendAsync( - HttpMethod.Post, LoadUrl, accessToken, - "{\"metadata\":{\"ideType\":\"ANTIGRAVITY\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}}", - UserAgent, ct); + var load = await LoadCodeAssistAsync(accessToken, ct); + if (load.StatusCode is 401 or 403 && refreshToken is not null) + { + // A stored or cached token can be revoked well before its expiry; only a refused refresh + // means the CLI has signed out. + ForgetCachedToken(); + var (refreshed, refreshRejected) = await RefreshAsync( + refreshToken, LocalLoginHttp.PostFormAsync, AntigravityOAuthClient.DiscoverPairs, AntigravityOAuthClient.ForgetPairs, ct); + if (refreshed is null) + return refreshRejected ? ForgetCachedTokenAndReportSignedOut() : AntigravityUsage.Failed; + accessToken = refreshed; + load = await LoadCodeAssistAsync(accessToken, ct); + } if (load.StatusCode is 401 or 403) return ForgetCachedTokenAndReportSignedOut(); if (!load.Ok) @@ -104,12 +113,24 @@ public static async Task FetchAsync(CancellationToken ct) return new AntigravityUsage(LocalLoginOutcome.Ok, quota.Body, planName); } + private static Task LoadCodeAssistAsync(string accessToken, CancellationToken ct) => + LocalLoginHttp.SendAsync( + HttpMethod.Post, LoadUrl, accessToken, + "{\"metadata\":{\"ideType\":\"ANTIGRAVITY\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}}", + UserAgent, ct); + private static AntigravityUsage FailedFor(LocalLoginHttp.Response response) => AntigravityUsage.Failed with { FailureKind = response.FailureKind, HttpStatus = response.StatusCode > 0 ? response.StatusCode : null }; /// A refused token must not keep being served from the cache for up to an hour after the /// CLI has already signed in again. private static AntigravityUsage ForgetCachedTokenAndReportSignedOut() + { + ForgetCachedToken(); + return AntigravityUsage.NotSignedIn; + } + + private static void ForgetCachedToken() { lock (Gate) { @@ -117,7 +138,6 @@ private static AntigravityUsage ForgetCachedTokenAndReportSignedOut() _cachedAccessToken = null; _acceptedPair = null; } - return AntigravityUsage.NotSignedIn; } /// Rejected is true when the token endpoint refused the refresh token itself diff --git a/src/AiUsage/Providers/LocalLogin/ClaudeAccountLabelReader.cs b/src/AiUsage/Providers/LocalLogin/ClaudeAccountLabelReader.cs index 8563b86..78a0d93 100644 --- a/src/AiUsage/Providers/LocalLogin/ClaudeAccountLabelReader.cs +++ b/src/AiUsage/Providers/LocalLogin/ClaudeAccountLabelReader.cs @@ -31,29 +31,49 @@ internal static string PathFor(string? configDirValue, string userProfile) public static string? Read() => Read(DefaultPath()); + // Claude Code keeps its whole project history in the same file, often several megabytes; an + // unchanged file is not parsed again just to find the same address. + private static readonly Lock Gate = new(); + private static (string Path, long Length, DateTime LastWriteUtc, string? Label)? _last; + /// Test seam: a fixture path instead of the real profile folder. internal static string? Read(string path) { try { - if (!File.Exists(path)) + var info = new FileInfo(path); + if (!info.Exists) return null; - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - using var document = JsonDocument.Parse(stream); - if (document.RootElement.ValueKind != JsonValueKind.Object) - return null; - if (!document.RootElement.TryGetProperty("oauthAccount", out var account) || account.ValueKind != JsonValueKind.Object) - return null; - if (!account.TryGetProperty("emailAddress", out var emailEl) || emailEl.ValueKind != JsonValueKind.String) - return null; + lock (Gate) + { + if (_last is { } last && last.Path == path && last.Length == info.Length && last.LastWriteUtc == info.LastWriteTimeUtc) + return last.Label; + } - var email = emailEl.GetString(); - return AccountLabels.LooksLikeEmail(email) ? email : null; + var label = ReadLabel(path); + lock (Gate) + _last = (path, info.Length, info.LastWriteTimeUtc, label); + return label; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentOutOfRangeException) { return null; } } + + private static string? ReadLabel(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var document = JsonDocument.Parse(stream); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return null; + if (!document.RootElement.TryGetProperty("oauthAccount", out var account) || account.ValueKind != JsonValueKind.Object) + return null; + if (!account.TryGetProperty("emailAddress", out var emailEl) || emailEl.ValueKind != JsonValueKind.String) + return null; + + var email = emailEl.GetString(); + return AccountLabels.LooksLikeEmail(email) ? email : null; + } } diff --git a/src/AiUsage/Providers/LocalLogin/GitHubCliUsage.cs b/src/AiUsage/Providers/LocalLogin/GitHubCliUsage.cs index a1559e7..f95027b 100644 --- a/src/AiUsage/Providers/LocalLogin/GitHubCliUsage.cs +++ b/src/AiUsage/Providers/LocalLogin/GitHubCliUsage.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.RegularExpressions; namespace AiUsage.Providers.LocalLogin; @@ -28,7 +29,7 @@ internal sealed record GitHubAccount(string Host, string Login, bool Active); /// machine: gh api copilot_internal/user. The widget never touches the CLI's token - it only /// asks the tool the user already trusts, which reads usage numbers only. Never throws. /// -internal static class GitHubCliUsage +internal static partial class GitHubCliUsage { /// The CLI's own download page, offered when it is missing. public const string DownloadUrl = "https://cli.github.com/"; @@ -103,6 +104,9 @@ public static async Task> ListSignedInUsersAsync(Ca internal static IReadOnlyList OnlyGitHubDotCom(IReadOnlyList accounts) => accounts.Where(account => string.Equals(account.Host, GitHubHost, StringComparison.OrdinalIgnoreCase)).ToList(); + [GeneratedRegex(@"Logged in to (?\S+) account (?\S+)", RegexOptions.CultureInvariant)] + private static partial Regex LoginLine(); + /// Pure parsing of gh auth status's own text - one line per account, shaped /// "  ✓ Logged in to github.com account octocat (keyring)" (spelled with a leading /// checkmark and worded slightly differently across CLI versions, hence the loose match), with the @@ -112,8 +116,7 @@ internal static IReadOnlyList ParseAuthStatus(string text) { var accounts = new List(); var lines = text.Replace("\r\n", "\n").Split('\n'); - var loginPattern = new System.Text.RegularExpressions.Regex( - @"Logged in to (?\S+) account (?\S+)", System.Text.RegularExpressions.RegexOptions.Compiled); + var loginPattern = LoginLine(); for (var i = 0; i < lines.Length; i++) { @@ -189,8 +192,6 @@ internal static IReadOnlyList ParseAuthStatus(string text) if (combineStreams) return (GitHubCliOutcome.Ok, stdout + "\n" + stderr); - if (process.ExitCode == 0 && stdout.TrimStart().StartsWith('{')) - return (GitHubCliOutcome.Ok, stdout); if (process.ExitCode == 0) return (GitHubCliOutcome.Ok, stdout.Trim()); diff --git a/src/AiUsage/Providers/Parsing/ClaudeLocalLimitReader.cs b/src/AiUsage/Providers/Parsing/ClaudeLocalLimitReader.cs index baa6fa6..6a3ca22 100644 --- a/src/AiUsage/Providers/Parsing/ClaudeLocalLimitReader.cs +++ b/src/AiUsage/Providers/Parsing/ClaudeLocalLimitReader.cs @@ -73,7 +73,9 @@ private sealed record FileState( try { var files = LocalFileScan.NewestFiles(projectsRoot, "*.jsonl", MaxDepth, MaxFilesToCheck); - newestFilePath = files.Count > 0 ? files[0].FullName : null; + // A running subagent writes its own transcript next to the session's; the session is the + // one the tile describes, so its token sum and waiting state never flip to the subagent's. + newestFilePath = files.FirstOrDefault(f => !Stats.ClaudeUsageLogParser.IsSubagentFile(f.FullName))?.FullName; PruneCache(projectsRoot, files); var newestPerType = new Dictionary(StringComparer.Ordinal); diff --git a/src/AiUsage/Providers/Parsing/ClaudeSessionTokenReader.cs b/src/AiUsage/Providers/Parsing/ClaudeSessionTokenReader.cs index 4418f00..8e3f8c9 100644 --- a/src/AiUsage/Providers/Parsing/ClaudeSessionTokenReader.cs +++ b/src/AiUsage/Providers/Parsing/ClaudeSessionTokenReader.cs @@ -30,6 +30,10 @@ private sealed class FileState // Oldest read first, newest last. private readonly List<(string Path, FileState State)> _states = []; + + // A read abandoned by a timed-out refresh may still be running when the retry starts; the two must + // never update one file's sum at the same time. + private readonly Lock _gate = new(); private FileState? _current; // A single line longer than this is never a usage line; it is skipped without being buffered. @@ -54,22 +58,24 @@ private sealed class FileState /// cref="ClaudeUsageLogParser.TryParse"/> and adding its four token counts to that file's running /// total - or, on a repeat call against the same file, only the lines appended since the last /// call. - public long SumTokens(string sessionFile) + public long SumTokens(string sessionFile, CancellationToken ct = default) + { + lock (_gate) + return SumTokensCore(sessionFile, ct); + } + + private long SumTokensCore(string sessionFile, CancellationToken ct) { var length = new FileInfo(sessionFile).Length; var state = StateFor(sessionFile); if (length < state.Offset) - { - state.Sum = 0; - state.Offset = 0; - state.LastEventAt = null; - state.SeenMessages.Clear(); - } + Reset(state); var offsetBefore = state.Offset; - using (var stream = new FileStream(sessionFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + try { + using var stream = new FileStream(sessionFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); // Only newline-terminated lines are consumed: the resume offset ends at the last one, so a // line the writer has not finished yet is read whole on the next call instead of being // cut in two and lost. Growth after the stream was opened is the next call's business. @@ -83,13 +89,28 @@ public long SumTokens(string sessionFile) if (state.LastEventAt is not { } newest || usage.Timestamp > newest) state.LastEventAt = usage.Timestamp; state.Sum += usage.InputTokens + usage.OutputTokens + usage.CacheCreationTokens + usage.CacheReadTokens; - }, CancellationToken.None); + }, ct); + } + catch (OperationCanceledException) + { + // The lines summed before the cancel are past the stored offset; the file is read whole + // next time rather than counted twice. + Reset(state); + throw; } TotalBytesRead += state.Offset - offsetBefore; return state.Sum; } + private static void Reset(FileState state) + { + state.Sum = 0; + state.Offset = 0; + state.LastEventAt = null; + state.SeenMessages.Clear(); + } + /// The state of , moved to the newest position; a new file takes the /// place of the least recently read one when all slots are in use. private FileState StateFor(string path) diff --git a/src/AiUsage/Providers/Parsing/CodexRateLimitParser.cs b/src/AiUsage/Providers/Parsing/CodexRateLimitParser.cs index ece99ea..4a885a4 100644 --- a/src/AiUsage/Providers/Parsing/CodexRateLimitParser.cs +++ b/src/AiUsage/Providers/Parsing/CodexRateLimitParser.cs @@ -67,11 +67,11 @@ public static bool TryParse(string line, out CodexRateLimits? result) result = new CodexRateLimits( PrimaryUsedPercent: primaryUsedPercent, - PrimaryWindowMinutes: TryGetInt32(primary, "window_minutes"), - PrimaryResetsAt: TryGetUnixSeconds(primary, "resets_at"), + PrimaryWindowMinutes: CodexWindowKinds.PlausibleMinutesOrNull(TryGetInt32(primary, "window_minutes")), + PrimaryResetsAt: TryGetUnixTime(primary, "resets_at"), SecondaryUsedPercent: secondaryUsedPercent, - SecondaryWindowMinutes: TryGetInt32(secondary, "window_minutes"), - SecondaryResetsAt: TryGetUnixSeconds(secondary, "resets_at"), + SecondaryWindowMinutes: CodexWindowKinds.PlausibleMinutesOrNull(TryGetInt32(secondary, "window_minutes")), + SecondaryResetsAt: TryGetUnixTime(secondary, "resets_at"), PlanType: TryGetString(rateLimits, "plan_type"), Timestamp: timestamp, TotalTokens: TryGetTotalTokens(payload)); @@ -97,13 +97,13 @@ public static bool TryParse(string line, out CodexRateLimits? result) ? tokens : null; - private static DateTimeOffset? TryGetUnixSeconds(JsonElement? element, string propertyName) + private static DateTimeOffset? TryGetUnixTime(JsonElement? element, string propertyName) { if (element is not { } parent || !parent.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out var seconds)) return null; - return UnixTimeConversion.FromUnixSecondsOrNull(seconds) is { } instant + return UnixTimeConversion.FromUnixSecondsOrMillisecondsOrNull(seconds) is { } instant ? UnixTimeConversion.PlausibleOrNull(instant) : null; } diff --git a/src/AiUsage/Providers/Parsing/CodexWebUsageParser.cs b/src/AiUsage/Providers/Parsing/CodexWebUsageParser.cs index dad1647..ef968b4 100644 --- a/src/AiUsage/Providers/Parsing/CodexWebUsageParser.cs +++ b/src/AiUsage/Providers/Parsing/CodexWebUsageParser.cs @@ -78,11 +78,11 @@ private static void AddWindow( if (block is not { } window || TryGetDouble(window, "used_percent") is not { } usedPercent) return; - // A length that is not a positive, int-sized minute count reads as unknown; the percentage - // stays valid on its own. + // A length that is not a plausible minute count reads as unknown; the percentage stays valid + // on its own. long? minutes = TryGetInt32(window, "window_minutes"); minutes ??= TryGetInt64(window, "limit_window_seconds") is { } seconds ? seconds / 60 : null; - var windowMinutes = minutes is > 0 and <= int.MaxValue ? (int?)minutes : null; + var windowMinutes = CodexWindowKinds.PlausibleMinutesOrNull(minutes); // The length decides which bar this is; the position only answers when the length is unknown. var kind = CodexWindowKinds.KindFor(windowMinutes, byPosition, windows.Count > 0 ? windows[^1].Kind : null); diff --git a/src/AiUsage/Providers/Parsing/CodexWindowKinds.cs b/src/AiUsage/Providers/Parsing/CodexWindowKinds.cs index 0677051..8768810 100644 --- a/src/AiUsage/Providers/Parsing/CodexWindowKinds.cs +++ b/src/AiUsage/Providers/Parsing/CodexWindowKinds.cs @@ -29,5 +29,13 @@ internal static WindowKind KindFor(int? windowMinutes, WindowKind byPosition, Wi return earlier == kind ? byPosition : kind; } + /// No usage window runs longer than about a year. + private const long MaxWindowMinutes = 400L * 24 * 60; + + /// A reported length as a positive minute count no longer than a year, otherwise unknown - + /// an absurd length would push the window's start before the first representable instant. + internal static int? PlausibleMinutesOrNull(long? minutes) => + minutes is > 0 and <= MaxWindowMinutes ? (int)minutes.Value : null; + internal static string LabelFor(WindowKind kind) => kind == WindowKind.Weekly ? "Window_Weekly" : "Window_FiveHour"; } diff --git a/src/AiUsage/Providers/ProviderSnapshots.cs b/src/AiUsage/Providers/ProviderSnapshots.cs index cc9eb78..1b3bb9e 100644 --- a/src/AiUsage/Providers/ProviderSnapshots.cs +++ b/src/AiUsage/Providers/ProviderSnapshots.cs @@ -30,17 +30,21 @@ internal static ProviderSnapshot Unavailable( : OfflineError, SkipReasonWord: skipReasonWord); - /// The answer of a fetch that leaves the network alone (a pause or the energy saver holds - /// network reads back): no windows, no failure, nothing to retry. - internal static ProviderSnapshot NoData(string accountKey, DateTimeOffset fetchedAt) => new( + /// A snapshot with no windows, carrying only its status and error. + internal static ProviderSnapshot Empty(string accountKey, DateTimeOffset fetchedAt, ProviderStatus status, ProviderError? error) => new( ProviderId: accountKey, Windows: [], PlanType: null, SourceKind: SourceKind.None, FetchedAt: fetchedAt, DataTimestamp: null, - Status: ProviderStatus.NoLocalData, - Error: null); + Status: status, + Error: error); + + /// The answer of a fetch that leaves the network alone (a pause or the energy saver holds + /// network reads back): no windows, no failure, nothing to retry. + internal static ProviderSnapshot NoData(string accountKey, DateTimeOffset fetchedAt) => + Empty(accountKey, fetchedAt, ProviderStatus.NoLocalData, null); /// The offline stand-in alone: null while the machine is online, so a caller whose /// failure case has nothing better to say can fall through to its own fallback. diff --git a/src/AiUsage/Resources/Strings.de.resx b/src/AiUsage/Resources/Strings.de.resx index 1085b4a..33c4302 100644 --- a/src/AiUsage/Resources/Strings.de.resx +++ b/src/AiUsage/Resources/Strings.de.resx @@ -25,7 +25,7 @@ 5 Stunden 7 Tage - fast aufgebraucht + wird knapp fast am Limit Sonstiges {0} Token in diesem Zeitfenster @@ -68,10 +68,15 @@ Downloadseite öffnen Für die Anmeldung braucht AI-Usage die Microsoft WebView2-Laufzeit. Sie fehlt auf diesem PC. Jetzt installieren? Installieren - Die WebView2-Laufzeit wird heruntergeladen … - Die WebView2-Laufzeit wird installiert … + Die WebView2-Laufzeit wird heruntergeladen… + Die WebView2-Laufzeit wird installiert… Die WebView2-Laufzeit ließ sich nicht installieren. Du kannst sie bei Microsoft herunterladen und dich danach erneut anmelden. Downloadseite öffnen + Vorbereitung: Dem Downloadordner kann nicht vertraut werden. + Vorbereitung: Die heruntergeladene Datei ist eine Verknüpfung. + Vorbereitung: Der Downloadordner enthält mehr als das Installationsprogramm. + Das Setup endete mit Code {0}. + Die Laufzeit fehlt auch nach dem Setup. Veraltete Daten Letzte Aktualisierung vor {0} Die Zahlen stammen aus einem Fenster, das inzwischen abgelaufen ist. @@ -79,14 +84,14 @@ Abruf fehlgeschlagen Der Abruf ist fehlgeschlagen. Der nächste Versuch folgt automatisch. Der Abruf ist fehlgeschlagen ({0}). Der nächste Versuch folgt automatisch. - keine Antwort in der Zeit + keine rechtzeitige Antwort HTTP {0} keine Verbindung Limit erreicht Das Diagramm erscheint ab zwei Messpunkten. Für diesen Zeitraum sind noch keine Token gezählt. Verlauf {0}: {1} Werte, Höchstwert {2} %. - Alle Einstellungen auf die Vorgaben zurücksetzen? Das lässt sich nicht rückgängig machen. Für eine Sicherung vorher "Einstellungen exportieren" wählen. + Alle Einstellungen auf die Vorgaben zurücksetzen? Das lässt sich nicht rückgängig machen. Für eine Sicherung vorher „Exportieren…“ wählen. Die Einstellungsdatei stammt aus einer neueren Version. Sie bleibt unangetastet, in dieser Sitzung wird nichts gespeichert; es gelten die Vorgaben. {0}: {1} bei {2} % ({3}) {0}: {1} bei {2} % @@ -198,6 +203,8 @@ {0} ({1}) Strg Alt + Umschalt + Win Nebula Terminal Dunkel @@ -233,7 +240,7 @@ Diesen Zugang entfernen über die Websitzung über Claude Code - über Antigravity-CLI + über die Antigravity-CLI über die GitHub-CLI über die Codex-Sitzungsdateien über die Dateien von Claude Code @@ -296,7 +303,7 @@ Aufgezeichneten Verlauf ebenfalls löschen Exportieren… Importieren… - Einstellungen übernommen. Fensterposition und -größe sind geblieben. + Einstellungen übernommen. Fensterposition und -größe wurden beibehalten. Diese Datei ist keine brauchbare Einstellungsdatei. Datenordner Ordner wählen… @@ -312,7 +319,7 @@ Von dir gewählter Ordner Notfallordner, weil das Benutzerprofil nicht beschreibbar war Anmelden - Anmeldeseite wird geöffnet … + Anmeldeseite wird geöffnet… Seite blockiert Die Anmeldung wollte {0} öffnen. Diese Seite gehört nicht zu den erlaubten Zielen dieses Fensters, geladen wurde nichts. Gehört sie zur Anmeldung, kannst du sie hier für dieses Fenster laden; beim nächsten Öffnen gilt wieder die übliche Liste. Verstanden @@ -331,7 +338,7 @@ Es liest die Nutzungsdateien, die diese Werkzeuge ohnehin auf diesem PC führen. Wo du dich unten anmeldest, liest es zusätzlich deine eigene Nutzungsseite beim Anbieter. Deine Nutzungsdaten bleiben auf diesem PC. AI-Usage spricht nur mit der Nutzungsseite des jeweiligen Anbieters, mit Google zum Erneuern einer abgelaufenen Antigravity-Anmeldung und mit GitHub für die Update-Prüfung. Es ruft nie einen Chat-Endpunkt auf und kann deshalb nie etwas von deinem Kontingent verbrauchen. Fertig - Daten werden geladen … + Daten werden geladen… Zeitraum Gruppieren nach Pro Tag @@ -339,8 +346,8 @@ gegenüber Vorzeitraum gegenüber {0} im Vorzeitraum Tsd. - Mio - Mrd + Mio. + Mrd. {0} % Tsd Mio @@ -368,7 +375,7 @@ Schätzung aus {0} Stunden deiner Nutzung. Gelesener Cache zählt getrennt: 1 % ≈ {1} Token aus dem Cache. 1 % der Woche ≈ {0} Token (alle Modelle) Noch zu wenig Daten für Token je Prozent. - Mit mehr als einem Konto nicht verfügbar. + Mit mehr als einem Zugang nicht verfügbar. Ohne Projekt Weitere Projekte {0} Projekte @@ -407,15 +414,15 @@ Auf welche Wochentage die Token fallen, über den ganzen Zeitraum summiert. Auf welche Tagesstunden die Token fallen. Mica ist ein Effekt von Windows 11: Der Fensterhintergrund nimmt einen zarten Farbton deines Desktophintergrunds an und ändert sich leicht, wenn du das Fenster verschiebst. Er erscheint nur im Design „System“ bei voller Deckkraft und kostet etwas Grafikleistung. - Legt fest, wie hoch eine Kachel ist. Bei automatischer Einstellung passen sich die Kacheln an die Fensterhöhe an. - Legt fest, wessen Verbrauch das Symbol im Infobereich der Taskleiste zeigt. Auf automatisch zeigt es den höchsten Verbrauch aller Kacheln. - Legt fest, welches Zeitfenster das Symbol zeigt. Auf automatisch zeigt es den höchsten Wert. + Legt fest, wie hoch eine Kachel ist. Bei „Automatisch“ passen sich die Kacheln an die Fensterhöhe an. + Legt fest, wessen Verbrauch das Symbol im Infobereich der Taskleiste zeigt. Bei „Automatisch“ zeigt es den höchsten Verbrauch aller Kacheln. + Legt fest, welches Zeitfenster das Symbol zeigt, zum Beispiel 5 Stunden oder 7 Tage. Bei „Automatisch“ zeigt es den höchsten Wert. Hier liegen deine Einstellungen, dein Verlauf und der Token-Index. Öffnet die Anmeldeseite dieses Anbieters in einem eigenen Fenster. Die Sitzung bleibt auf diesem PC. Löscht die gespeicherte Browsersitzung dieses Anbieters von diesem PC. Zeigt oder verbirgt die Kachel dieses Anbieters im Hauptfenster. Die Daten werden so oder so weiter gesammelt. Wartemarke anzeigen für - So lange ohne Antwort markiert die Kachel den Anbieter als wartend. Auf Aus wird nichts markiert. + So lange ohne Antwort markiert die Kachel den Anbieter als wartend. Bei „Aus“ wird nichts markiert. Aus 1 h 2 h @@ -437,11 +444,12 @@ {0:0.#} MB Der Anbieter hat seine Seite geändert. Sobald eine angepasste Version erscheint, bietet das Widget sie als Update an. Du bist schon angemeldet. - Diese Sitzung hat bereits einen gültigen Zugang. Die Kachel holt ihre Zahlen ab der nächsten Aktualisierung darüber. + Diese Sitzung ist bereits gültig angemeldet. Die Kachel holt ihre Zahlen ab der nächsten Aktualisierung darüber. Token - Schnitt je aktivem Tag + Schnitt pro aktivem Tag Die Summe geteilt durch die Tage, an denen wirklich Token angefallen sind. an {0} von {1} Tagen + an {0} von 1 Tag Verbrauch je Tag Für diesen Zeitraum sind noch keine Tage gezählt. weniger @@ -461,7 +469,7 @@ {0} % der Zeit sind vergangen, der Verbrauch liegt darunter. {0} hat gerade Probleme Der Server meldet einen Fehler (HTTP {1}). Der nächste Versuch folgt automatisch. - {0} lehnt die Anfrage ab + {0} lehnt die Abfrage ab HTTP {1}. Der nächste Versuch folgt automatisch. {0} antwortet nicht Keine Antwort in der vorgesehenen Zeit. Der nächste Versuch folgt automatisch. @@ -483,7 +491,7 @@ Name z. B. Arbeit Das Update ließ sich nicht abschließen. Deine Programmdatei liegt jetzt unter {0}. Benenne sie wieder in AI-Usage.exe um, dann startet die App wieder. - Das Setup ist nicht gestartet. Falls du die Admin-Abfrage geschlossen hast, versuch es noch einmal. + Das Setup ist nicht gestartet. Falls du die Admin-Abfrage geschlossen hast, versuche es noch einmal. Warnen, bevor ein Limit voraussichtlich erreicht ist {0} h {0} min @@ -497,7 +505,7 @@ {0}, {1}: {2} %, Limit fast erreicht {0}, {1}: Limit erreicht Melden, wenn ein Limit erreicht ist (100 %) - {0}: Limit für {1} erreicht, wieder frei in {2} + {0}: Limit für {1} erreicht, {2} {0}: Limit für {1} erreicht Widget zeigen Im Akkubetrieb Strom sparen @@ -514,7 +522,7 @@ Wochentag Stunde Wochentag × Stunde - {0}, {1}-{2} Uhr: {3} Token + {0}, {1}-{2}: {3} Token Auf welche Wochentage und Tagesstunden die Token fallen. Meist {0} ({1}) Farben nach @@ -535,6 +543,7 @@ Dieser Monat Letzter Monat Eigener Zeitraum… + {0} bis {1} Alle Anbieter Anbieter {0} Anbieter @@ -561,14 +570,15 @@ Sicherung gespeichert. Diese Datei ist keine AI-Usage-Sicherung oder stammt aus einer neueren Version. Verlauf importieren… - Liest den Token-Verlauf aus der Sicherung eines anderen PCs ein und führt ihn mit dem Verlauf hier zusammen. Die eigenen Daten bleiben unverändert; derselbe PC erneut importiert ersetzt seinen alten Stand. + Liest den Token-Verlauf aus der Sicherung eines anderen PCs ein und führt ihn mit dem Verlauf hier zusammen. Die eigenen Daten bleiben unverändert; wird derselbe PC erneut importiert, ersetzt das seinen alten Stand. Diese Sicherung stammt von diesem PC. {0} Tage Token-Verlauf von {1} importiert. + 1 Tag Token-Verlauf von {1} importiert. Entfernen Enthält Verlauf von: {0} {0} bremst die Abfragen Gerade zu viele Abfragen (HTTP {1}). Der nächste Versuch wartet etwas länger. - Das Update wird heruntergeladen … + Das Update wird heruntergeladen… Dieses Release enthält keine Datei für deine Fassung von AI-Usage. Details stehen auf der Release-Seite. Keine Meldungen ab Keine Meldungen bis diff --git a/src/AiUsage/Resources/Strings.resx b/src/AiUsage/Resources/Strings.resx index d845524..5b459da 100644 --- a/src/AiUsage/Resources/Strings.resx +++ b/src/AiUsage/Resources/Strings.resx @@ -25,7 +25,7 @@ 5 hours 7 days - nearly used up + running low almost at the limit Other {0} tokens in this window @@ -72,6 +72,11 @@ Installing the WebView2 Runtime… The WebView2 Runtime could not be installed. You can download it from Microsoft and then sign in again. Open download page + Preparing: the download folder cannot be trusted. + Preparing: the downloaded file is a link. + Preparing: the download folder holds more than the installer. + Setup ended with exit code {0}. + The runtime is still missing after setup. Data is out of date Last update {0} ago These numbers come from a window that has since expired. @@ -86,11 +91,11 @@ The chart appears once there are two readings. No tokens counted for this period yet. History {0}: {1} values, highest {2}%. - Reset all settings to their defaults? This cannot be undone. To keep a copy, choose Export settings first. + Reset all settings to their defaults? This cannot be undone. To keep a copy, choose Export… first. The settings file comes from a newer version. It is left untouched and nothing is saved this session; defaults are in use. {0}: {1} at {2}% ({3}) {0}: {1} at {2}% - {0}: the {1} window is free again. + {0}: the “{1}” window is free again. Every provider is hidden Data keeps being collected in the background. Show a provider again to see it. Open settings @@ -199,6 +204,8 @@ {0} ({1}) Ctrl Alt + Shift + Win Nebula Terminal Dark @@ -364,10 +371,10 @@ Peak {0} · avg. peak {1} Peak: the highest level in the range. Avg. peak: the mean of each window's highest level, marked as a line on the bar. No quota history in this period yet. - 1 % of the week ≈ {0} + 1% of the week ≈ {0} {0} tokens {1} - Estimate from {0} hours of your own usage. Cache reads count separately: 1 % ≈ {1} cached tokens. - 1 % of the week ≈ {0} tokens (all models) + Estimate from {0} hours of your own usage. Cache reads count separately: 1% ≈ {1} cached tokens. + 1% of the week ≈ {0} tokens (all models) Not enough data yet for tokens per percent. Not available with more than one account. No project @@ -408,15 +415,15 @@ Which weekdays the tokens fall on, added up over the whole period. Which hours of the day the tokens fall on. Mica is a Windows 11 material: the window background takes on a soft tint of your desktop wallpaper and shifts slightly as you move the window. It only shows with the "System" theme at full opacity and uses a little graphics power. - Sets how tall one tile is. On automatic the tiles fit themselves to the window height. - Picks whose usage the icon in the taskbar notification area shows. On automatic it shows the highest usage of all tiles. - Picks which time window the icon shows, for example 5 hours or week. On automatic it shows the highest value. + Sets how tall one tile is. When set to Automatic, the tiles fit themselves to the window height. + Picks whose usage the icon in the taskbar notification area shows. When set to Automatic, it shows the highest usage of all tiles. + Picks which time window the icon shows, for example 5 hours or 7 days. When set to Automatic, it shows the highest value. Where your settings, your history and the token index are stored. Opens this provider's sign-in page in a window of its own. The session stays on this PC. Deletes this provider's saved browser session from this PC. Shows or hides this provider's tile in the main window. Its data keeps being collected either way. Show the waiting mark for - After this long without an answer, the tile stops marking the provider as waiting. On off, nothing is marked. + After this long without an answer, the tile stops marking the provider as waiting. When set to Off, nothing is marked. Off 1 h 2 h @@ -443,6 +450,7 @@ Average per active day The total divided by the days that actually saw tokens. on {0} of {1} days + on {0} of 1 day Usage per day No days counted for this period yet. less @@ -457,9 +465,9 @@ Selected day · {0} Close day detail Waiting for an answer. Click to dismiss. - {0} % of the time has passed, usage is ahead of it. - {0} % of the time has passed, usage is on pace. - {0} % of the time has passed, usage is below it. + {0}% of the time has passed, usage is ahead of it. + {0}% of the time has passed, usage is on pace. + {0}% of the time has passed, usage is below it. {0} is having problems The server answered with an error (HTTP {1}). The next attempt follows automatically. {0} refused the request @@ -494,11 +502,11 @@ Turn off to pick weekdays without notifications: unchecked days stay completely quiet. Show the time marker in the bars A short line in each bar shows how much of the time window has passed. If the fill reaches past it, usage is running ahead of time. - {0}, {1}: {2} %, running low - {0}, {1}: {2} %, limit almost reached + {0}, {1}: {2}%, running low + {0}, {1}: {2}%, limit almost reached {0}, {1}: limit reached - Notify when a limit is reached (100 %) - {0}: {1} limit reached, resets in {2} + Notify when a limit is reached (100%) + {0}: limit reached for {1}, {2} {0}: {1} limit reached Show widget Save energy on battery @@ -536,6 +544,7 @@ This month Last month Custom range… + {0} to {1} All providers Provider {0} providers @@ -565,6 +574,7 @@ Reads the token history from another PC's backup and merges it with the history here. This PC's own data stays untouched; importing the same PC again replaces its earlier copy. This backup comes from this PC. Imported {0} days of token history from {1}. + Imported 1 day of token history from {1}. Remove Includes history from: {0} {0} asks for fewer requests diff --git a/src/AiUsage/Services/AppearanceHook.cs b/src/AiUsage/Services/AppearanceHook.cs index 8abec56..4faa50d 100644 --- a/src/AiUsage/Services/AppearanceHook.cs +++ b/src/AiUsage/Services/AppearanceHook.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using System.Windows.Interop; +using System.Windows.Threading; namespace AiUsage.Services; @@ -20,6 +21,10 @@ internal static bool IsColorNotice(int msg, string? settingName) => msg == WmDwmColorizationColorChanged || (msg == WmSettingChange && string.Equals(settingName, "ImmersiveColorSet", StringComparison.Ordinal)); + // Windows sends several of these notices for one change; the theme is applied once they settle. + private static readonly TimeSpan ColorNoticeSettle = TimeSpan.FromMilliseconds(250); + private static DispatcherTimer? _pendingApply; + public static void Attach(HwndSource source) => source.AddHook(OnMessage); private static IntPtr OnMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) @@ -32,8 +37,25 @@ private static IntPtr OnMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lPar var settingName = msg == WmSettingChange && lParam != IntPtr.Zero ? Marshal.PtrToStringUni(lParam) : null; if (IsColorNotice(msg, settingName) && ThemeService.CurrentTheme == AppTheme.System) - ThemeService.Apply(AppTheme.System); + ScheduleApply(); return IntPtr.Zero; } + + private static void ScheduleApply() + { + if (_pendingApply is null) + { + _pendingApply = new DispatcherTimer(DispatcherPriority.Background) { Interval = ColorNoticeSettle }; + _pendingApply.Tick += (_, _) => + { + _pendingApply!.Stop(); + if (ThemeService.CurrentTheme == AppTheme.System) + ThemeService.Apply(AppTheme.System); + }; + } + + _pendingApply.Stop(); + _pendingApply.Start(); + } } diff --git a/src/AiUsage/Services/CountdownFormatter.cs b/src/AiUsage/Services/CountdownFormatter.cs index aa1a5af..fcd7e5c 100644 --- a/src/AiUsage/Services/CountdownFormatter.cs +++ b/src/AiUsage/Services/CountdownFormatter.cs @@ -54,7 +54,7 @@ internal static string FormatClock(DateTimeOffset? resetsAt, DateTimeOffset now, if (resetsAt is not { } reset) return ""; - var culture = CultureInfo.CurrentCulture; + var culture = LocalizationService.Instance.DisplayCulture; var local = TimeZoneInfo.ConvertTime(reset, zone); var remaining = reset - now; if (remaining < TimeSpan.FromHours(24)) @@ -73,9 +73,8 @@ internal static string FormatClock(DateTimeOffset? resetsAt, DateTimeOffset now, /// "Last update … ago" for the Stale state /// - same day/hour/minute rounding as , just phrased as - /// elapsed time. The bare duration ("2h 15m") is not looked up per language: the identical - /// "d"/"h"/"m" unit letters are used in both EN and DE, so this is - /// locale-invariant the same way a raw number is, not a piece of translatable prose. + /// elapsed time. The bare duration ("2h 15m") is not looked up per language: it uses the compact + /// unit symbols the reset texts of both languages use as well. public static string FormatAge(DateTimeOffset? dataTimestamp, DateTimeOffset now) { if (dataTimestamp is not { } timestamp) @@ -84,7 +83,7 @@ public static string FormatAge(DateTimeOffset? dataTimestamp, DateTimeOffset now return Loc.Format("State.Stale.Reason", FormatElapsed(now - timestamp)); } - /// Bare "2h 15m"-style duration text (a zero unit is left out: "2h", "19d"), locale-invariant like a raw number - reused by + /// Bare "2h 15m"-style duration text (a zero unit is left out: "2h", "19d"), in the same compact symbols in every language - reused by /// for the usage forecast segment instead of a second /// duration formatter. Below a minute the seconds run along instead of a flat "<1m", so the /// number on screen keeps moving instead of sitting still for up to 59 seconds. diff --git a/src/AiUsage/Services/GlobalHotkey.cs b/src/AiUsage/Services/GlobalHotkey.cs index 8e645ac..1a5aae1 100644 --- a/src/AiUsage/Services/GlobalHotkey.cs +++ b/src/AiUsage/Services/GlobalHotkey.cs @@ -233,4 +233,21 @@ public static string Format(ModifierKeys modifiers, Key key) parts.Add(key.ToString()); return string.Join("+", parts); } + + /// The stored form as shown to the user, the modifiers named in the active language + /// ("Strg+Alt+U" in German). Text that does not parse is shown as it is. + public static string FormatForDisplay(string? stored) + { + if (string.IsNullOrEmpty(stored) || !TryParse(stored, out var modifiers, out var key)) + return stored ?? ""; + + var loc = LocalizationService.Instance; + var parts = new List(5); + if (modifiers.HasFlag(ModifierKeys.Control)) parts.Add(loc["Key.Ctrl"]); + if (modifiers.HasFlag(ModifierKeys.Alt)) parts.Add(loc["Key.Alt"]); + if (modifiers.HasFlag(ModifierKeys.Shift)) parts.Add(loc["Key.Shift"]); + if (modifiers.HasFlag(ModifierKeys.Windows)) parts.Add(loc["Key.Win"]); + parts.Add(key.ToString()); + return string.Join("+", parts); + } } diff --git a/src/AiUsage/Services/LocalizationService.cs b/src/AiUsage/Services/LocalizationService.cs index 6cd99f8..9f2f5c4 100644 --- a/src/AiUsage/Services/LocalizationService.cs +++ b/src/AiUsage/Services/LocalizationService.cs @@ -10,8 +10,9 @@ namespace AiUsage.Services; /// Strings.resx (EN) and Strings.de.resx (DE) are embedded straight into the main /// assembly (see AiUsage.csproj), each behind its own instance, so /// switching languages is a plain lookup-table swap - no thread culture, no satellite assembly -/// resolution, nothing that could interact with number/date formatting (those follow -/// the OS regional format regardless of which UI language is picked here). +/// resolution, nothing that could interact with number/date formatting (those follow the OS +/// regional format regardless of which UI language is picked here; only the names of days and +/// months follow the UI language, through ). /// public sealed class LocalizationService : INotifyPropertyChanged { @@ -80,6 +81,48 @@ public void SetLanguage(string language) /// calendar already knows (the days of the week). public CultureInfo ActiveCulture => CultureInfo.GetCultureInfo(Languages.First(l => l.Resources == _active).Code); + private sealed record DisplayEntry(CultureInfo Regional, CultureInfo Language, CultureInfo Display); + + // Replaced whole, so a reader on another thread never sees half of an entry. + private DisplayEntry? _display; + + /// The Windows regional format with the day and month names of the active UI language: + /// what every date that names a weekday or month is written with, so a German UI on an English + /// regional format reads "Montag", never "Monday", while its clock and numbers keep the regional + /// format. + public CultureInfo DisplayCulture + { + get + { + var regional = CultureInfo.CurrentCulture; + var language = ActiveCulture; + if (_display is { } cached && ReferenceEquals(cached.Regional, regional) && ReferenceEquals(cached.Language, language)) + return cached.Display; + + var display = DisplayCultureFor(regional, language); + _display = new DisplayEntry(regional, language, display); + return display; + } + } + + internal static CultureInfo DisplayCultureFor(CultureInfo regional, CultureInfo language) + { + if (string.Equals(regional.TwoLetterISOLanguageName, language.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) + return regional; + + var culture = (CultureInfo)regional.Clone(); + var names = language.DateTimeFormat; + var format = culture.DateTimeFormat; + format.DayNames = names.DayNames; + format.AbbreviatedDayNames = names.AbbreviatedDayNames; + format.ShortestDayNames = names.ShortestDayNames; + format.MonthNames = names.MonthNames; + format.AbbreviatedMonthNames = names.AbbreviatedMonthNames; + format.MonthGenitiveNames = names.MonthGenitiveNames; + format.AbbreviatedMonthGenitiveNames = names.AbbreviatedMonthGenitiveNames; + return CultureInfo.ReadOnly(culture); + } + // CurrentCulture, not InvariantCulture: numbers and dates embedded into a // format string follow the OS regional format, independent of which UI language is active. public string Format(string key, params object[] args) => string.Format(CultureInfo.CurrentCulture, this[key], args); diff --git a/src/AiUsage/Services/PathSanitizer.cs b/src/AiUsage/Services/PathSanitizer.cs index f3fa410..0a30133 100644 --- a/src/AiUsage/Services/PathSanitizer.cs +++ b/src/AiUsage/Services/PathSanitizer.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Text.RegularExpressions; namespace AiUsage.Services; @@ -7,20 +8,30 @@ namespace AiUsage.Services; /// the log file or the crash dialog (no user paths, no credentials in /// diagnostic output). The profile path goes first, in its backslash, slash and JSON-escaped /// spellings; the bare account name and the profile's own folder name (they differ after an account -/// rename) are replaced only as whole words, so "Maximum" survives a user called "Max". The real -/// values are looked up lazily so a test can inject fakes instead. +/// rename) are replaced only as whole words, so "Maximum" survives a user called "Max". The 8.3 +/// short form of the profile path, which temp paths can carry, is treated the same way. The real values are looked up lazily so a test can inject fakes instead. /// public static class PathSanitizer { - public static string Sanitize(string text, string? userProfile = null, string? userName = null) + private static readonly Lazy RealShortProfile = new(() => + ShortPathOf(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))); + + public static string Sanitize(string text, string? userProfile = null, string? userName = null, string? shortProfile = null) { - userProfile ??= Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (userProfile is null) + { + userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + shortProfile ??= RealShortProfile.Value; + } userName ??= Environment.UserName; var names = new List(); - if (!string.IsNullOrEmpty(userProfile)) + foreach (var profile in new[] { userProfile, shortProfile }) { - var plain = userProfile.TrimEnd('\\', '/'); + if (string.IsNullOrEmpty(profile)) + continue; + + var plain = profile.TrimEnd('\\', '/'); foreach (var spelling in new[] { plain.Replace("\\", "\\\\", StringComparison.Ordinal), plain, plain.Replace('\\', '/') }) if (spelling.Length > 0) text = text.Replace(spelling, "", StringComparison.OrdinalIgnoreCase); @@ -34,4 +45,31 @@ public static string Sanitize(string text, string? userProfile = null, string? u return text; } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern unsafe uint GetShortPathNameW(string longPath, char* shortPath, uint bufferLength); + + /// The 8.3 spelling of , or null when it has none of its own. + private static unsafe string? ShortPathOf(string path) + { + if (string.IsNullOrEmpty(path) || !OperatingSystem.IsWindows()) + return null; + + try + { + var buffer = new char[path.Length + 1]; + uint length; + fixed (char* pointer = buffer) + length = GetShortPathNameW(path, pointer, (uint)buffer.Length); + if (length == 0 || length >= buffer.Length) + return null; + + var shortPath = new string(buffer, 0, (int)length); + return string.Equals(shortPath, path, StringComparison.OrdinalIgnoreCase) ? null : shortPath; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return null; + } + } } diff --git a/src/AiUsage/Services/PlanTier.cs b/src/AiUsage/Services/PlanTier.cs index dde0ffe..70bcb6f 100644 --- a/src/AiUsage/Services/PlanTier.cs +++ b/src/AiUsage/Services/PlanTier.cs @@ -29,6 +29,11 @@ private static readonly (string Token, string Name)[] Known = var tokens = Tokenize(raw); + if (tokens.Contains("max5x")) + return "Max 5x"; + if (tokens.Contains("max20x")) + return "Max 20x"; + var maxAt = tokens.IndexOf("max"); if (maxAt >= 0) { @@ -39,10 +44,6 @@ private static readonly (string Token, string Name)[] Known = if (i + 1 < tokens.Count && tokens[i + 1] is "5x" or "20x") return "Max " + tokens[i + 1]; } - if (tokens.Contains("max5x")) - return "Max 5x"; - if (tokens.Contains("max20x")) - return "Max 20x"; return "Max"; } diff --git a/src/AiUsage/Services/RefreshScheduler.cs b/src/AiUsage/Services/RefreshScheduler.cs index 2c18b6d..484045b 100644 --- a/src/AiUsage/Services/RefreshScheduler.cs +++ b/src/AiUsage/Services/RefreshScheduler.cs @@ -35,14 +35,10 @@ public sealed class RefreshScheduler private readonly object _gate = new(); private TimeSpan _baseInterval; - // Set by the most recent Tick/RefreshNow(all) call, read by RunOneAsync (directly, and via the - // single-provider RefreshNow below, which has no fresher information of its own to pass in) to - // resolve the interval a just-finished fetch schedules its next one at. The one-second timer - // keeps these within a second of the real window/tile state, so a manual single-provider refresh - // in between two ticks still resolves against an answer that is practically current. - // Which tiles are hidden, resolved by TickCore on the caller's (UI) thread: the delegate behind it - // reads view-model state that only that thread may touch, and a finished fetch resolves its next - // interval on a pool thread. + // Which tiles are hidden and whether the window shows, set by the most recent Tick/RefreshNow(all) + // on the caller's (UI) thread - the delegate behind them reads view-model state only that thread + // may touch - and read by RunOneAsync, often on a pool thread, to resolve the interval a finished + // fetch schedules its next one at. The one-second timer keeps them practically current. private Dictionary _hiddenByAccount = []; private bool _windowVisible = true; @@ -60,7 +56,7 @@ public sealed class RefreshScheduler /// /// / default to the real /// 60s/5min floor and cap; a test can inject a much shorter pair so a "provider never completes" - /// scenario does not have to wait out a real 60 real seconds - the timeout itself always fires on + /// scenario does not have to wait out 60 real seconds - the timeout itself always fires on /// the real clock (CancellationTokenSource has no fake-time hook), only the configured duration is /// test-controlled. receives one line for each subscriber that throws. /// diff --git a/src/AiUsage/Services/StatusFileWriter.cs b/src/AiUsage/Services/StatusFileWriter.cs index 1545abf..37aa950 100644 --- a/src/AiUsage/Services/StatusFileWriter.cs +++ b/src/AiUsage/Services/StatusFileWriter.cs @@ -30,6 +30,7 @@ public sealed class StatusFileWriter : IDisposable private readonly Action? _log; private readonly TimeSpan _debounce; private readonly Lock _gate = new(); + private readonly Lock _writeGate = new(); private readonly Timer _timer; private string? _pending; private bool _disposed; @@ -62,15 +63,20 @@ public void Schedule(string json) /// Writes the pending document now, if there is one. public void Flush() { - string? json; - lock (_gate) + // Taking and writing happen under one lock, so a timer write still retrying an older document + // can never land after the final one written at exit. + lock (_writeGate) { - json = _pending; - _pending = null; - } + string? json; + lock (_gate) + { + json = _pending; + _pending = null; + } - if (json is not null) - TryWrite(_pathProvider(), json, _log); + if (json is not null) + TryWrite(_pathProvider(), json, _log); + } } public void Dispose() diff --git a/src/AiUsage/Services/TrayIconRenderer.cs b/src/AiUsage/Services/TrayIconRenderer.cs index 545a588..7a18209 100644 --- a/src/AiUsage/Services/TrayIconRenderer.cs +++ b/src/AiUsage/Services/TrayIconRenderer.cs @@ -22,8 +22,8 @@ public static class TrayIconRenderer private static readonly Color WarnColor = Color.FromArgb(0xB4, 0x53, 0x09); private static readonly Color CritColor = Color.FromArgb(0xBE, 0x12, 0x3C); - /// The fixed background colour for a level - not itself a theme lookup, see the class - /// doc. + /// The fixed background colour for a level - not a theme lookup, so the icon reads the + /// same on every taskbar. internal static Color BackgroundColor(UsageLevel level) => level switch { UsageLevel.Crit => CritColor, diff --git a/src/AiUsage/Services/TrayService.cs b/src/AiUsage/Services/TrayService.cs index bdce383..c23e6f5 100644 --- a/src/AiUsage/Services/TrayService.cs +++ b/src/AiUsage/Services/TrayService.cs @@ -11,8 +11,8 @@ namespace AiUsage.Services; /// Tray icon and its context menu, built directly on Shell_NotifyIcon (shell32) instead of /// a UI framework's own wrapper - a hidden message window receives the shell callback, and the /// right-click menu is a plain WPF themed the same way as every other -/// popup in this app. Not unit-testable (a live shell tray icon needs a real desktop -/// session) - only 's pure text-building is tested. +/// popup in this app. The live shell icon itself needs a real desktop session, so the tests cover the +/// menu, the tooltip text and the rendered icons around it. /// public sealed class TrayService : IDisposable { @@ -123,6 +123,13 @@ private struct NOTIFYICONDATA // for real. private bool _readyForContextMenu; + // At sign-in the shell can refuse the icon after its TaskbarCreated broadcast has already gone out, + // which would leave an autostarted copy with neither a window nor an icon. + private static readonly TimeSpan AddRetryInterval = TimeSpan.FromSeconds(2); + private const int MaxAddRetries = 30; + private DispatcherTimer? _addRetryTimer; + private int _addRetries; + /// Tells whether the widget is on screen right now; read each time the menu opens to /// word the first entry "hide" or "show". Null counts as visible. public Func? WidgetVisible { get; set; } @@ -221,7 +228,7 @@ private static void RefreshMenuText(MenuParts parts, string? hotkeyShortcutText, { var loc = LocalizationService.Instance; parts.ShowHide.Header = loc[widgetVisible ? "Tray.Hide" : "Tray.Show"]; - parts.ShowHide.InputGestureText = hotkeyShortcutText ?? ""; + parts.ShowHide.InputGestureText = GlobalHotkey.FormatForDisplay(hotkeyShortcutText); parts.Refresh.Header = loc["Action.RefreshNow"]; parts.Pause.Header = pausedUntil is { } until ? loc.Format("Tray.Resume", TrayTooltipBuilder.ShortTime(until)) @@ -440,11 +447,46 @@ public void ShowBalloon(string text) } private void AddIcon() + { + if (TryAddIcon()) + { + StopAddRetries(); + return; + } + + if (_addRetryTimer is not null) + return; + _addRetries = 0; + _addRetryTimer = new DispatcherTimer(DispatcherPriority.Background, _hwndSource.Dispatcher) { Interval = AddRetryInterval }; + _addRetryTimer.Tick += (_, _) => + { + if (TryAddIcon()) + { + StopAddRetries(); + return; + } + if (++_addRetries >= MaxAddRetries) + { + StopAddRetries(); + LogService.Shared.LogError("Tray icon: the shell did not accept the icon."); + } + }; + _addRetryTimer.Start(); + } + + /// Adds the icon, or updates it when the shell already holds one under this id. + private bool TryAddIcon() { var data = NewNotifyIconData(NIF_MESSAGE | NIF_ICON | NIF_TIP); data.hIcon = (_currentIcon ?? _okIcon).Handle; data.szTip = _tooltipText; - Shell_NotifyIcon(NIM_ADD, ref data); + return Shell_NotifyIcon(NIM_ADD, ref data) || Shell_NotifyIcon(NIM_MODIFY, ref data); + } + + private void StopAddRetries() + { + _addRetryTimer?.Stop(); + _addRetryTimer = null; } private NOTIFYICONDATA NewNotifyIconData(uint flags) => new() @@ -478,6 +520,7 @@ private static (Icon Icon, MemoryStream Stream) LoadIcon(string fileName) public void Dispose() { LocalizationService.Instance.PropertyChanged -= _localizationChanged; + StopAddRetries(); var data = NewNotifyIconData(0); Shell_NotifyIcon(NIM_DELETE, ref data); diff --git a/src/AiUsage/Stats/ClaudeUsageLogParser.cs b/src/AiUsage/Stats/ClaudeUsageLogParser.cs index d6779a8..5b2b218 100644 --- a/src/AiUsage/Stats/ClaudeUsageLogParser.cs +++ b/src/AiUsage/Stats/ClaudeUsageLogParser.cs @@ -165,7 +165,8 @@ public static string FallbackSessionId(string filePath) : null; private static long GetLong(JsonElement parent, string propertyName) => - parent.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number) + parent.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Number + && value.TryGetInt64(out var number) && number >= 0 ? number : 0; } diff --git a/src/AiUsage/Stats/CodexUsageLogParser.cs b/src/AiUsage/Stats/CodexUsageLogParser.cs index 6bbe2f1..bdd7fb3 100644 --- a/src/AiUsage/Stats/CodexUsageLogParser.cs +++ b/src/AiUsage/Stats/CodexUsageLogParser.cs @@ -116,14 +116,14 @@ public bool TryParseLine(string line, out UsageEvent result) // A line missing any of the three base 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. - if (JsonReading.TryGetInt64(total, "input_tokens") is not { } input - || JsonReading.TryGetInt64(total, "output_tokens") is not { } output - || JsonReading.TryGetInt64(total, "cached_input_tokens") is not { } cacheRead) + if (Count(total, "input_tokens") is not { } input + || Count(total, "output_tokens") is not { } output + || Count(total, "cached_input_tokens") is not { } cacheRead) return false; // Older Codex versions write no cache-write total at all; requiring it would drop every // line of their logs. Where it is missing the previous total stands, so it adds nothing. - var cacheCreation = JsonReading.TryGetInt64(total, "cache_write_input_tokens") ?? _cumulativeCacheCreation; + var cacheCreation = Count(total, "cache_write_input_tokens") ?? _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 @@ -220,4 +220,8 @@ public bool TryParseLine(string line, out UsageEvent result) /// back to zero (a context compaction, or a fresh file), so the new value is counted as this /// event's whole delta rather than being clamped to zero and lost. private static long CounterDelta(long previous, long current) => current < previous ? current : current - previous; + + /// A running total as a count; a negative value is no count at all and reads as missing. + private static long? Count(JsonElement parent, string propertyName) => + JsonReading.TryGetInt64(parent, propertyName) is { } value && value >= 0 ? value : null; } diff --git a/src/AiUsage/Stats/ProjectColorResolver.cs b/src/AiUsage/Stats/ProjectColorResolver.cs index 86017d7..7a7dd1e 100644 --- a/src/AiUsage/Stats/ProjectColorResolver.cs +++ b/src/AiUsage/Stats/ProjectColorResolver.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Windows.Media; using System.Windows.Media.Imaging; +using AiUsage.Io; namespace AiUsage.Stats; @@ -27,6 +28,9 @@ namespace AiUsage.Stats; /// public static class ProjectColorResolver { + /// The color cache's file name, in the machine-local cache folder. + internal const string CacheFileName = "project-colors.json"; + // Raised whenever the icon search changes what it finds, so colors computed from an older find are // recomputed once. private const int CacheVersion = 2; @@ -731,7 +735,7 @@ private static void SaveCache(string cacheFilePath, DictionaryOne row of a grouped result: a label (a day, an ISO week, a model or a project name) /// and its token total, broken into stacked segments for the chart - one segment per provider for /// day/week grouping, a single segment for model/project grouping (a model or project already -/// belongs to exactly one provider, so stacking would add nothing there). -public readonly record struct StatsGroupedRow(string Label, IReadOnlyList StackedValues, long Total); +/// belongs to exactly one provider, so stacking would add nothing there). +/// is what the row is grouped by when that differs from its label (the records +/// without a project); null otherwise. +public readonly record struct StatsGroupedRow(string Label, IReadOnlyList StackedValues, long Total, string? Key = null); /// A day or week chart stacked by model: the model names in stacking order (largest /// first, the pooled remainder last when ) and one row per period whose stacked @@ -102,8 +104,10 @@ public readonly record struct StatsHeadlineFigures( /// cref="ShareByModel"/>, which is the one caller that can name a single provider a model belongs /// to - the provider ring's own slices are already one provider each, and the pooled entry belongs /// to none. is only filled in by 's hour list, so a -/// caller can put the bars back in clock order whatever the label looks like. -public readonly record struct StatsShareSlice(string Label, long Total, double Percent, string ProviderId = "", int Hour = -1); +/// caller can put the bars back in clock order whatever the label looks like. +/// marks the entry that pools what has no name of its own (the records without +/// a project, the models past the top ones), so it is told apart without comparing its label. +public readonly record struct StatsShareSlice(string Label, long Total, double Percent, string ProviderId = "", int Hour = -1, bool IsPooled = false); /// One "top projects" row. First/LastActivity are scoped to the requested period; the token split, ProviderIds and MainModel feed only the hover tooltip; Color and IconPath are filled in by StatsViewModel. public readonly record struct StatsProjectRow( @@ -118,7 +122,9 @@ public readonly record struct StatsProjectRow( // change again. int? SessionCount = null, // A row that brings its own hover text (a session) shows it instead of the project lines. - string? TooltipText = null); + string? TooltipText = null, + // The row that pools the records without a project; its FullPath is the display label. + bool IsNoProject = false); /// The month grid's own day-detail panel: one day's usage broken four ways, every list /// already sorted by total, descending. is the one exception to "only entries @@ -153,8 +159,8 @@ public readonly record struct StatsCacheShareRow(long NewInput, long CacheWrite, /// public static class StatsAggregator { - /// Fixed stacking order for day/week grouping - the only two providers a - /// can ever name, always in the same order so a bar's colors + /// Fixed stacking order for day/week grouping - every provider a + /// can name, always in the same order so a bar's colors /// never swap between two consecutive periods. public static readonly IReadOnlyList StackedProviderOrder = ProviderCoverage.IndexedProviderIds; @@ -171,12 +177,13 @@ public static IReadOnlyList Group( { StatsGrouping.Day => GroupByDay(records, rangeStart, rangeEnd), StatsGrouping.Week => GroupByWeek(records, rangeStart, rangeEnd), - StatsGrouping.Weekday => GroupByWeekday(records, CultureInfo.CurrentCulture), + StatsGrouping.Weekday => GroupByWeekday(records, Services.LocalizationService.Instance.DisplayCulture), StatsGrouping.Hour => GroupByHour(records, CultureInfo.CurrentCulture), - StatsGrouping.WeekdayHour => GroupByWeekday(records, CultureInfo.CurrentCulture), + StatsGrouping.WeekdayHour => GroupByWeekday(records, Services.LocalizationService.Instance.DisplayCulture), StatsGrouping.Model => GroupBySingleValueKey(records, record => ModelDisplayNames.Resolve(record.Model)), - StatsGrouping.Project => GroupBySingleValueKey( - records, record => string.IsNullOrEmpty(record.Project) ? noProjectLabel : ProjectKey(record.Project), StringComparer.OrdinalIgnoreCase), + StatsGrouping.Project => GroupBySingleValueKey(records, record => ProjectGroupKey(record.Project), StringComparer.OrdinalIgnoreCase) + .Select(row => row.Label == NoProjectKey ? row with { Label = noProjectLabel, Key = NoProjectKey } : row) + .ToList(), StatsGrouping.Effort => GroupBySingleValueKey(records, record => record.Effort), _ => [], }; @@ -630,7 +637,7 @@ public static IReadOnlyList ShareByModel(IReadOnlyList entry.Total); if (pooledTotal > 0) - slices.Add(new StatsShareSlice(otherLabel, pooledTotal, Percent(pooledTotal))); + slices.Add(new StatsShareSlice(otherLabel, pooledTotal, Percent(pooledTotal), IsPooled: true)); return slices; } @@ -653,11 +660,13 @@ public static IReadOnlyList TopProjects(IReadOnlyList record.TotalTokens); return records - .GroupBy(record => string.IsNullOrEmpty(record.Project) ? noProjectLabel : ProjectKey(record.Project), StringComparer.OrdinalIgnoreCase) - .Select(group => (Label: group.Key, Total: group.Sum(record => record.TotalTokens))) + .GroupBy(record => ProjectGroupKey(record.Project), StringComparer.OrdinalIgnoreCase) + .Select(group => (Key: group.Key, Total: group.Sum(record => record.TotalTokens))) .OrderByDescending(entry => entry.Total) .Take(limit) - .Select(entry => new StatsShareSlice(entry.Label, entry.Total, grandTotal > 0 ? entry.Total * 100.0 / grandTotal : 0)) + .Select(entry => new StatsShareSlice( + entry.Key == NoProjectKey ? noProjectLabel : entry.Key, entry.Total, grandTotal > 0 ? entry.Total * 100.0 / grandTotal : 0, + IsPooled: entry.Key == NoProjectKey)) .ToList(); } @@ -674,7 +683,7 @@ public static IReadOnlyList TopProjectsDetailed(IReadOnlyList record.TotalTokens); return records - .GroupBy(record => string.IsNullOrEmpty(record.Project) ? noProjectLabel : ProjectKey(record.Project), StringComparer.OrdinalIgnoreCase) + .GroupBy(record => ProjectGroupKey(record.Project), StringComparer.OrdinalIgnoreCase) .Select(group => ( FullPath: group.Key, Total: group.Sum(record => record.TotalTokens), @@ -698,8 +707,8 @@ public static IReadOnlyList TopProjectsDetailed(IReadOnlyList entry.Total) .Take(limit) .Select(entry => new StatsProjectRow( - MiddleEllipsis(ShortenProjectLabel(entry.FullPath), ProjectLabelMaxChars), - entry.FullPath, + entry.FullPath == NoProjectKey ? noProjectLabel : MiddleEllipsis(ShortenProjectLabel(entry.FullPath), ProjectLabelMaxChars), + entry.FullPath == NoProjectKey ? noProjectLabel : entry.FullPath, entry.Total, grandTotal > 0 ? entry.Total * 100.0 / grandTotal : 0, entry.First, @@ -708,7 +717,8 @@ public static IReadOnlyList TopProjectsDetailed(IReadOnlyList TopProjectsDetailed(IReadOnlyList.tmp, .worktrees, .claude\worktrees), so everything from the first such /// folder on is cut off and the work counts for the project itself. Trailing separators are /// trimmed so the same folder always yields the same key. + /// What the records without a project are grouped under: a value no folder can have, so a + /// project whose name happens to read like the translated "no project" label stays its own row. + public const string NoProjectKey = "\u0001"; + + internal static string ProjectGroupKey(string? project) => + string.IsNullOrEmpty(project) ? NoProjectKey : ProjectKey(project); + internal static string ProjectKey(string project) { var match = AgentWorkFolder.Match(project); @@ -932,10 +949,11 @@ public static StatsDayDetail DayDetail(IReadOnlyList records, DateO .ToList(); var byProject = dayRecords - .GroupBy(record => string.IsNullOrEmpty(record.Project) ? noProjectLabel : ProjectKey(record.Project), StringComparer.OrdinalIgnoreCase) - .Select(group => (Label: group.Key, Total: group.Sum(record => record.TotalTokens))) + .GroupBy(record => ProjectGroupKey(record.Project), StringComparer.OrdinalIgnoreCase) + .Select(group => (Key: group.Key, Total: group.Sum(record => record.TotalTokens))) .OrderByDescending(entry => entry.Total) - .Select(entry => new StatsShareSlice(entry.Label, entry.Total, Percent(entry.Total))) + .Select(entry => new StatsShareSlice( + entry.Key == NoProjectKey ? noProjectLabel : entry.Key, entry.Total, Percent(entry.Total), IsPooled: entry.Key == NoProjectKey)) .ToList(); var byHourTotals = dayRecords.GroupBy(record => record.Hour).ToDictionary(group => group.Key, group => group.Sum(record => record.TotalTokens)); diff --git a/src/AiUsage/Stats/StatsIndexer.cs b/src/AiUsage/Stats/StatsIndexer.cs index 4750780..2f638c9 100644 --- a/src/AiUsage/Stats/StatsIndexer.cs +++ b/src/AiUsage/Stats/StatsIndexer.cs @@ -140,6 +140,10 @@ internal static List ClaudeProjectsRootsFor(string? configDirValue, stri // one run counts as one blocked run). private readonly Dictionary _passHeld = []; + // Whether this walk's snapshot of remembered files was read, and the Codex file names it holds. + private bool _knownComplete; + private HashSet? _knownCodexFileNames; + public StatsIndexResult IndexOnce(CancellationToken cancellationToken = default) { _failedWrites = 0; @@ -151,8 +155,13 @@ public StatsIndexResult IndexOnce(CancellationToken cancellationToken = default) var claudeFoldersSkipped = 0; // One query for every remembered file instead of one database open per file: most walks find - // thousands of files and change none of them. - var known = _store.LoadSourceFiles(); + // thousands of files and change none of them. Once it is read, a file missing from it is + // new, so the walk never asks the index about each new file on its own. + _knownComplete = _store.TryLoadSourceFiles(out var known); + _knownCodexFileNames = _knownComplete + ? known.Values.Where(state => state.Provider == CodexProviderId) + .Select(state => Path.GetFileName(state.Path)).ToHashSet(StringComparer.OrdinalIgnoreCase) + : null; // Before the walk: the walk counts a response's fullest copy from the first line it reads, so // a pass that ran after it would add the same tokens to files the walk just read for the @@ -190,6 +199,9 @@ public StatsIndexResult IndexOnce(CancellationToken cancellationToken = default) claudeFoldersSkipped += foldersSkipped; } + // Two configured roots can overlap; a file new to the index is only ever read once per walk. + claudeFiles = [.. claudeFiles.DistinctBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)]; + // A file new to the index is read after the files it may repeat: the first transcript to count // a response owns it, and a resumed session copies its parent's history. Once every file is // known only appended parts are read and the order no longer matters. @@ -226,6 +238,7 @@ public StatsIndexResult IndexOnce(CancellationToken cancellationToken = default) codexFoldersSkipped += archivedFoldersSkipped; } + codexFiles = [.. codexFiles.DistinctBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)]; foreach (var listed in codexFiles) { cancellationToken.ThrowIfCancellationRequested(); @@ -592,7 +605,8 @@ public ClaudeCopyTracker(StatsSourceFileState? existing) // is taken over so the file resumes where it stood instead of being counted from the start. if (!TryFindState(path, known, out var existing)) return (false, 0, 0); - existing ??= _store.TryAdoptMovedSourceFile(path, CodexProviderId); + if (existing is null && (_knownCodexFileNames is null || _knownCodexFileNames.Contains(listed.Name))) + existing = _store.TryAdoptMovedSourceFile(path, CodexProviderId); if (existing is not null && existing.MatchesOnDisk(info.Length, info.LastWriteTimeUtc, _clock())) return (false, 0, 0); @@ -1345,13 +1359,15 @@ private static string ProbeClaudeProject(string path, string fallback, Cancellat return fallback; } - /// The remembered state from this walk's snapshot, else straight from the index (a file - /// first seen during this walk). False when the index could not be read: the file is then skipped - /// until the next walk rather than read again from the start. + /// The remembered state from this walk's snapshot; a file the snapshot lacks is new. Only + /// when the snapshot could not be read is the index asked directly. False when that fails too: + /// the file is then skipped until the next walk rather than read again from the start. private bool TryFindState(string path, Dictionary known, out StatsSourceFileState? state) { if (known.TryGetValue(path, out state)) return true; + if (_knownComplete) + return true; return _store.TryGetSourceFile(path, out state); } diff --git a/src/AiUsage/Stats/StatsIndexerService.cs b/src/AiUsage/Stats/StatsIndexerService.cs index 171a8df..b091d27 100644 --- a/src/AiUsage/Stats/StatsIndexerService.cs +++ b/src/AiUsage/Stats/StatsIndexerService.cs @@ -184,6 +184,7 @@ public void StartInBackground() return; _isRunning = true; + _cancellation?.Dispose(); _cancellation = new CancellationTokenSource(); var token = _cancellation.Token; _thread = new Thread(() => RunSafely(token)) diff --git a/src/AiUsage/Stats/StatsSnapshotFile.cs b/src/AiUsage/Stats/StatsSnapshotFile.cs index 18b5071..a8bf906 100644 --- a/src/AiUsage/Stats/StatsSnapshotFile.cs +++ b/src/AiUsage/Stats/StatsSnapshotFile.cs @@ -14,7 +14,6 @@ namespace AiUsage.Stats; /// internal static class StatsSnapshotFile { - /// The name of the file. internal const string FileName = "stats-snapshot.json"; /// Bumped whenever the shape of a record changes; a file with another number is dropped. diff --git a/src/AiUsage/Stats/StatsStore.cs b/src/AiUsage/Stats/StatsStore.cs index 35135ab..0bb790f 100644 --- a/src/AiUsage/Stats/StatsStore.cs +++ b/src/AiUsage/Stats/StatsStore.cs @@ -31,21 +31,29 @@ public sealed class StatsStore /// The data folder of a test store; null for the production store. internal string? FixedDirectory => _fixedDirectory; - private static string DatabasePathIn(string dataDirectory) => Path.Combine(dataDirectory, "stats.db"); + /// The index's file name inside the data folder. + internal const string DatabaseFileName = "stats.db"; + + private static string DatabasePathIn(string dataDirectory) => Path.Combine(dataDirectory, DatabaseFileName); private readonly int _busyTimeoutMs; + // Where a damaged index is reported; the production store writes to the app log. + private readonly Action? _logError; + public StatsStore() { _busyTimeoutMs = 2000; + _logError = message => Services.LogService.Shared.LogError(message); } /// Test seam: a temp directory instead of the real data folder, and optionally a /// shorter busy timeout than the production default of 2000 ms - see /// - so a test that deliberately holds the write lock does not have to wait out the real one. - internal StatsStore(string dataDirectory, int busyTimeoutMs = 2000) + internal StatsStore(string dataDirectory, int busyTimeoutMs = 2000, Action? logError = null) { _fixedDirectory = dataDirectory; _busyTimeoutMs = busyTimeoutMs; + _logError = logError; } /// Copies the index from one data folder into another through SQLite's own backup, which @@ -533,6 +541,12 @@ private SqliteConnection Open() EnsuredDatabases[fullPath] = true; } } + catch (SqliteException ex) when (ex.SqliteErrorCode is SqliteCorrupt or SqliteNotADatabase) + { + connection.Dispose(); + QuarantineDamagedDatabase(connection, fullPath, ex.SqliteErrorCode); + throw; + } catch { // The caller never sees this connection, so it would otherwise keep the file open. @@ -543,6 +557,32 @@ private SqliteConnection Open() return connection; } + private const int SqliteCorrupt = 11; + private const int SqliteNotADatabase = 26; + + /// A damaged index never heals and would leave the statistics empty for good. It is set + /// aside (one copy is kept for inspection) so the next walk rebuilds it from the session logs. + /// Best effort: a file another process still holds stays where it is until the next attempt. + private void QuarantineDamagedDatabase(SqliteConnection connection, string fullPath, int errorCode) + { + SqliteConnection.ClearPool(connection); + EnsuredDatabases.TryRemove(fullPath, out _); + try + { + var directory = Path.GetDirectoryName(fullPath)!; + foreach (var older in Directory.EnumerateFiles(directory, DatabaseFileName + ".corrupt-*")) + File.Delete(older); + File.Move(fullPath, $"{fullPath}.corrupt-{DateTime.Now:yyyyMMdd-HHmmss}"); + foreach (var sidecar in new[] { fullPath + "-wal", fullPath + "-shm", fullPath + "-journal" }) + File.Delete(sidecar); + _logError?.Invoke($"Statistics: the index was damaged (SQLite error {errorCode}) and is rebuilt from the session logs."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logError?.Invoke($"Statistics: the index is damaged (SQLite error {errorCode}) and could not be set aside ({ex.GetType().Name})."); + } + } + // Database files whose schema this process already brought up to date; later opens (the indexer // opens once per file) skip the schema work. Files a newer build wrote are never listed. private static readonly ConcurrentDictionary EnsuredDatabases = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/AiUsage/Stats/StatsViewModel.cs b/src/AiUsage/Stats/StatsViewModel.cs index 5ed960e..8eece79 100644 --- a/src/AiUsage/Stats/StatsViewModel.cs +++ b/src/AiUsage/Stats/StatsViewModel.cs @@ -184,9 +184,9 @@ private void UpdateCustomChoiceLabel() return; custom.Label = SelectedRange == CustomRange - ? string.Format( - CultureInfo.CurrentCulture, "{0:d} - {1:d}", - _customFrom.ToDateTime(TimeOnly.MinValue), _customTo.ToDateTime(TimeOnly.MinValue)) + ? LocalizationService.Instance.Format( + "Stats.Range.CustomLabel", + _customFrom.ToString("d", CultureInfo.CurrentCulture), _customTo.ToString("d", CultureInfo.CurrentCulture)) : LocalizationService.Instance["Stats.Range.Custom"]; } @@ -273,12 +273,12 @@ private void SyncProviderOptions() public bool HasAnyFilter => HasModelFilter || HasProjectFilter; - /// The filter chip's text, for example "Model: Opus 4.5". /// The session table's combined "Project / Model" column header. [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Bound from XAML, which needs an instance property.")] public string SessionProjectModelHeader => LocalizationService.Instance.Format( "Format.Pair", LocalizationService.Instance["Stats.Col.Project"], LocalizationService.Instance["Stats.Col.Model"]); + /// The filter chip's text, for example "Model: Opus 4.5". public string ModelFilterText => LocalizationService.Instance.Format("Stats.Filter.Model", SelectedModelFilter); public string ProjectFilterText => LocalizationService.Instance.Format( @@ -290,7 +290,7 @@ private void SyncProviderOptions() /// What holds for the records that carry no project: a /// value no folder can have, so the filter keeps matching after the display language (and with it /// the "no project" wording) changes. - internal const string NoProjectFilterKey = "\u0001"; + internal const string NoProjectFilterKey = StatsAggregator.NoProjectKey; /// True while the breakdown chart draws one bar per model or project, so a click on a bar /// can filter. @@ -518,7 +518,7 @@ private void SyncProviderOptions() [ObservableProperty] private IReadOnlyList cacheShareLabels = []; - /// The "top projects" panel - already sorted longest first, capped at ten rows. + /// The "top projects" panel - already sorted longest first, capped at twelve rows. [ObservableProperty] private IReadOnlyList topProjectRows = []; @@ -628,7 +628,7 @@ private void ResolveLimitProvider() /// The month grid's own input - every day from the grid's start up to today (local time): /// the last twelve months, reaching further back on a window wide enough to show more weeks than /// that at the normal cell size. Independent of and alike, each - /// carrying every provider that contributed to it (across all five, not just the two stacks) and whichever led, largest first. [ObservableProperty] private IReadOnlyList monthGridDays = []; @@ -757,15 +757,14 @@ private void RebuildSelectedDayDetail() // The day's projects that have no resolved color yet are resolved in the background; the // detail is built again once they arrive (see OnProjectColorsChanged). - var noProject = LocalizationService.Instance["Stats.NoProject"]; - _dayProjectKeys = detail.ByProject.Select(slice => slice.Label).Where(key => key != noProject).ToList(); + _dayProjectKeys = detail.ByProject.Where(slice => !slice.IsPooled).Select(slice => slice.Label).ToList(); var unrequested = _dayProjectKeys.Where(key => !ProjectColors.ContainsKey(key) && _requestedDayProjectKeys.Add(key)).ToList(); if (unrequested.Count > 0) RefreshProjectColors(TopProjectRows, CurrentBarProjectKeys()); var loc = LocalizationService.Instance; var dayTotal = detail.ByProvider.Sum(slice => slice.Total); - var dateText = day.ToDateTime(TimeOnly.MinValue).ToString("D", CultureInfo.CurrentCulture); + var dateText = day.ToDateTime(TimeOnly.MinValue).ToString("D", LocalizationService.Instance.DisplayCulture); var shortened = StatsAggregator.ShortenTokenCount(dayTotal, loc["Number.Thousand"], loc["Number.Million"], loc["Number.Billion"]); SelectedDayHeadText = string.Format(CultureInfo.CurrentCulture, loc["Stats.DayDetail.Title"], $"{dateText} · {shortened} {loc["Stats.TokenUnit"]}"); } @@ -781,7 +780,7 @@ private void RebuildSelectedDayDetail() private readonly HashSet _requestedDayProjectKeys = []; private List CurrentBarProjectKeys() => SelectedGrouping == StatsGrouping.Project - ? Bars.Select(bar => bar.ColorProviderId).Where(key => key.Length > 0 && key != OtherProjectsColorKey).ToList() + ? Bars.Select(bar => bar.ColorProviderId).Where(key => key.Length > 0 && key != OtherProjectsColorKey && key != NoProjectFilterKey).ToList() : []; partial void OnProjectColorsChanged(IReadOnlyDictionary value) @@ -814,7 +813,7 @@ private void RefreshProjectColors(IReadOnlyList rows, IReadOnly [ObservableProperty] private IReadOnlyDictionary projectColors = new Dictionary(); - private const string ProjectColorsFileName = "project-colors.json"; + private const string ProjectColorsFileName = ProjectColorResolver.CacheFileName; /// Where the project color cache lives: the machine-local cache folder, or the folder of a /// store that is pinned to one (a test). @@ -855,7 +854,7 @@ private async Task RefreshProjectColorsAsync(IReadOnlyList rows { var generation = ++_projectColorGeneration; var dayKeys = _dayProjectKeys; - var folders = rows.Select(row => row.FullPath).Concat(barProjects).Concat(dayKeys).Distinct().ToList(); + var folders = rows.Where(row => !row.IsNoProject).Select(row => row.FullPath).Concat(barProjects).Concat(dayKeys).Distinct().ToList(); if (folders.Count == 0) return; @@ -926,11 +925,13 @@ private static List ToDayProjectRows( slices .Select(slice => { - var shortened = StatsAggregator.MiddleEllipsis(StatsAggregator.ShortenProjectLabel(slice.Label), StatsAggregator.ProjectLabelMaxChars); - var color = projectColors.TryGetValue(slice.Label, out var resolved) && resolved != default + var shortened = slice.IsPooled + ? slice.Label + : StatsAggregator.MiddleEllipsis(StatsAggregator.ShortenProjectLabel(slice.Label), StatsAggregator.ProjectLabelMaxChars); + var color = !slice.IsPooled && projectColors.TryGetValue(slice.Label, out var resolved) && resolved != default ? resolved : ChartPalette.ForProvider(slice.Label); - return new StatsProjectRow(shortened, slice.Label, slice.Total, slice.Percent, day, day, Color: color); + return new StatsProjectRow(shortened, slice.Label, slice.Total, slice.Percent, day, day, Color: color, IsNoProject: slice.IsPooled); }) .ToList(); @@ -1113,10 +1114,6 @@ public void ToggleFilter(StatsFilterKind kind, string key) SelectedModelFilter = SelectedModelFilter == key ? "" : key; break; case StatsFilterKind.Project: - // The rows and bars name the no-project group in the display language; the filter - // keeps the neutral key. - if (key == LocalizationService.Instance["Stats.NoProject"]) - key = NoProjectFilterKey; SelectedProjectFilter = string.Equals(SelectedProjectFilter, key, StringComparison.OrdinalIgnoreCase) ? "" : key; break; default: @@ -1142,7 +1139,8 @@ public void ActivateModelSlice(int index) return; var slice = ModelShareSlices[index]; - if (slice.Label != LocalizationService.Instance["Stats.Other"]) + // Every real model slice names the provider it belongs to; the pooled one names none. + if (slice.ProviderId.Length > 0) ToggleFilter(StatsFilterKind.Model, slice.Label); } @@ -1150,7 +1148,7 @@ public void ActivateModelSlice(int index) public void ActivateTopProject(int index) { if (index >= 0 && index < TopProjectRows.Count) - ToggleFilter(StatsFilterKind.Project, TopProjectRows[index].FullPath); + ToggleFilter(StatsFilterKind.Project, TopProjectRows[index].IsNoProject ? NoProjectFilterKey : TopProjectRows[index].FullPath); } [RelayCommand] @@ -1422,7 +1420,7 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs var rest = groupedRows.Skip(MaxProjectBars).ToList(); var restTotal = rest.Sum(row => row.Total); barRows = groupedRows.Take(MaxProjectBars) - .Append(new StatsGroupedRow(LocalizationService.Instance["Stats.OtherProjects"], [restTotal], restTotal)).ToList(); + .Append(new StatsGroupedRow(LocalizationService.Instance["Stats.OtherProjects"], [restTotal], restTotal, Key: "")).ToList(); barKeys = barColorKeys.Take(MaxProjectBars).Append(new BarColorKey(OtherProjectsColorKey, 0)).ToList(); otherTooltipLine = BuildOtherProjectsTooltip(rest); } @@ -1475,12 +1473,10 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs StatsGrouping.Project => StatsFilterKind.Project, _ => StatsFilterKind.None, }; - var otherProjectsLabel = LocalizationService.Instance["Stats.OtherProjects"]; BarsFilterable = filterKind != StatsFilterKind.None && !IsModelStack && !IsAgentStack; + // The bundled "other projects" bar carries an empty key and names nothing to filter by. _barTargets = BarsFilterable - ? barRows.Select(row => row.Label == otherProjectsLabel && filterKind == StatsFilterKind.Project - ? ((StatsFilterKind Kind, string Key)?)null - : (filterKind, row.Label)).ToList() + ? barRows.Select(row => row.Key is "" ? ((StatsFilterKind Kind, string Key)?)null : (filterKind, row.Key ?? row.Label)).ToList() : []; Bars = barRows.Select((row, index) => new Views.Controls.StatsBarChart.Bar( @@ -1489,7 +1485,7 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs // The month grid's own by-day list: the last twelve months up to today (local time), or further // back for a wide window (see MonthGridAvailableWidth), never the selected range or grouping - - // stacked by all five providers rather than just the two + // stacked by all five providers rather than just the ones // StackedProviderOrder names, since any of them can lead a day on this grid. Built by // StatsMonthGridBuilder, the one place this computation lives - the widget's own day-grid tile // (DayGridTileViewModel) reads the same store through the same builder, so the two never @@ -1508,6 +1504,8 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs if (SelectedGrouping != StatsGrouping.Project) return new StatsRowViewModel(row.Label, row.Total, totalText); + if (row.Key == NoProjectFilterKey) + return new StatsRowViewModel(row.Label, row.Total, totalText, FilterKind: StatsFilterKind.Project, FilterKey: NoProjectFilterKey); var shortened = StatsAggregator.MiddleEllipsis(StatsAggregator.ShortenProjectLabel(row.Label), StatsAggregator.ProjectLabelMaxChars); return new StatsRowViewModel(shortened, row.Total, totalText, ToolTip: row.Label, FilterKind: StatsFilterKind.Project, FilterKey: row.Label); }) @@ -1547,7 +1545,7 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs if (held is null || !IsFresh(held)) ScheduleLimitRefresh(source, from, to); - // The two donut charts - provider stays in the fixed StackedProviderOrder (same color + // The share charts - provider stays in the fixed StackedProviderOrder (same color // convention the stacked charts already use), model is sorted by size with everything past // the largest six pooled into one "Other"/"Andere" entry. var million = loc["Number.Million"]; @@ -1573,7 +1571,7 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs ModelShareSlices = modelShares .Select(slice => new Views.Controls.StatsRingChart.Slice( slice.Label, slice.Percent, slice.ProviderId, Total: slice.Total, - Tooltip: RingTooltip(slice.Label, slice, loc, slice.Label == otherLabel && slice.ProviderId.Length == 0 ? pooledModels : null))) + Tooltip: RingTooltip(slice.Label, slice, loc, slice.IsPooled ? pooledModels : null))) .ToList(); // The "share per effort level" ring, beside the model ring. ProviderId here carries the raw @@ -1599,12 +1597,10 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs : ""; SubagentShareCaption = loc["Stats.SubagentShare.Caption"]; - // The cache-share bar. var cacheShare = StatsAggregator.CacheShare(inRange); CacheShareValues = cacheShare.AsValues; CacheShareLabels = [loc["Stats.Cache.New"], loc["Stats.Cache.Write"], loc["Stats.Cache.Read"], loc["Stats.Cache.Output"]]; - // The "top projects" panel. TopProjectRows = StatsAggregator.TopProjectsDetailed(inRange, limit: 12, loc["Stats.NoProject"]); RefreshProjectColors(TopProjectRows, CurrentBarProjectKeys()); @@ -1639,7 +1635,7 @@ private void RebuildFrom(IReadOnlyList source, LimitInputs? inputs PreviousPeriodTotalRaw = previous.Sum(record => record.TotalTokens); ActiveDayCountRaw = figures.ActiveDayCount; PeriodDayCountRaw = Math.Max(1, periodDayCount); - ActiveDaysOfText = string.Format(CultureInfo.CurrentCulture, loc["Stats.ActiveDaysOf"], figures.ActiveDayCount, periodDayCount); + ActiveDaysOfText = string.Format(CultureInfo.CurrentCulture, loc[periodDayCount == 1 ? "Stats.ActiveDaysOf.One" : "Stats.ActiveDaysOf"], figures.ActiveDayCount, periodDayCount); } /// The quota history a rebuild draws, read ahead of it: every provider's points for one @@ -1985,7 +1981,7 @@ private void ApplyHeadlineFigures(StatsHeadlineFigures figures, long previousTot if (figures.BusiestDay is { } busiestDay) { BusiestDayFigureText = StatsAggregator.ShortenTokenCount(figures.BusiestDayTotal, thousand, million, billion); - BusiestDayDateText = DateLabels.WeekdayDayMonthShort(busiestDay, CultureInfo.CurrentCulture); + BusiestDayDateText = DateLabels.WeekdayDayMonthShort(busiestDay, LocalizationService.Instance.DisplayCulture); } else { @@ -2145,7 +2141,7 @@ private static List BuildBarColorKeys( return groupedRows.Select(row => new BarColorKey(row.Label, 0)).ToList(); if (grouping == StatsGrouping.Project) - return groupedRows.Select(row => new BarColorKey(row.Label, 0)).ToList(); + return groupedRows.Select(row => new BarColorKey(row.Key ?? row.Label, 0)).ToList(); if (grouping == StatsGrouping.Model) { diff --git a/src/AiUsage/Storage/AppPaths.cs b/src/AiUsage/Storage/AppPaths.cs index db4684a..fc149ce 100644 --- a/src/AiUsage/Storage/AppPaths.cs +++ b/src/AiUsage/Storage/AppPaths.cs @@ -384,12 +384,19 @@ private static bool TryReadPointer(string pointerPath, out string directory) /// is never touched. A file is deleted only once it is at least an hour old, so a write in /// progress right now is safe. Called once at startup, the same way does for its own - /// temp folder. + /// temp folder. The statistics snapshot and the project color cache in the machine-local cache + /// folder are swept the same way. /// - public static void CleanUpLeftoverTempFiles() => CleanUpLeftoverTempFiles(DataDirectory); + public static void CleanUpLeftoverTempFiles() + { + CleanUpLeftoverTempFiles(DataDirectory); + CleanUpLeftoverTempFiles(Services.StartupProfile.CacheDirectory, IsOwnCacheTempFileName); + } /// Exposed so a test can plant a leftover in a folder of its own. - internal static void CleanUpLeftoverTempFiles(string directory) + internal static void CleanUpLeftoverTempFiles(string directory) => CleanUpLeftoverTempFiles(directory, IsOwnTempFileName); + + internal static void CleanUpLeftoverTempFiles(string directory, Func isOwnTempFileName) { try { @@ -399,7 +406,7 @@ internal static void CleanUpLeftoverTempFiles(string directory) var cutoff = DateTime.UtcNow - TimeSpan.FromHours(1); foreach (var path in Directory.EnumerateFiles(directory, "*.tmp")) { - if (!IsOwnTempFileName(Path.GetFileName(path))) + if (!isOwnTempFileName(Path.GetFileName(path))) continue; try @@ -419,6 +426,10 @@ internal static void CleanUpLeftoverTempFiles(string directory) } } + internal static bool IsOwnCacheTempFileName(string name) => + name.StartsWith(Stats.StatsSnapshotFile.FileName + ".", StringComparison.OrdinalIgnoreCase) + || name.StartsWith(Stats.ProjectColorResolver.CacheFileName + ".", StringComparison.OrdinalIgnoreCase); + private static bool IsOwnTempFileName(string name) => name.StartsWith("settings.json.", StringComparison.OrdinalIgnoreCase) || name.StartsWith("status.json.", StringComparison.OrdinalIgnoreCase) @@ -430,6 +441,56 @@ private static bool IsOwnTempFileName(string name) => || (name.StartsWith("stats.v", StringComparison.OrdinalIgnoreCase) && name.EndsWith(".bak.tmp", StringComparison.OrdinalIgnoreCase)); + /// Creates when it does not exist yet, open only to the current + /// user, SYSTEM and the administrators: a folder made on another drive would otherwise inherit that + /// drive's defaults, which commonly let every signed-in account read it. An existing folder is + /// left as the user set it up. Best effort - the writability check that follows decides. + internal static void TryCreatePrivateDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + return; + + try + { + var directory = new DirectoryInfo(path); + if (directory.Exists) + return; + if (directory.Parent is { } parent) + Directory.CreateDirectory(parent.FullName); + + var security = new System.Security.AccessControl.DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + var owners = new List + { + new(System.Security.Principal.WellKnownSidType.LocalSystemSid, null), + new(System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid, null), + }; + using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent()) + { + if (identity.User is not { } user) + return; + owners.Insert(0, user); + } + + foreach (var sid in owners) + { + security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( + sid, System.Security.AccessControl.FileSystemRights.FullControl, + System.Security.AccessControl.InheritanceFlags.ContainerInherit | System.Security.AccessControl.InheritanceFlags.ObjectInherit, + System.Security.AccessControl.PropagationFlags.None, + System.Security.AccessControl.AccessControlType.Allow)); + } + + directory.Create(security); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException or System.Security.SecurityException or InvalidOperationException) + { + // A drive without permissions (FAT, a share) or a refused ACL: the plain creation below + // still applies. + } + } + /// Internal, not private: 's own "change data /// folder" flow needs the exact same writability check before it trusts a user-picked folder. internal static bool TryEnsureWritableDirectory(string path) diff --git a/src/AiUsage/Storage/BackupService.cs b/src/AiUsage/Storage/BackupService.cs index abb8547..c461a98 100644 --- a/src/AiUsage/Storage/BackupService.cs +++ b/src/AiUsage/Storage/BackupService.cs @@ -89,8 +89,8 @@ public static class BackupService private const string SettingsName = "settings.json"; private const string NotificationsName = "notifications.json"; - private const string ProjectColorsName = "project-colors.json"; - private const string StatsName = "stats.db"; + private const string ProjectColorsName = Stats.ProjectColorResolver.CacheFileName; + private const string StatsName = Stats.StatsStore.DatabaseFileName; private static readonly Regex HistoryName = new(@"^history-[A-Za-z0-9_-]{1,100}\.jsonl\z", RegexOptions.CultureInvariant); @@ -542,7 +542,10 @@ public static BackupInspection Inspect(string zipPath, string? scratchDirectory } /// The checks that need the entries' content: the settings file and the token index. - private static void CheckContent(OpenedBackup backup, string scratch) + /// Checks every entry of and returns where its token index was + /// unpacked for the schema check (null when it carries none), so a caller needing the index + /// itself takes this copy instead of unpacking it a second time. + private static string? CheckContent(OpenedBackup backup, string scratch) { if (backup.Has(SettingsName)) CheckSettingsText(backup.ReadText(SettingsName)); @@ -554,12 +557,13 @@ private static void CheckContent(OpenedBackup backup, string scratch) backup.Verify(name, ref total); if (!backup.Has(StatsName)) - return; + return null; Directory.CreateDirectory(scratch); var extracted = Path.Combine(scratch, StatsName); backup.Extract(StatsName, extracted, ref total); CheckStatsSchema(extracted); + return extracted; } /// The same gate a normal load applies to the settings file. @@ -616,10 +620,8 @@ internal ImportSource(BackupManifest manifest, string statsDatabasePath, string return null; } - CheckContent(backup, Path.Combine(scratch, "check")); - var path = Path.Combine(scratch, StatsName); - long total = 0; - backup.Extract(StatsName, path, ref total); + var path = CheckContent(backup, scratch) + ?? throw new RefusedException(BackupRefusal.NotABackup); refusal = BackupRefusal.None; return new ImportSource(backup.Manifest, path, scratch); } diff --git a/src/AiUsage/Storage/HistoryStore.cs b/src/AiUsage/Storage/HistoryStore.cs index a20a679..2278feb 100644 --- a/src/AiUsage/Storage/HistoryStore.cs +++ b/src/AiUsage/Storage/HistoryStore.cs @@ -168,7 +168,9 @@ private void AppendCore(string providerId, PendingAppend request) if (last is not null) { var unchanged = Math.Abs(last.Percent - request.Percent) < 0.001; - var tooSoon = request.Now - last.Timestamp < MinAppendInterval; + // A clock set back puts the newest point in the future; that is no reason to stop writing. + var age = request.Now - last.Timestamp; + var tooSoon = age >= TimeSpan.Zero && age < MinAppendInterval; if (unchanged && tooSoon) return; } diff --git a/src/AiUsage/Themes/Tokens.xaml b/src/AiUsage/Themes/Tokens.xaml index 26955ef..7976efa 100644 --- a/src/AiUsage/Themes/Tokens.xaml +++ b/src/AiUsage/Themes/Tokens.xaml @@ -38,8 +38,8 @@ together. --> 7,7,0,0 - 258 diff --git a/src/AiUsage/ViewModels/DayGridTileViewModel.cs b/src/AiUsage/ViewModels/DayGridTileViewModel.cs index 8d0bd13..8f79797 100644 --- a/src/AiUsage/ViewModels/DayGridTileViewModel.cs +++ b/src/AiUsage/ViewModels/DayGridTileViewModel.cs @@ -120,7 +120,7 @@ public DayGridTileViewModel(StatsStore? statsStore, Func? clock = null /// the eye popup's row template binds this property by name on whichever kind of row it is /// showing, so the two must agree on it. public string ToggleVisibilityActionText => - $"{HeaderDisplayName}: {LocalizationService.Instance[IsHidden ? "Tile.Show" : "Tile.Hide"]}"; + LocalizationService.Instance.Format("Format.LabelValue", HeaderDisplayName, LocalizationService.Instance[IsHidden ? "Tile.Show" : "Tile.Hide"]); /// The header row's own right-hand figure: the total of whatever span is actually shown /// (the visible weeks at Full, the visible days at Mini) - not the whole year, so it diff --git a/src/AiUsage/ViewModels/MainViewModel.Accounts.cs b/src/AiUsage/ViewModels/MainViewModel.Accounts.cs index e881160..6c514b0 100644 --- a/src/AiUsage/ViewModels/MainViewModel.Accounts.cs +++ b/src/AiUsage/ViewModels/MainViewModel.Accounts.cs @@ -329,6 +329,10 @@ private async Task DeleteWebProfileAsync(string accountKey) return result; } + /// True while a sign-out of is still clearing its session; a + /// second request for the same account then has nothing to add. + internal bool IsSigningOut(string accountKey) => _signingOutAccounts.Contains(accountKey); + /// What the sign-out buttons call from their event handlers: an unexpected failure (a /// browser process that will not shut down cleanly) is logged and reported as an incomplete /// sign-out instead of escaping an async event handler and ending the app. diff --git a/src/AiUsage/ViewModels/MainViewModel.cs b/src/AiUsage/ViewModels/MainViewModel.cs index d33ac51..3d08b9a 100644 --- a/src/AiUsage/ViewModels/MainViewModel.cs +++ b/src/AiUsage/ViewModels/MainViewModel.cs @@ -875,14 +875,18 @@ public void Tick(DateTimeOffset now) public void RefreshNow(bool userStarted = false) { if (userStarted) - _userStartedFetches.UnionWith(Tiles.Select(tile => tile.ProviderId)); + { + lock (_userStartedFetches) + _userStartedFetches.UnionWith(Tiles.Select(tile => tile.ProviderId)); + } try { _scheduler.RefreshNow(IsTileHidden, IsAttended(WindowVisible, SessionLocked), IsAccountDisconnected, _lifetimeCts.Token); } finally { - _userStartedFetches.Clear(); + lock (_userStartedFetches) + _userStartedFetches.Clear(); } } @@ -998,8 +1002,8 @@ void Apply() /// One tile's hidden flag, for - a plain /// method rather than a lambda at each call site, so the same delegate instance is reused. - // TryGetValue, not the plain indexer: a fetch already in flight for an account just removed via - // TryGetValue: a removed account may be asked once more before the scheduler drops it. + // TryGetValue, not the plain indexer: a removed account may be asked once more before the + // scheduler drops it. internal bool IsTileHidden(string providerId) => _tilesById.TryGetValue(providerId, out var tile) && tile.IsHidden; /// One account's disconnected flag (), for @@ -1020,14 +1024,16 @@ private bool IsLiveAccount(string accountKey) => [RelayCommand] private void RefreshProvider(string providerId) { - _userStartedFetches.Add(providerId); + lock (_userStartedFetches) + _userStartedFetches.Add(providerId); try { _scheduler.RefreshNow(providerId, _lifetimeCts.Token); } finally { - _userStartedFetches.Clear(); + lock (_userStartedFetches) + _userStartedFetches.Clear(); } } @@ -1462,8 +1468,11 @@ void Tail() // off the UI thread the same way OnSnapshotReady can, hence the same dispatcher check. private void OnFetchStarted(string providerId) { - // Read here, on the calling thread: only the scheduler call a person triggered has it set. - var userStarted = _userStartedFetches.Contains(providerId); + // Read here, on the calling thread: only the scheduler call a person triggered has it set. A + // retry attempt arrives on a pool thread, so the set is only ever touched under its lock. + bool userStarted; + lock (_userStartedFetches) + userStarted = _userStartedFetches.Contains(providerId); void Mark() { @@ -1656,6 +1665,10 @@ void Apply() CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } + /// The one history store of this process. The settings and statistics windows share it, so + /// a reset or a data-folder move in one is never undone by a rewrite still running in another. + internal HistoryStore HistoryStore => _historyStore; + /// Settings window's own "change data folder" flow calls this once it has already /// copied every history file across, so the live recording this view model does on every tick /// () continues into the new location instead of the one just left diff --git a/src/AiUsage/ViewModels/NotificationRowViewModel.cs b/src/AiUsage/ViewModels/NotificationRowViewModel.cs index 4c7466f..159ed58 100644 --- a/src/AiUsage/ViewModels/NotificationRowViewModel.cs +++ b/src/AiUsage/ViewModels/NotificationRowViewModel.cs @@ -65,10 +65,10 @@ internal void SetHeaderDisplayName(string headerDisplayName) /// Names what the master switch actually toggles for a screen reader, which otherwise /// only announces the checkbox's own bare "Ein" content. - public string NotificationsEnabledFullLabel => $"{NotificationsForProviderText}: {Loc["Settings.On"]}"; + public string NotificationsEnabledFullLabel => Loc.Format("Format.LabelValue", NotificationsForProviderText, Loc["Settings.On"]); public string NotifyOnResetEnabledFullLabel => - $"{HeaderDisplayName} {Loc["Settings.NotifyOnResetForProvider"]}: {Loc["Settings.On"]}"; + Loc.Format("Format.LabelValue", $"{HeaderDisplayName} {Loc["Settings.NotifyOnResetForProvider"]}", Loc["Settings.On"]); private static LocalizationService Loc => LocalizationService.Instance; diff --git a/src/AiUsage/ViewModels/ProviderTileViewModel.cs b/src/AiUsage/ViewModels/ProviderTileViewModel.cs index 3b6f2d3..9957f58 100644 --- a/src/AiUsage/ViewModels/ProviderTileViewModel.cs +++ b/src/AiUsage/ViewModels/ProviderTileViewModel.cs @@ -797,7 +797,7 @@ partial void OnShowWeeklyChanged(bool value) /// buttons (Settings window's display card, the title bar's eye popup), so the text names the /// action and shows which way the show/hide toggle flips next, not just the bare provider name. public string ToggleVisibilityActionText => - $"{HeaderDisplayName}: {LocalizationService.Instance[IsHidden ? "Tile.Show" : "Tile.Hide"]}"; + LocalizationService.Instance.Format("Format.LabelValue", HeaderDisplayName, LocalizationService.Instance[IsHidden ? "Tile.Show" : "Tile.Hide"]); /// Timestamp of the last snapshot that actually carried data, regardless of status - a /// Stale snapshot still has a last-known timestamp. Feeds the Settings window's "zuletzt diff --git a/src/AiUsage/ViewModels/SettingsViewModel.cs b/src/AiUsage/ViewModels/SettingsViewModel.cs index 2f5ca58..5c09c40 100644 --- a/src/AiUsage/ViewModels/SettingsViewModel.cs +++ b/src/AiUsage/ViewModels/SettingsViewModel.cs @@ -486,7 +486,7 @@ public Choice? SelectedZoomChoice /// Names what the global Ein/Aus check box actually toggles - the same reasoning as /// and its siblings. - public string DefaultThresholdEnabledFullLabel => $"{DefaultThresholdLabelText}: {LocalizationService.Instance["Settings.On"]}"; + public string DefaultThresholdEnabledFullLabel => LocalizationService.Instance.Format("Format.LabelValue", DefaultThresholdLabelText, LocalizationService.Instance["Settings.On"]); /// Names the two real colour boundaries uses - /// shown as a grey line under the threshold slider so "yellow"/"red" is never a claim the numbers on @@ -578,7 +578,7 @@ internal SettingsViewModel(MainViewModel main, AppSettings settings, SettingsSto Main = main; _settings = settings; _store = store; - _historyStore = historyStore ?? new HistoryStore(); + _historyStore = historyStore ?? main.HistoryStore; _isAutostartEnabled = isAutostartEnabled; _enableAutostart = enableAutostart; _disableAutostart = disableAutostart; @@ -708,7 +708,22 @@ private void OnTilePropertyChanged(object? sender, System.ComponentModel.Propert } } - private void OnTilesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) => RebuildProviderRows(); + private void OnTilesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + // A reordered tile moves its rows along, so a row being edited keeps its state. + if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Move + && e.OldItems is { Count: 1 } + && e.OldStartingIndex is >= 0 && e.OldStartingIndex < NotificationRows.Count + && e.NewStartingIndex is >= 0 && e.NewStartingIndex < NotificationRows.Count + && NotificationRows.Count == ThresholdRows.Count) + { + NotificationRows.Move(e.OldStartingIndex, e.NewStartingIndex); + ThresholdRows.Move(e.OldStartingIndex, e.NewStartingIndex); + return; + } + + RebuildProviderRows(); + } /// One notification and threshold row per tile, each bound to that account's own entry /// in the live settings - created when an import or an older file has none for it. @@ -1386,7 +1401,7 @@ private async Task ChooseDataFolderAsync() return; } - if (File.Exists(Path.Combine(destination, "stats.db")) || File.Exists(Path.Combine(destination, "settings.json"))) + if (File.Exists(Path.Combine(destination, Stats.StatsStore.DatabaseFileName)) || File.Exists(Path.Combine(destination, "settings.json"))) { if (!AppPaths.SameDirectory(destination, OwnDefaultDataDirectory())) { @@ -1412,6 +1427,7 @@ private async Task ChooseDataFolderAsync() _store.SaveNow(_settings); // drain a still-pending debounced save before anything else moves + AppPaths.TryCreatePrivateDirectory(destination); if (!AppPaths.TryEnsureWritableDirectory(destination)) { _showMessage(LocalizationService.Instance["Settings.DataFolderRefused"]); @@ -1453,7 +1469,8 @@ private async Task ChooseDataFolderAsync() AppPaths.EnsureDataMarker(); _store.Redirect(destination); Main.RedirectHistoryStore(destination); - _historyStore.Redirect(destination); + if (!ReferenceEquals(_historyStore, Main.HistoryStore)) + _historyStore.Redirect(destination); DataFolderPath = AppPaths.DataDirectory; // the settings row shows the new path without a restart _store.SaveNow(_settings); // now lands in the new folder - _store was just redirected above @@ -1768,7 +1785,7 @@ private async Task ImportPcAsync() _store.RequestSave(_settings); await RebuildImportedPcsAsync(); Stats.StatsIndexerService.Shared?.RefreshSnapshotInBackground(); - _showMessage(loc.Format("Settings.Backup.Imported", days, name)); + _showMessage(loc.Format(days == 1 ? "Settings.Backup.Imported.One" : "Settings.Backup.Imported", days, name)); break; default: _showMessage(loc["Settings.Backup.Refused"]); diff --git a/src/AiUsage/ViewModels/UsageRowViewModel.cs b/src/AiUsage/ViewModels/UsageRowViewModel.cs index a675c38..837cf3e 100644 --- a/src/AiUsage/ViewModels/UsageRowViewModel.cs +++ b/src/AiUsage/ViewModels/UsageRowViewModel.cs @@ -74,7 +74,7 @@ public partial class UsageRowViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(MiniAccessibleName))] private UsageLevel level; - /// The level in words for a screen reader ("nearly used up", "almost at the limit"), + /// The level in words for a screen reader ("running low", "almost at the limit"), /// empty while the window is fine - colour and icon alone must not carry the warning. public string LevelName => Level switch { diff --git a/src/AiUsage/Views/Controls/DayGridTile.xaml b/src/AiUsage/Views/Controls/DayGridTile.xaml index 7f96999..eabc275 100644 --- a/src/AiUsage/Views/Controls/DayGridTile.xaml +++ b/src/AiUsage/Views/Controls/DayGridTile.xaml @@ -20,7 +20,7 @@ internal static string RangeCaption(DateTimeOffset at, TimeSpan range, bool withYear = false) { - var culture = CultureInfo.CurrentCulture; + var culture = LocalizationService.Instance.DisplayCulture; var local = at.LocalDateTime; if (range <= TimeSpan.FromHours(24)) return local.ToString("t", culture); @@ -269,7 +269,7 @@ internal static (string Start, string End) RangeCaptions(DateTimeOffset start, D // A day-long range shows the same clock time at both ends, so the start names its day. if (range <= TimeSpan.FromHours(24) && start.LocalDateTime.Date != end.LocalDateTime.Date) - return (DateLabels.ShortMonthDay(start.LocalDateTime, CultureInfo.CurrentCulture) + " " + RangeCaption(start, range), + return (DateLabels.ShortMonthDay(start.LocalDateTime, LocalizationService.Instance.DisplayCulture) + " " + RangeCaption(start, range), RangeCaption(end, range)); var crossesYear = start.LocalDateTime.Year != end.LocalDateTime.Year; @@ -280,7 +280,7 @@ internal static (string Start, string End) RangeCaptions(DateTimeOffset start, D /// day plus clock time. internal static string HoverTime(DateTimeOffset at, TimeSpan range) { - var culture = CultureInfo.CurrentCulture; + var culture = LocalizationService.Instance.DisplayCulture; var local = at.LocalDateTime; return range <= TimeSpan.FromHours(24) ? local.ToString("t", culture) @@ -513,8 +513,7 @@ private void DrawCaptionBox(DrawingContext dc, string text, double anchorX, doub var boxY = Math.Clamp(4, 0, Math.Max(0, height - boxHeight)); var bgBrush = (Brush)(TryFindResource("Bg.Raised") ?? System.Windows.SystemColors.WindowBrush); - var borderBrush = (Brush)(TryFindResource("Border") ?? System.Windows.SystemColors.GrayTextBrush); - dc.DrawRoundedRectangle(bgBrush, new Pen(borderBrush, 1), new Rect(boxX, boxY, boxWidth, boxHeight), 4, 4); + dc.DrawRoundedRectangle(bgBrush, GetGridStyle().SolidPen, new Rect(boxX, boxY, boxWidth, boxHeight), 4, 4); dc.DrawText(formatted, new Point(boxX + padding, boxY + padding)); } @@ -863,8 +862,7 @@ private LevelStyle GetSeriesColours(WindowKind kind, UsageLevel level) /// Cached, frozen draw resources for the grid lines and the hover guide line - a solid /// pen (the top edge and the hover guide, both plain "Border"), a dashed pen at half opacity (the - /// 50% line), sharing the same "Border" theme resource already caches - /// its own colors from. Built once per distinct resolved brush and reused across renders instead + /// 50% line), sharing the same "Border" theme resource the series colors are cached from. Built once per distinct resolved brush and reused across renders instead /// of allocating three fresh Pen/Brush instances on every and every /// call. private sealed class GridStyle diff --git a/src/AiUsage/Views/Controls/HotkeyDisplayConverter.cs b/src/AiUsage/Views/Controls/HotkeyDisplayConverter.cs new file mode 100644 index 0000000..d7b9575 --- /dev/null +++ b/src/AiUsage/Views/Controls/HotkeyDisplayConverter.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using System.Windows.Data; +using AiUsage.Services; + +namespace AiUsage.Views.Controls; + +/// The stored hotkey text to the form shown to the user. The second value is any +/// localized string, bound only so a language switch reformats the modifier names. +public sealed class HotkeyDisplayConverter : IMultiValueConverter +{ + public static HotkeyDisplayConverter Instance { get; } = new(); + + public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture) => + GlobalHotkey.FormatForDisplay(values.Length > 0 ? values[0] as string : null); + + public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/AiUsage/Views/Controls/ProviderTile.xaml b/src/AiUsage/Views/Controls/ProviderTile.xaml index 1cfcf52..b9185af 100644 --- a/src/AiUsage/Views/Controls/ProviderTile.xaml +++ b/src/AiUsage/Views/Controls/ProviderTile.xaml @@ -340,10 +340,6 @@ FontSize="{StaticResource Font.Size.Small}" Visibility="{Binding ShowHeaderSignIn, Converter={StaticResource BoolToVis}}" Command="{Binding RunActionCommand}"/> - diff --git a/src/AiUsage/Views/Controls/StatsBarChart.cs b/src/AiUsage/Views/Controls/StatsBarChart.cs index ee332b7..cce6a71 100644 --- a/src/AiUsage/Views/Controls/StatsBarChart.cs +++ b/src/AiUsage/Views/Controls/StatsBarChart.cs @@ -804,8 +804,8 @@ protected override void OnRender(DrawingContext drawingContext) var bareHours = false; string LabelString(int index) => hourIndices is not null ? (bareHours ? index.ToString(CultureInfo.CurrentCulture) : HourAxisLabel(index)) - : monthIndices is null ? FormatAxisLabel(bars[index].Label, CultureInfo.CurrentCulture) - : FormatMonthAxisLabel(bars[index].Label, CultureInfo.CurrentCulture); + : monthIndices is null ? FormatAxisLabel(bars[index].Label, LocalizationService.Instance.DisplayCulture) + : FormatMonthAxisLabel(bars[index].Label, LocalizationService.Instance.DisplayCulture); FormattedText MakeLabel(int index) => new( LabelString(index), CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, tickTypeface, 9, TextBrush, dpi); @@ -960,7 +960,7 @@ string WithUnit(long value) return string.IsNullOrEmpty(tokenWord) ? number : $"{number} {tokenWord}"; } - var lines = new List { BuildTooltipHeaderLine(bar.Label, CultureInfo.CurrentCulture) }; + var lines = new List { BuildTooltipHeaderLine(bar.Label, LocalizationService.Instance.DisplayCulture) }; if (bar.StackedValues.Count > 1 && seriesLabels.Count > 0) { for (var s = 0; s < bar.StackedValues.Count; s++) diff --git a/src/AiUsage/Views/Controls/StatsHeatmap.cs b/src/AiUsage/Views/Controls/StatsHeatmap.cs index 4ec16e4..05d8c7c 100644 --- a/src/AiUsage/Views/Controls/StatsHeatmap.cs +++ b/src/AiUsage/Views/Controls/StatsHeatmap.cs @@ -3,6 +3,7 @@ using System.Windows.Automation; using System.Windows.Input; using System.Windows.Media; +using AiUsage.Services; namespace AiUsage.Views.Controls; @@ -245,7 +246,7 @@ protected override void OnRender(DrawingContext drawingContext) var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; var typeface = ChartFonts.UiTypeface(this); - var culture = CultureInfo.CurrentCulture; + var culture = LocalizationService.Instance.DisplayCulture; if (!HasData) { @@ -313,7 +314,7 @@ protected override void OnRender(DrawingContext drawingContext) private ChartTooltipPopup? _tooltipPopup; private string TextOf(int row, int column) => - BuildTooltip(TooltipFormat, DayOfRow(FirstDay, row), column, Values[row * Columns + column], CultureInfo.CurrentCulture); + BuildTooltip(TooltipFormat, DayOfRow(FirstDay, row), column, Values[row * Columns + column], LocalizationService.Instance.DisplayCulture); /// The leader line of one cell, empty when there is none. private string LeaderOf(int row, int column) diff --git a/src/AiUsage/Views/Controls/StatsMonthGrid.cs b/src/AiUsage/Views/Controls/StatsMonthGrid.cs index 6c56675..631b962 100644 --- a/src/AiUsage/Views/Controls/StatsMonthGrid.cs +++ b/src/AiUsage/Views/Controls/StatsMonthGrid.cs @@ -77,7 +77,7 @@ private static void OnSummaryInputChanged(DependencyObject d, DependencyProperty if (grid.IsKeyboardFocused && grid._focusedDay is not null) grid.UpdateFocusedAutomationName(); else - AutomationProperties.SetName(grid, BuildAccessibleSummary(grid.Days, grid.RangeStart, grid.RangeEnd, CultureInfo.CurrentCulture)); + AutomationProperties.SetName(grid, BuildAccessibleSummary(grid.Days, grid.RangeStart, grid.RangeEnd, LocalizationService.Instance.DisplayCulture)); } /// Pure so the summary text is unit testable without a visual tree - the whole covered @@ -757,7 +757,7 @@ private void DrawWeekdayLabels(DrawingContext dc, GridFit fit, double dpi, Typef { var cellSize = fit.CellSize; const DayOfWeek firstDayOfWeek = DayOfWeek.Monday; - var dayAbbreviations = CultureInfo.CurrentCulture.DateTimeFormat.AbbreviatedDayNames; + var dayAbbreviations = LocalizationService.Instance.DisplayCulture.DateTimeFormat.AbbreviatedDayNames; for (var row = 0; row < 7; row++) { var dayOfWeek = (DayOfWeek)(((int)firstDayOfWeek + row) % 7); @@ -773,7 +773,7 @@ private void DrawWeekdayLabels(DrawingContext dc, GridFit fit, double dpi, Typef private void DrawMonthLabels(DrawingContext dc, IReadOnlyList columns, GridFit fit, double dpi, Typeface typeface) { - foreach (var (column, label) in MonthLabels(columns, CultureInfo.CurrentCulture)) + foreach (var (column, label) in MonthLabels(columns, LocalizationService.Instance.DisplayCulture)) { var x = EffectiveLeftLabelWidth + column * (fit.CellSize + fit.Gap); var text = new FormattedText(label, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, typeface, 11, TextBrush, dpi); @@ -992,7 +992,7 @@ string WithUnit(long value) private void ShowTooltip(CellHit hover) { - var lines = BuildTooltipLines(hover, ProviderDisplayNames, TokenWord, TotalLabel, NoUsageText, CultureInfo.CurrentCulture); + var lines = BuildTooltipLines(hover, ProviderDisplayNames, TokenWord, TotalLabel, NoUsageText, LocalizationService.Instance.DisplayCulture); _tooltipPopup ??= new ChartTooltipPopup(); _tooltipPopup.Show(this, hover.Rect, lines, TooltipBackground, TextBrush, TextBrush); } @@ -1113,7 +1113,7 @@ protected override void OnLostKeyboardFocus(KeyboardFocusChangedEventArgs e) _focusedDay = null; _tooltipPopup?.Hide(); - AutomationProperties.SetName(this, BuildAccessibleSummary(Days, RangeStart, RangeEnd, CultureInfo.CurrentCulture)); + AutomationProperties.SetName(this, BuildAccessibleSummary(Days, RangeStart, RangeEnd, LocalizationService.Instance.DisplayCulture)); InvalidateVisual(); } @@ -1148,7 +1148,7 @@ private void UpdateFocusedAutomationName() var lines = BuildTooltipLines( new CellHit(default, found.Day, found.Total, found.ByProvider), ProviderDisplayNames, TokenWord, TotalLabel, - NoUsageText, CultureInfo.CurrentCulture); + NoUsageText, LocalizationService.Instance.DisplayCulture); AutomationProperties.SetName(this, string.Join(". ", lines)); return; } diff --git a/src/AiUsage/Views/Controls/TitleBar.xaml b/src/AiUsage/Views/Controls/TitleBar.xaml index 52b17e0..59fde30 100644 --- a/src/AiUsage/Views/Controls/TitleBar.xaml +++ b/src/AiUsage/Views/Controls/TitleBar.xaml @@ -140,14 +140,15 @@ Padding="4,4" MinHeight="32" HorizontalContentAlignment="Left" Cursor="Hand"> - + diff --git a/src/AiUsage/Views/Controls/TitleBar.xaml.cs b/src/AiUsage/Views/Controls/TitleBar.xaml.cs index 7c75029..7bb037f 100644 --- a/src/AiUsage/Views/Controls/TitleBar.xaml.cs +++ b/src/AiUsage/Views/Controls/TitleBar.xaml.cs @@ -32,7 +32,7 @@ public partial class TitleBar : UserControl private bool _eyeOpenedByKeyboard; public static readonly DependencyProperty TitleTextProperty = - DependencyProperty.Register(nameof(TitleText), typeof(string), typeof(TitleBar), new PropertyMetadata("AI-Usage")); + DependencyProperty.Register(nameof(TitleText), typeof(string), typeof(TitleBar), new PropertyMetadata(AppInfo.ProductName)); public static readonly DependencyProperty ShowEyeMenuProperty = DependencyProperty.Register(nameof(ShowEyeMenu), typeof(bool), typeof(TitleBar), new PropertyMetadata(true)); diff --git a/src/AiUsage/Views/EscapeKey.cs b/src/AiUsage/Views/EscapeKey.cs index 8db0aa9..7d91f4f 100644 --- a/src/AiUsage/Views/EscapeKey.cs +++ b/src/AiUsage/Views/EscapeKey.cs @@ -1,19 +1,44 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; namespace AiUsage.Views; /// Decides whether Escape should close a window. A window-level tunnelling handler sees the -/// key before an open drop-down list does, so Escape meant to dismiss the list must be left alone. +/// key before an open drop-down list or popup does, so Escape meant to dismiss one of those must be +/// left alone. internal static class EscapeKey { - /// True for Escape that no open combo box dropdown is about to use. + /// True for Escape that no open combo box dropdown or popup is about to use. public static bool ClosesWindow(KeyEventArgs e) => e.Key == Key.Escape && !InOpenComboBox(e.OriginalSource as DependencyObject) - && !InOpenComboBox(Keyboard.FocusedElement as DependencyObject); + && !InOpenComboBox(Keyboard.FocusedElement as DependencyObject) + && OpenPopupOf(e.OriginalSource as DependencyObject) is null + && OpenPopupOf(Keyboard.FocusedElement as DependencyObject) is null; + + /// The innermost open popup sits in (a date picker's calendar, a + /// window's own panel), or null outside any. + public static Popup? OpenPopupOf(DependencyObject? node) + { + while (node is not null) + { + if (node is Popup { IsOpen: true } self) + return self; + + // A popup's content hangs off its own root visual; only the logical tree leads back to it. + var logicalParent = LogicalTreeHelper.GetParent(node); + if (logicalParent is Popup { IsOpen: true } owner) + return owner; + + node = (node is Visual or System.Windows.Media.Media3D.Visual3D ? VisualTreeHelper.GetParent(node) : null) + ?? logicalParent; + } + + return null; + } /// True when is a combo box with its list open or sits inside one. public static bool InOpenComboBox(DependencyObject? node) diff --git a/src/AiUsage/Views/MainWindow.xaml b/src/AiUsage/Views/MainWindow.xaml index 5a9abcd..1aa8900 100644 --- a/src/AiUsage/Views/MainWindow.xaml +++ b/src/AiUsage/Views/MainWindow.xaml @@ -72,13 +72,13 @@ AutomationProperties.AutomationId="UpdateLaterButton" Content="{Binding Source={x:Static services:LocalizationService.Instance}, Path=[Update.Later]}" Click="UpdateLater_Click"/> - + diff --git a/src/AiUsage/Views/MainWindow.xaml.cs b/src/AiUsage/Views/MainWindow.xaml.cs index daa5e74..de2f227 100644 --- a/src/AiUsage/Views/MainWindow.xaml.cs +++ b/src/AiUsage/Views/MainWindow.xaml.cs @@ -228,7 +228,7 @@ internal MainWindow(SettingsStore settingsStore, AppSettings? settings, LogServi ViewModel.SetPower(PowerStatus.Read()); ViewModel.RefreshNow(); UpdateTray(); - // UpdateDensity() does not run from here - its own inputs (Layout/HiddenCount/DensityMode + // The tick never calls UpdateDensity() - its own inputs (Layout/HiddenCount/DensityMode // via ViewModel_PropertyChanged, the tile count via the CollectionChanged subscription above, // and a live resize via MainWindow_SizeChanged) already call it directly, so a second's worth // of countdown ticks in between has nothing new to recompute. @@ -512,6 +512,9 @@ private void DayGridTile_PropertyChanged(object? sender, PropertyChangedEventArg QueueDensityUpdate(); } + private static bool IsWelcomeOpen() => + Application.Current?.Windows.OfType().Any(window => window.IsVisible) == true; + /// Connects one tile's sign-in and sign-out requests to the app's own sign-in window /// and sign-out path; a tile without either has neither button (). @@ -532,7 +535,9 @@ private void WireSignIn(ProviderTileViewModel tile) WebSignInFlow.Open(this, ProviderRegistry.WebSessionFor(accountKey), signedIn => { ViewModel.CompleteSignIn(accountKey, signedIn); - ShowAndActivate(userInitiated: false); + // Signed in from the first-start welcome: the widget follows once that closes. + if (!IsWelcomeOpen()) + ShowAndActivate(userInitiated: false); }); }; } @@ -552,6 +557,8 @@ private void WireSignIn(ProviderTileViewModel tile) tile.SignOutRequested += async (_, _) => { + if (ViewModel.IsSigningOut(accountKey)) + return; var signedOut = await ViewModel.TrySignOutAsync(accountKey); if (!signedOut) ViewModel.ShowSignOutIncomplete(accountKey); @@ -951,7 +958,10 @@ private void MainWindow_SizeChanged(object sender, SizeChangedEventArgs e) if (_settings.Window.Collapsed || _applyingDensityHeight) return; - UpdateDensity(); + if (_resizeDragActive) + QueueDensityUpdate(); + else + UpdateDensity(); if (!_heightIsAutomatic || _autoHeightArea is not { } area) return; @@ -1230,9 +1240,8 @@ private void ApplyClickThrough(bool clickThrough) } } - // Only the title bar's native DragMove() (a plain window MOVE) still goes through the Win32 - // size/move loop, so this hook only ever needs to persist position now - see the resize Thumbs - // below for why height changes are handled entirely in managed code instead. + // The window's own messages: the hotkey, the desktop layer, snapping and the end of a title-bar + // move. Resizing never reaches it - see the resize Thumbs below for why that runs in managed code. private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg == DesktopLayer.WmWindowPosChanging) @@ -1285,14 +1294,32 @@ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref b // native non-client resize-border loop entirely for a layered window - confirmed by testing on // the VM (self-test): dragging inside WindowChrome's ResizeBorderThickness never even // started a resize. These eight Thumb grips (WPF's own drag-delta control) replace it. + // A resize drag measures the tallest content once per width instead of on every mouse move, and + // coalesces the density passes its size changes ask for; the drag's end settles the final one. + private bool _resizeDragActive; + private (double Width, double Height)? _dragTallestContentHeight; + + private double TallestContentHeightForDrag() + { + _resizeDragActive = true; + if (_dragTallestContentHeight is { } cached && cached.Width == Width) + return cached.Height; + + var height = MeasureTallestContentHeight(); + _dragTallestContentHeight = (Width, height); + return height; + } + private void GrowRight(double dx) { + _resizeDragActive = true; MarkWidthManual(); Width = Math.Max(MinWidth, Width + dx); } private void GrowLeft(double dx) { + _resizeDragActive = true; MarkWidthManual(); var newWidth = Math.Max(MinWidth, Width - dx); Left += Width - newWidth; @@ -1329,7 +1356,7 @@ private void GrowDown(double dy) // window was last dragged small. var workAreaHeight = CurrentArea().Height; MaxHeight = workAreaHeight; - Height = WindowPlacementService.GrowManualHeight(Height, dy, MinHeight, workAreaHeight, MeasureTallestContentHeight(), _zoom); + Height = WindowPlacementService.GrowManualHeight(Height, dy, MinHeight, workAreaHeight, TallestContentHeightForDrag(), _zoom); } /// The content area's natural height, measured fresh rather than read from a stale @@ -1362,7 +1389,7 @@ private void GrowUp(double dy) // 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); + var newHeight = WindowPlacementService.GrowManualHeight(Height, -dy, MinHeight, workAreaHeight, TallestContentHeightForDrag(), _zoom); Top += Height - newHeight; Height = newHeight; } @@ -1448,6 +1475,12 @@ private void ResizeBottomRight_DragDelta(object sender, DragDeltaEventArgs e) private void Resize_DragCompleted(object sender, DragCompletedEventArgs e) { + var wasDragging = _resizeDragActive; + _resizeDragActive = false; + _dragTallestContentHeight = null; + if (wasDragging && !_settings.Window.Collapsed) + UpdateDensity(); + if (!_heightIsAutomatic) Current.Height = Height; SaveWindowSettings(); @@ -1522,12 +1555,9 @@ private void Restore() } /// F5 refreshes, Ctrl+, opens Settings - the same actions already reachable via the - /// title bar's own buttons. The two conditions are pulled out as pure, - /// unit-testable predicates (/) - /// because end-to-end proof on a real window was not obtainable: automated synthetic key - /// delivery never reached this window in several attempts (F5, Ctrl+,, Esc all silently did - /// nothing, while every mouse-driven interaction on the same window worked normally) - a - /// disclosed test-tooling gap, not a code defect. + /// title bar's own buttons. The two conditions are pure predicates + /// (/) so they are testable + /// without a live window. private void MainWindow_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) { if (IsRefreshShortcut(e.Key)) @@ -1637,7 +1667,7 @@ private void TitleBarControl_StatsRequested(object? sender, EventArgs e) WindowActivation.TryActivate(_statsWindow.Activate); } - private StatsWindow CreateStatsWindow() => new StatsWindow(new StatsViewModel(new StatsStore()) + private StatsWindow CreateStatsWindow() => new StatsWindow(new StatsViewModel(new StatsStore(), history: ViewModel.HistoryStore) { ImportedMachineNames = () => _settings.ImportedMachines.Select(machine => machine.Name).ToList(), }, _settings, _settingsStore); diff --git a/src/AiUsage/Views/OwnerWindowResolver.cs b/src/AiUsage/Views/OwnerWindowResolver.cs index 32868b9..62c2f0c 100644 --- a/src/AiUsage/Views/OwnerWindowResolver.cs +++ b/src/AiUsage/Views/OwnerWindowResolver.cs @@ -50,7 +50,7 @@ private static bool IsShown(Window? window) => /// The decision behind , generic for the same test-without-a-real-window /// reason as : an unowned dialog needs to be switched to /// explicitly, since CenterOwner - what - /// four of today's six dialogs declare in XAML - silently behaves like Manual (a top-left + /// most dialogs declare in XAML - silently behaves like Manual (a top-left /// corner open) once there is no owner to center against. /// internal static bool NeedsCenterOnScreen(T? resolvedOwner) where T : class => resolvedOwner is null; diff --git a/src/AiUsage/Views/SettingsWindow.xaml b/src/AiUsage/Views/SettingsWindow.xaml index 8bc2fe0..06ade47 100644 --- a/src/AiUsage/Views/SettingsWindow.xaml +++ b/src/AiUsage/Views/SettingsWindow.xaml @@ -951,10 +951,17 @@ IsChecked="{Binding Main.HotkeyEnabled}" Style="{StaticResource CheckBox.GapM}"/> + AutomationProperties.Name="{Binding Source={x:Static services:LocalizationService.Instance}, Path=[Settings.HotkeyCapture]}"> + + + + + + + { if (EscapeKey.ClosesWindow(e)) Close(); }; - // Owner is only set after the constructor returns (object initializer), so the work area - // this window will actually appear on is only known once it is about to show. + // The owner is applied after the constructor returns, so the work area this window will + // actually appear on is only known once it is about to show. Loaded += SettingsWindow_Loaded; Closed += (_, _) => _viewModel.Main.CopilotLoginChoiceRequested -= Main_CopilotLoginChoiceRequested; @@ -277,7 +277,7 @@ private void SignIn_Click(object sender, RoutedEventArgs e) private async void SignOut_Click(object sender, RoutedEventArgs e) { var button = (Button)sender; - if (button.DataContext is not ProviderTileViewModel tile) + if (button.DataContext is not ProviderTileViewModel tile || _viewModel.Main.IsSigningOut(tile.ProviderId)) return; var spinner = button.FindName("SignOutSpinner") as UIElement; @@ -496,7 +496,7 @@ private System.Windows.Controls.StackPanel BuildAboutProviderGroup(ProviderKind HorizontalContentAlignment = HorizontalAlignment.Left, Content = headerContent, }; - System.Windows.Automation.AutomationProperties.SetName(headerButton, $"{provider.DisplayName}: {caption}"); + System.Windows.Automation.AutomationProperties.SetName(headerButton, LocalizationService.Instance.Format("Format.LabelValue", provider.DisplayName, caption)); headerButton.Click += (_, _) => { var expanded = content.Visibility == Visibility.Visible; diff --git a/src/AiUsage/Views/StatsWindow.xaml b/src/AiUsage/Views/StatsWindow.xaml index cc75f49..809a56b 100644 --- a/src/AiUsage/Views/StatsWindow.xaml +++ b/src/AiUsage/Views/StatsWindow.xaml @@ -218,7 +218,8 @@ Background="{DynamicResource Bg.Raised}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="{StaticResource Radius.Control}"> -