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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ Backups, history from a second PC, real Windows notifications and a much deeper
- The token usage window no longer clips text in its smallest size, and its charts use the "Terminal" theme's font.
- Dates use the same month abbreviations everywhere.
- The 1.1.0 weekday or hour chart choice carries over into the new groupings.
- Token usage counts Codex again: real Codex logs carry no cache write total, and every line without one was skipped. Codex logs read so far without a single counted line are read once more.
- Codex reads its sessions from `CODEX_HOME` when that is set.
- Started into the notification area, the widget fetches at the slower hidden-window pace, and the first time it is shown it fetches right away.
- A quick double click on "Add account" for Copilot no longer adds the same GitHub account twice.
- Dragging the top edge of the widget stops at the content and the screen height like the bottom edge, instead of sliding the window off the screen.
- A settings file with an empty entry in the hidden windows no longer stops the app from starting, and a backup with a damaged token index is refused instead of closing the app.
- The widget no longer hides itself as if a full-screen app were running when no window has the focus.
- The update notice, the chart legend of a tile and the percentages in the top projects follow a language and number format change at once.
- Hovering or clicking the value labels of a token usage chart no longer picks the first column.
- A custom token usage range far in the past no longer freezes the window or throws, and ranges longer than a year are drawn per week.
- The history export names each window, so several Cursor bars can be told apart.
- An account name cut to its maximum length no longer ends in half an emoji.
- Settings and history are flushed to disk before they replace the old file, so a power cut cannot leave an empty file behind.
- After an in-app update the app starts again even when it closed before the setup looked for it, and uninstalling removes the desktop shortcut of a per-machine install.
- The portable setup no longer writes a setup log to the PC, and `build.bat` no longer stops at the installer step when Inno Setup is installed under "Program Files (x86)".

### Security

Expand Down
9 changes: 5 additions & 4 deletions build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ rem after a "for /f" command-substitution loop like the one above it (a known cm
rem GOTO :error, used everywhere else in this file, is unaffected and keeps working normally.
if exist "%STAGE%" rmdir /s /q "%STAGE%"
if exist "%STAGE%" (
echo ERROR: could not clear %STAGE%
echo ERROR: could not clear "%STAGE%"
echo Something is still using it - close any running app or Explorer window there.
goto :error
)
mkdir "%STAGE%"
if errorlevel 1 (
echo ERROR: could not create %STAGE%
echo ERROR: could not create "%STAGE%"
goto :error
)

Expand All @@ -98,7 +98,7 @@ if exist "%DIST%" (
mkdir "%DIST%"
)
if not exist "%DIST%" (
echo ERROR: could not create %DIST%
echo ERROR: could not create "%DIST%"
goto :error
)
for %%f in ("%DIST%\AI-Usage.exe" "%DIST%\SHA256SUMS.txt") do (
Expand Down Expand Up @@ -171,7 +171,8 @@ if not defined ISCC if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" set "ISCC=%P
if not defined ISCC (
echo WARNING: Inno Setup ^(ISCC.exe^) not found - skipping the installer.
) else (
echo Using %ISCC%
rem Quoted: the default "Program Files (x86)" path ends this block early at its ")" otherwise.
echo Using "%ISCC%"
"%ISCC%" /DAppVersion=%VERSION% "%ROOT%installer\AiUsage.iss"
if errorlevel 1 goto :error
)
Expand Down
24 changes: 21 additions & 3 deletions installer/AiUsage.iss
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ Filename: "{code:GetAppExePath}"; Flags: nowait runasoriginaluser; \
; Same hive limitation as autostart above: on a per-machine install approved by a different
; administrator account, the shortcut was written to the installing user's desktop, which an
; elevated uninstall running under that administrator's own account cannot see. Not worked around.
Type: files; Name: "{autodesktop}\{#AppName}.lnk"
; {userdesktop}, not {autodesktop}: on a per-machine install the latter is the Public desktop, where
; the app never writes its shortcut.
Type: files; Name: "{userdesktop}\{#AppName}.lnk"

[Registry]
; Creates nothing - the app writes the value itself (see [Run]). This entry exists only so
Expand Down Expand Up @@ -650,9 +652,24 @@ end;

{ The auto-relaunch Check in [Run] reads this - only relevant to the standard (non-portable),
silent path where no "launch now" checkbox was ever shown. }
function HasSwitch(const Name: String): Boolean;
var
I: Integer;
begin
Result := False;
for I := 1 to ParamCount do
if CompareText(ParamStr(I), Name) = 0 then
begin
Result := True;
Exit;
end;
end;

{ /RELAUNCH comes from the app's own in-app update: the app has usually ended before the window
lookup in InitializeSetup runs, so that lookup alone would leave the tray icon gone. }
function WasAppRunning: Boolean;
begin
Result := AppWasRunning;
Result := AppWasRunning or HasSwitch('/RELAUNCH');
end;

{ Deletes every file in Folder matching "setup-*.log" beyond the newest KeepCount, sorted by name
Expand Down Expand Up @@ -692,7 +709,8 @@ procedure CurStepChanged(CurStep: TSetupStep);
var
LogFolder, DestFile: String;
begin
if CurStep = ssDone then
{ Portable mode promises that nothing else is written to this PC. }
if (CurStep = ssDone) and not IsPortableMode then
begin
LogFolder := ExpandConstant('{localappdata}\{#AppName}\setup');
if ForceDirectories(LogFolder) and FileExists(ExpandConstant('{log}')) then
Expand Down
6 changes: 6 additions & 0 deletions src/AiUsage/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ void DeleteLeftoverOnce(object? sender, EventArgs args)
if (ShouldShowMainWindow(Dispatcher.HasShutdownStarted))
_mainWindow.Show();
}
else
{
// The view model starts out as "window visible"; left that way, a tray-only start polls
// at the open-window pace and the first show from the tray skips its catch-up refresh.
_mainWindow.ViewModel.WindowVisible = false;
}

// The one visible trace of a restore: where the previous data went, or that nothing changed.
if (_restoreOutcome != RestoreOutcome.NoPending)
Expand Down
9 changes: 8 additions & 1 deletion src/AiUsage/Io/AtomicFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ public static class AtomicFile
public static void WriteAllBytes(string path, byte[] bytes)
{
var tempPath = $"{path}.{Environment.ProcessId}.tmp";
File.WriteAllBytes(tempPath, bytes);
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(bytes);
// The rename is journaled but the content is not: without this, a power loss right after
// the move can leave the target empty instead of holding either version.
stream.Flush(flushToDisk: true);
}

File.Move(tempPath, path, overwrite: true);
}
}
7 changes: 6 additions & 1 deletion src/AiUsage/Models/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,12 @@ public sealed class ProviderSettings
public static string NormalizeAccountName(string? name)
{
var trimmed = (name ?? "").Trim();
return trimmed.Length > MaxAccountNameLength ? trimmed[..MaxAccountNameLength].TrimEnd() : trimmed;
if (trimmed.Length <= MaxAccountNameLength)
return trimmed;

// Never half of a character made of two code units (an emoji): that half is no character.
var cut = char.IsHighSurrogate(trimmed[MaxAccountNameLength - 1]) ? MaxAccountNameLength - 1 : MaxAccountNameLength;
return trimmed[..cut].TrimEnd();
}

