Skip to content

Commit 81dbcb1

Browse files
committed
Changed mac code to work directly with the mac api instead of using the security tool
1 parent 5652e03 commit 81dbcb1

4 files changed

Lines changed: 311 additions & 61 deletions

File tree

.github/workflows/build.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ jobs:
1717
- os: windows-latest
1818
rid: win-x64
1919
artifact: ClaudeUsageMonitor-win-x64
20+
- os: macos-latest
21+
rid: osx-x64
22+
artifact: ClaudeUsageMonitor-osx-x64
2023

2124
runs-on: ${{ matrix.os }}
2225

@@ -37,6 +40,12 @@ jobs:
3740
cd publish/${{ matrix.rid }}
3841
tar -czf ../../${{ matrix.artifact }}.tar.gz .
3942
43+
- name: Package (macOS)
44+
if: runner.os == 'macOS'
45+
run: |
46+
cd publish/${{ matrix.rid }}
47+
tar -czf ../../${{ matrix.artifact }}.tar.gz .
48+
4049
- name: Package (Windows)
4150
if: runner.os == 'Windows'
4251
shell: pwsh

ClaudeUsageMonitor.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<OutputType>WinExe</OutputType>
44
<TargetFramework>net10.0</TargetFramework>
55
<Nullable>enable</Nullable>
6+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
67
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
78
<ApplicationManifest>app.manifest</ApplicationManifest>
89
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>

