Skip to content

Commit 5652e03

Browse files
committed
Potential mac cred fix
1 parent 55cd5fe commit 5652e03

4 files changed

Lines changed: 104 additions & 12 deletions

File tree

App.axaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public override void OnFrameworkInitializationCompleted()
1919
{
2020
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
2121
{
22-
if (!UsageService.CredentialsFileExists())
22+
if (!UsageService.CredentialsExist())
2323
{
2424
var icons = TrayIcon.GetIcons(this);
2525
if (icons != null)

NoCredentialsWindow.axaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
FontSize="13"
3434
TextWrapping="Wrap"
3535
Margin="0,12,0,0">
36-
<Run Text="Expected credentials file: " />
36+
<Run Text="Expected credentials location: " />
3737
<Run x:Name="CredentialsPathRun" FontWeight="Bold" />
3838
</TextBlock>
3939
</StackPanel>

NoCredentialsWindow.axaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public partial class NoCredentialsWindow : Window
1010
public NoCredentialsWindow()
1111
{
1212
InitializeComponent();
13-
CredentialsPathRun.Text = UsageService.CredentialsFilePath;
13+
CredentialsPathRun.Text = UsageService.CredentialsLocation;
1414
}
1515

1616
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)

Services/UsageService.cs

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics;
34
using System.IO;
45
using System.Net;
56
using System.Net.Http;
67
using System.Net.Http.Headers;
8+
using System.Runtime.InteropServices;
79
using System.Text.Json;
810
using System.Threading.Tasks;
911
using ClaudeUsageMonitor.Models;
@@ -14,9 +16,13 @@ namespace ClaudeUsageMonitor.Services;
1416

1517
public class UsageService : IDisposable
1618
{
19+
private static readonly bool IsMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
20+
1721
private static readonly string CredentialsPath =
1822
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", ".credentials.json");
1923

24+
private const string KeychainServiceName = "Claude Code-credentials";
25+
2026
private const string TokenRefreshUrl = "https://platform.claude.com/v1/oauth/token";
2127
private const string UsageUrl = "https://api.anthropic.com/api/oauth/usage";
2228
private const string ClientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -26,9 +32,25 @@ public class UsageService : IDisposable
2632

2733
public void Dispose() => _httpClient.Dispose();
2834

29-
public static bool CredentialsFileExists() => File.Exists(CredentialsPath);
35+
public static bool CredentialsExist()
36+
{
37+
if (!IsMacOS)
38+
return File.Exists(CredentialsPath);
3039

31-
public static string CredentialsFilePath => CredentialsPath;
40+
try
41+
{
42+
var json = ReadKeychain();
43+
return json != null;
44+
}
45+
catch
46+
{
47+
return false;
48+
}
49+
}
50+
51+
public static string CredentialsLocation => IsMacOS
52+
? $"macOS Keychain (service: \"{KeychainServiceName}\")"
53+
: CredentialsPath;
3254

3355
public async Task<UsageResponse?> GetUsageAsync()
3456
{
@@ -103,19 +125,85 @@ private readonly record struct UsageRequestResult(
103125

104126
private async Task LoadCredentialsAsync()
105127
{
106-
// Always read fresh from Claude Code's credentials file so we pick up any
107-
// refresh it performs, and never hold a stale refresh_token that the server
108-
// has rotated away under us.
109-
if (!File.Exists(CredentialsPath))
128+
// Always read fresh so we pick up any refresh Claude Code performs,
129+
// and never hold a stale refresh_token that the server has rotated.
130+
string? json;
131+
132+
if (IsMacOS)
110133
{
111-
throw new FileNotFoundException($"No credentials file found at {CredentialsPath}");
134+
json = ReadKeychain();
135+
if (json == null)
136+
throw new InvalidOperationException(
137+
$"No credentials found in macOS Keychain (service: \"{KeychainServiceName}\")");
138+
}
139+
else
140+
{
141+
if (!File.Exists(CredentialsPath))
142+
throw new FileNotFoundException($"No credentials file found at {CredentialsPath}");
143+
144+
json = await File.ReadAllTextAsync(CredentialsPath);
112145
}
113146

114-
var json = await File.ReadAllTextAsync(CredentialsPath);
115147
var credFile = JsonSerializer.Deserialize(json, UsageJsonContext.Default.CredentialsFile);
116148
_credentials = credFile?.ClaudeAiOauth;
117149
}
118150

151+
private static string? ReadKeychain()
152+
{
153+
using var process = Process.Start(new ProcessStartInfo
154+
{
155+
FileName = "/usr/bin/security",
156+
Arguments = $"find-generic-password -s \"{KeychainServiceName}\" -w",
157+
RedirectStandardOutput = true,
158+
RedirectStandardError = true,
159+
UseShellExecute = false,
160+
CreateNoWindow = true
161+
});
162+
163+
if (process == null)
164+
return null;
165+
166+
var output = process.StandardOutput.ReadToEnd().Trim();
167+
process.WaitForExit();
168+
169+
return process.ExitCode == 0 && output.Length > 0 ? output : null;
170+
}
171+
172+
private static void WriteKeychain(string json)
173+
{
174+
// Delete the existing entry first (security add fails if it already exists)
175+
using var delete = Process.Start(new ProcessStartInfo
176+
{
177+
FileName = "/usr/bin/security",
178+
Arguments = $"delete-generic-password -s \"{KeychainServiceName}\"",
179+
RedirectStandardOutput = true,
180+
RedirectStandardError = true,
181+
UseShellExecute = false,
182+
CreateNoWindow = true
183+
});
184+
delete?.WaitForExit();
185+
186+
using var add = Process.Start(new ProcessStartInfo
187+
{
188+
FileName = "/usr/bin/security",
189+
Arguments = $"add-generic-password -s \"{KeychainServiceName}\" -a \"{Environment.UserName}\" -w \"{json.Replace("\"", "\\\"")}\"",
190+
RedirectStandardOutput = true,
191+
RedirectStandardError = true,
192+
UseShellExecute = false,
193+
CreateNoWindow = true
194+
});
195+
196+
if (add == null)
197+
return;
198+
199+
add.WaitForExit();
200+
if (add.ExitCode != 0)
201+
{
202+
var error = add.StandardError.ReadToEnd();
203+
throw new InvalidOperationException($"Failed to write credentials to Keychain: {error}");
204+
}
205+
}
206+
119207
private async Task RefreshTokenAsync()
120208
{
121209
if (_credentials?.RefreshToken == null)
@@ -164,6 +252,10 @@ private async Task RefreshTokenAsync()
164252
ClaudeAiOauth = _credentials
165253
};
166254
var updatedJson = JsonSerializer.Serialize(credFile, UsageJsonContext.Default.CredentialsFile);
167-
await File.WriteAllTextAsync(CredentialsPath, updatedJson);
255+
256+
if (IsMacOS)
257+
WriteKeychain(updatedJson);
258+
else
259+
await File.WriteAllTextAsync(CredentialsPath, updatedJson);
168260
}
169261
}

0 commit comments

Comments
 (0)