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
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 25 additions & 15 deletions installer/AiUsage.iss
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions installer/RuntimeSetup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/AiUsage/Providers/ClaudeDiscoveryScript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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'};
}
Expand Down
10 changes: 5 additions & 5 deletions src/AiUsage/Providers/ClaudeProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>Settings-driven (this account's own value, else <see cref="AppSettings.RemoteRefreshMinutes"/>,
/// default 5 min) rather than hardcoded, so <see cref="RefreshScheduler"/> 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
Expand All @@ -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.</summary>
// 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
Expand Down Expand Up @@ -246,7 +246,7 @@ public async Task<ProviderSnapshot> 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)
Expand Down
18 changes: 15 additions & 3 deletions src/AiUsage/Providers/CodexProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ public sealed partial class CodexProvider : IUsageProvider
// current walk, so it never outgrows MaxFilesToCheck.
private readonly Dictionary<string, (long Length, DateTime WriteTimeUtc)> _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();

/// <summary>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.</summary>
internal int FilesOpened { get; private set; }
Expand Down Expand Up @@ -139,8 +143,8 @@ internal CodexProvider(
public bool RunsOnUiThread => _webSource is not null;

/// <summary>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.</summary>
/// beat fewer, newer beats older), never merged. Without a web session the local read is the
/// answer as it stands.</summary>
public async Task<ProviderSnapshot> FetchAsync(CancellationToken ct)
{
var fetchedAt = _now();
Expand Down Expand Up @@ -219,6 +223,12 @@ public async Task<ProviderSnapshot> 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 3 additions & 12 deletions src/AiUsage/Providers/CopilotProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ public async Task<ProviderSnapshot> 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);
}

Expand Down Expand Up @@ -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);
}
11 changes: 2 additions & 9 deletions src/AiUsage/Providers/CursorProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,6 @@ public async Task<ProviderSnapshot> 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);
}
19 changes: 6 additions & 13 deletions src/AiUsage/Providers/GeminiProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -122,8 +122,8 @@ internal GeminiProvider(
};

/// <summary>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.</summary>
/// beat fewer, newer beats older), never merged. Without a web session the local read is the
/// answer as it stands.</summary>
public async Task<ProviderSnapshot> FetchAsync(CancellationToken ct)
{
var snapshot = await FetchSourcesAsync(ct);
Expand Down Expand Up @@ -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);
}
Loading
Loading