Services/MacKeychain.cs

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using System.Text;
4+
5+
6+
namespace ClaudeUsageMonitor.Services;
7+
8+
/// <summary>
9+
/// macOS Keychain access via Security.framework P/Invoke.
10+
/// </summary>
11+
internal static partial class MacKeychain
12+
{
13+
private const string SecurityLib = "/System/Library/Frameworks/Security.framework/Security";
14+
private const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
15+
private const string LibSystem = "/usr/lib/libSystem.B.dylib";
16+
17+
private const uint kCFStringEncodingUTF8 = 0x08000100;
18+
private const int errSecSuccess = 0;
19+
private const int errSecItemNotFound = -25300;
20+
private const int errSecDuplicateItem = -25299;
21+
22+
// Lazy-loaded Security/CF constant symbols
23+
private static readonly nint SecLib;
24+
private static readonly nint CfLib;
25+
26+
private static readonly nint KSecClass;
27+
private static readonly nint KSecClassGenericPassword;
28+
private static readonly nint KSecAttrService;
29+
private static readonly nint KSecAttrAccount;
30+
private static readonly nint KSecReturnData;
31+
private static readonly nint KSecValueData;
32+
private static readonly nint KSecMatchLimit;
33+
private static readonly nint KSecMatchLimitOne;
34+
private static readonly nint KCFBooleanTrue;
35+
private static readonly nint KCFTypeDictionaryKeyCallBacks;
36+
private static readonly nint KCFTypeDictionaryValueCallBacks;
37+
38+
static MacKeychain()
39+
{
40+
SecLib = dlopen(SecurityLib, 0);
41+
CfLib = dlopen(CoreFoundationLib, 0);
42+
43+
KSecClass = ReadSymbol(SecLib, "kSecClass");
44+
KSecClassGenericPassword = ReadSymbol(SecLib, "kSecClassGenericPassword");
45+
KSecAttrService = ReadSymbol(SecLib, "kSecAttrService");
46+
KSecAttrAccount = ReadSymbol(SecLib, "kSecAttrAccount");
47+
KSecReturnData = ReadSymbol(SecLib, "kSecReturnData");
48+
KSecValueData = ReadSymbol(SecLib, "kSecValueData");
49+
KSecMatchLimit = ReadSymbol(SecLib, "kSecMatchLimit");
50+
KSecMatchLimitOne = ReadSymbol(SecLib, "kSecMatchLimitOne");
51+
KCFBooleanTrue = ReadSymbol(CfLib, "kCFBooleanTrue");
52+
53+
// These are pointers to structs — pass directly (don't dereference)
54+
KCFTypeDictionaryKeyCallBacks = dlsym(CfLib, "kCFTypeDictionaryKeyCallBacks");
55+
KCFTypeDictionaryValueCallBacks = dlsym(CfLib, "kCFTypeDictionaryValueCallBacks");
56+
}
57+
58+
/// <summary>
59+
/// Reads a generic password from the Keychain by service name.
60+
/// Returns the UTF-8 password string, or null if not found.
61+
/// </summary>
62+
public static string? Read(string serviceName)
63+
{
64+
nint service = 0;
65+
nint query = 0;
66+
67+
try
68+
{
69+
service = CreateCFString(serviceName);
70+
71+
nint[] keys =
72+
[
73+
KSecClass,
74+
KSecAttrService,
75+
KSecReturnData,
76+
KSecMatchLimit
77+
];
78+
nint[] values =
79+
[
80+
KSecClassGenericPassword,
81+
service,
82+
KCFBooleanTrue,
83+
KSecMatchLimitOne
84+
];
85+
86+
query = CFDictionaryCreate(0, keys, values, keys.Length,
87+
KCFTypeDictionaryKeyCallBacks, KCFTypeDictionaryValueCallBacks);
88+
89+
var status = SecItemCopyMatching(query, out var result);
90+
91+
if (status == errSecItemNotFound || result == 0)
92+
{
93+
return null;
94+
}
95+
96+
if (status != errSecSuccess)
97+
{
98+
throw new InvalidOperationException($"SecItemCopyMatching failed with status {status}");
99+
}
100+
101+
try
102+
{
103+
return ExtractCFDataString(result);
104+
}
105+
finally
106+
{
107+
CFRelease(result);
108+
}
109+
}
110+
finally
111+
{
112+
if (query != 0)
113+
{
114+
CFRelease(query);
115+
}
116+
if (service != 0)
117+
{
118+
CFRelease(service);
119+
}
120+
}
121+
}
122+
123+
/// <summary>
124+
/// Writes a generic password to the Keychain. If the item already exists,
125+
/// updates it atomically via SecItemUpdate (no delete step).
126+
/// </summary>
127+
public static void Write(string serviceName, string account, string data)
128+
{
129+
nint service = 0;
130+
nint acct = 0;
131+
nint passwordData = 0;
132+
nint addDict = 0;
133+
134+
try
135+
{
136+
service = CreateCFString(serviceName);
137+
acct = CreateCFString(account);
138+
var dataBytes = Encoding.UTF8.GetBytes(data);
139+
passwordData = CFDataCreate(0, dataBytes, dataBytes.Length);
140+
141+
nint[] addKeys =
142+
[
143+
KSecClass,
144+
KSecAttrService,
145+
KSecAttrAccount,
146+
KSecValueData
147+
];
148+
nint[] addValues =
149+
[
150+
KSecClassGenericPassword,
151+
service,
152+
acct,
153+
passwordData
154+
];
155+
156+
addDict = CFDictionaryCreate(0, addKeys, addValues, addKeys.Length,
157+
KCFTypeDictionaryKeyCallBacks, KCFTypeDictionaryValueCallBacks);
158+
159+
var status = SecItemAdd(addDict, 0);
160+
161+
if (status == errSecDuplicateItem)
162+
{
163+
// Item exists — update in place
164+
nint queryDict = 0;
165+
nint updateDict = 0;
166+
167+
try
168+
{
169+
nint[] queryKeys = [KSecClass, KSecAttrService, KSecAttrAccount];
170+
nint[] queryValues = [KSecClassGenericPassword, service, acct];
171+
172+
queryDict = CFDictionaryCreate(0, queryKeys, queryValues, queryKeys.Length,
173+
KCFTypeDictionaryKeyCallBacks, KCFTypeDictionaryValueCallBacks);
174+
175+
nint[] updateKeys = [KSecValueData];
176+
nint[] updateValues = [passwordData];
177+
178+
updateDict = CFDictionaryCreate(0, updateKeys, updateValues, updateKeys.Length,
179+
KCFTypeDictionaryKeyCallBacks, KCFTypeDictionaryValueCallBacks);
180+
181+
status = SecItemUpdate(queryDict, updateDict);
182+
183+
if (status != errSecSuccess)
184+
{
185+
throw new InvalidOperationException($"SecItemUpdate failed with status {status}");
186+
}
187+
}
188+
finally
189+
{
190+
if (queryDict != 0)
191+
{
192+
CFRelease(queryDict);
193+
}
194+
if (updateDict != 0)
195+
{
196+
CFRelease(updateDict);
197+
}
198+
}
199+
}
200+
else if (status != errSecSuccess)
201+
{
202+
throw new InvalidOperationException($"SecItemAdd failed with status {status}");
203+
}
204+
}
205+
finally
206+
{
207+
if (addDict != 0)
208+
{
209+
CFRelease(addDict);
210+
}
211+
if (passwordData != 0)
212+
{
213+
CFRelease(passwordData);
214+
}
215+
if (acct != 0)
216+
{
217+
CFRelease(acct);
218+
}
219+
if (service != 0)
220+
{
221+
CFRelease(service);
222+
}
223+
}
224+
}
225+
226+
private static nint ReadSymbol(nint lib, string name)
227+
{
228+
var ptr = dlsym(lib, name);
229+
if (ptr == 0)
230+
{
231+
throw new EntryPointNotFoundException($"Symbol not found: {name}");
232+
}
233+
return Marshal.ReadIntPtr(ptr);
234+
}
235+
236+
private static nint CreateCFString(string value) =>
237+
CFStringCreateWithCString(0, value, kCFStringEncodingUTF8);
238+
239+
private static string ExtractCFDataString(nint cfData)
240+
{
241+
var length = (int) CFDataGetLength(cfData);
242+
var ptr = CFDataGetBytePtr(cfData);
243+
var buffer = new byte[length];
244+
Marshal.Copy(ptr, buffer, 0, length);
245+
return Encoding.UTF8.GetString(buffer);
246+
}
247+
248+
// --- P/Invoke declarations ---
249+
250+
[LibraryImport(LibSystem, StringMarshalling = StringMarshalling.Utf8)]
251+
private static partial nint dlopen(string path, int mode);
252+
253+
[LibraryImport(LibSystem, StringMarshalling = StringMarshalling.Utf8)]
254+
private static partial nint dlsym(nint handle, string symbol);
255+
256+
[LibraryImport(CoreFoundationLib, StringMarshalling = StringMarshalling.Utf8)]
257+
private static partial nint CFStringCreateWithCString(nint alloc, string cStr, uint encoding);
258+
259+
[LibraryImport(CoreFoundationLib)]
260+
private static partial void CFRelease(nint cf);
261+
262+
[LibraryImport(CoreFoundationLib)]
263+
private static partial long CFDataGetLength(nint theData);
264+
265+
[LibraryImport(CoreFoundationLib)]
266+
private static partial nint CFDataGetBytePtr(nint theData);
267+
268+
[LibraryImport(CoreFoundationLib)]
269+
private static partial nint CFDictionaryCreate(
270+
nint allocator,
271+
nint[] keys,
272+
nint[] values,
273+
long numValues,
274+
nint keyCallBacks,
275+
nint valueCallBacks);
276+
277+
[LibraryImport(CoreFoundationLib)]
278+
private static partial nint CFDataCreate(nint allocator, byte[] bytes, long length);
279+
280+
[LibraryImport(SecurityLib)]
281+
private static partial int SecItemCopyMatching(nint query, out nint result);
282+
283+
[LibraryImport(SecurityLib)]
284+
private static partial int SecItemAdd(nint attributes, nint result);
285+
286+
[LibraryImport(SecurityLib)]
287+
private static partial int SecItemUpdate(nint query, nint attributesToUpdate);
288+
}

0 commit comments

Comments
 (0)