public ThresholdSettings Thresholds { get; set; } = new();
Expand Down
10 changes: 9 additions & 1 deletion src/AiUsage/Providers/CodexPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ namespace AiUsage.Providers;
/// folder for its rate limits and the statistics index reads both.</summary>
internal static class CodexPaths
{
private static string Root => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex");
private static string Root => ResolveRoot(
Environment.GetEnvironmentVariable("CODEX_HOME"), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Directory.Exists);

/// <summary>Codex itself keeps everything under CODEX_HOME when that is set; ~/.codex otherwise,
/// and also when the variable names a folder that does not exist.</summary>
internal static string ResolveRoot(string? codexHome, string userProfile, Func<string, bool> folderExists) =>
!string.IsNullOrWhiteSpace(codexHome) && folderExists(codexHome.Trim())
? codexHome.Trim()
: Path.Combine(userProfile, ".codex");

public static string Sessions => Path.Combine(Root, "sessions");

Expand Down
4 changes: 3 additions & 1 deletion src/AiUsage/Providers/WebUsageSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ private bool IsPathAndStatus(string? entry)
{
envelopeJson = await _executeScript(script, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
// Only the caller's own cancellation propagates: a hidden session closed under a running read
// throws one of its own, which is a failed read like any other.
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
return (null, (WebUsageResult.Failed, null));
}
Expand Down
4 changes: 3 additions & 1 deletion src/AiUsage/Services/FullscreenWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ internal static bool IsFullscreen(bool foregroundIsOwn, string? className, Scree
if (foregroundIsOwn || IsShellWindowClass(className))
return false;

return foreground.Covers(monitor);
// An empty monitor rectangle (no foreground window, or the monitor lookup failed) is no screen
// to cover - the empty foreground rectangle would otherwise "cover" it.
return monitor.Right > monitor.Left && monitor.Bottom > monitor.Top && foreground.Covers(monitor);
}

/// <summary>The desktop and the taskbar: they fill the screen without being a full-screen app.</summary>
Expand Down
6 changes: 4 additions & 2 deletions src/AiUsage/Services/HistoryExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace AiUsage.Services;
/// </summary>
public static class HistoryExport
{
private const string Header = "provider,window,timestamp_utc,percent,resets_at_utc,tokens";
private const string Header = "provider,window,timestamp_utc,percent,resets_at_utc,tokens,label";

public static string ToCsv(IReadOnlyDictionary<string, IReadOnlyList<HistoryPoint>> byProvider)
{
Expand All @@ -28,7 +28,9 @@ public static string ToCsv(IReadOnlyDictionary<string, IReadOnlyList<HistoryPoin
.Append(point.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)).Append(',')
.Append(point.Percent.ToString(CultureInfo.InvariantCulture)).Append(',')
.Append(point.ResetsAt is { } resetsAt ? resetsAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture) : "").Append(',')
.Append(point.Tokens?.ToString(CultureInfo.InvariantCulture) ?? "")
.Append(point.Tokens?.ToString(CultureInfo.InvariantCulture) ?? "").Append(',')
// Tells several "Other" series of one provider apart (Cursor's per-model bars).
.Append(CsvField.Escape(point.Label ?? ""))
.Append("\r\n");
}
}
Expand Down
14 changes: 11 additions & 3 deletions src/AiUsage/Services/UpdateHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ internal static ProcessStartInfo BuildSetupStartInfo(string setupPath, string ar
/// <summary>The setup's command line. The setup does not reuse the previous install scope on its
/// own, so the scope of the running copy is passed along. The uninstall entries the setup wrote
/// say which scope owns this folder (it works for a folder the user picked); only when neither
/// names it does a copy under Program Files count as per-machine and any other as per-user.</summary>
/// names it does a copy under Program Files count as per-machine and any other as per-user. A
/// per-user setup also gets <see cref="RelaunchSwitch"/>: this copy has usually ended by the time the
/// setup looks for its window, so the setup would not know to start the new version again. A
/// per-machine setup runs elevated from the start and would start it elevated, so it gets none.</summary>
internal static string BuildSetupArguments(string? exePath) => BuildSetupArguments(exePath,
[
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
Expand All @@ -167,16 +170,21 @@ internal static string BuildSetupArguments(string? exePath, IEnumerable<string>
if (SameFolder(readInstallLocation(true), directory))
return "/SILENT /ALLUSERS";
if (SameFolder(readInstallLocation(false), directory))
return "/SILENT /CURRENTUSER";
return PerUserArguments;
}

var perMachine = directory is not null && programFilesRoots
.Where(root => !string.IsNullOrEmpty(root))
.Any(root => (directory + Path.DirectorySeparatorChar).StartsWith(
root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
return perMachine ? "/SILENT /ALLUSERS" : "/SILENT /CURRENTUSER";
return perMachine ? "/SILENT /ALLUSERS" : PerUserArguments;
}

/// <summary>Read by the setup script (WasAppRunning): start the app again once the update is done.</summary>
internal const string RelaunchSwitch = "/RELAUNCH";

private const string PerUserArguments = "/SILENT /CURRENTUSER " + RelaunchSwitch;

private static bool SameFolder(string? recorded, string directory) =>
!string.IsNullOrWhiteSpace(recorded)
&& string.Equals(recorded.Trim().TrimEnd(Path.DirectorySeparatorChar), directory.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
Expand Down
11 changes: 7 additions & 4 deletions src/AiUsage/Stats/CodexUsageLogParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,18 @@ public bool TryParseLine(string line, out UsageEvent result)
timestampProperty.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var timestamp))
return false;

// A line missing any of the four running totals says nothing reliable: reading the gap
// as zero would make the next complete line count its whole total as new. Such a line
// is skipped and the previous totals stay.
// A line missing any of the three running totals Codex writes says nothing reliable:
// reading the gap as zero would make the next complete line count its whole total as new.
// Such a line is skipped and the previous totals stay.
if (!TryGetLong(total, "input_tokens", out var input)
|| !TryGetLong(total, "output_tokens", out var output)
|| !TryGetLong(total, "cache_write_input_tokens", out var cacheCreation)
|| !TryGetLong(total, "cached_input_tokens", out var cacheRead))
return false;

// Codex's own rollouts carry no cache-write total at all; requiring it dropped every real
// line. Where it is missing the previous total stands, so it adds nothing either way.
var cacheCreation = TryGetLong(total, "cache_write_input_tokens", out var cacheWrite) ? cacheWrite : _cumulativeCacheCreation;

// Codex resets its running totals not only across files but also mid file, after a
// context compaction - each of the four counters is checked against its own previous
// value independently, never against the others' sum, so one counter dropping while the
Expand Down
21 changes: 21 additions & 0 deletions src/AiUsage/Stats/StatsIndexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@ private static void WalkDirectory(

using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
openLength = stream.Length;
if (IsShorterThanRead(existing, openLength))
{
// Replaced or cut between the stat above and this open: handled like a shrunk file
// rather than read again from the start (the reader falls back to 0 past the end).
_store.SetSourceFile(existing with { Offset = openLength, Size = openLength, WriteTimeUtc = info.LastWriteTimeUtc });
return (false, 0, 0);
}

consumedOffset = CompleteLineReader.Read(stream, offset, acceptTrailingLineWithoutNewline, MaxRecordBytes, LogOversizedRecord, line =>
{
if (ClaudeUsageLogParser.TryParse(line, out var usageEvent))
Expand Down Expand Up @@ -447,6 +455,14 @@ private static void WalkDirectory(

using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
openLength = stream.Length;
if (IsShorterThanRead(existing, openLength))
{
// Replaced or cut between the stat above and this open: handled like a shrunk file
// rather than read again from the start (the reader falls back to 0 past the end).
_store.SetSourceFile(existing with { Offset = openLength, Size = openLength, WriteTimeUtc = info.LastWriteTimeUtc });
return (false, 0, 0);
}

consumedOffset = CompleteLineReader.Read(stream, offset, acceptTrailingLineWithoutNewline, MaxRecordBytes, LogOversizedRecord, line =>
{
if (parser.TryParseLine(line, out var usageEvent))
Expand Down Expand Up @@ -768,6 +784,11 @@ private bool TryFindState(string path, Dictionary<string, StatsSourceFileState>
return _store.TryGetSourceFile(path, out state);
}

/// <summary>True when the opened file is shorter than what was already read of it - checked on the
/// open stream, since the file can change after it was listed.</summary>
internal static bool IsShorterThanRead([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] StatsSourceFileState? existing, long openLength) =>
existing is not null && openLength < existing.Offset;

private static FileInfo? TryStat(string path)
{
try
Expand Down
18 changes: 17 additions & 1 deletion src/AiUsage/Stats/StatsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ internal static void SnapshotIndex(string sourcePath, string destinationPath)
using var version = connection.CreateCommand();
version.CommandText = "SELECT MAX(version) FROM schema_version";
var value = version.ExecuteScalar();
return value is null or DBNull ? null : (int)Math.Min(int.MaxValue, Convert.ToInt64(value, CultureInfo.InvariantCulture));
// Only an integer is a version; a file from elsewhere (a backup zip is never trusted) can
// hold text, a real or a blob there, which Convert would throw on past every caller's catch.
return value is long number ? (int)Math.Clamp(number, int.MinValue, int.MaxValue) : null;
}
catch (SqliteException)
{
Expand Down Expand Up @@ -702,6 +704,19 @@ value TEXT NOT NULL
if (originalVersion is null or < 4)
Execute(connection, $"INSERT OR IGNORE INTO meta (key, value) VALUES ('{SessionsBackfilledKey}', '1')");

// Builds before this one required a cache-write total Codex never writes, so every Codex file
// was walked to its end without counting a line. Such a file still has all four running
// totals at zero and is read again from the start, once - only while this PC has no Codex
// usage at all, so nothing that was counted can be counted a second time.
Execute(connection, $"""
DELETE FROM source_file WHERE provider = 'codex'
AND cumulative_input_tokens = 0 AND cumulative_output_tokens = 0
AND cumulative_cache_creation_tokens = 0 AND cumulative_cache_read_tokens = 0
AND NOT EXISTS (SELECT 1 FROM usage WHERE provider = 'codex' AND machine = '')
AND NOT EXISTS (SELECT 1 FROM meta WHERE key = '{CodexRereadKey}');
INSERT OR IGNORE INTO meta (key, value) VALUES ('{CodexRereadKey}', '1');
""");

using (var writeVersion = connection.CreateCommand())
{
writeVersion.CommandText = "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, $version)";
Expand All @@ -713,6 +728,7 @@ value TEXT NOT NULL
}

private const string SessionsBackfilledKey = "sessions_backfilled";
private const string CodexRereadKey = "codex_uncounted_files_reread";

private static void Execute(SqliteConnection connection, string sql)
{
Expand Down
Loading
Loading