Skip to content

Commit 6fddffa

Browse files
committed
macospreferences: add class to read macOS app preferences
1 parent 4c32c09 commit 6fddffa

File tree

5 files changed

+289
-25
lines changed

5 files changed

+289
-25
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
using Xunit;
4+
using GitCredentialManager.Interop.MacOS;
5+
using static GitCredentialManager.Tests.TestUtils;
6+
7+
namespace GitCredentialManager.Tests.Interop.MacOS;
8+
9+
public class MacOSPreferencesTests
10+
{
11+
private const string TestAppId = "com.example.gcm-test";
12+
private const string DefaultsPath = "/usr/bin/defaults";
13+
14+
[MacOSFact]
15+
public async Task MacOSPreferences_ReadPreferences()
16+
{
17+
try
18+
{
19+
await SetupTestPreferencesAsync();
20+
21+
var pref = new MacOSPreferences(TestAppId);
22+
23+
string stringValue = pref.GetString("myString");
24+
int? intValue = pref.GetInteger("myInt");
25+
IDictionary<string, string> dictValue = pref.GetDictionary("myDict");
26+
27+
Assert.NotNull(stringValue);
28+
Assert.Equal("this is a string", stringValue);
29+
Assert.NotNull(intValue);
30+
Assert.Equal(42, intValue);
31+
Assert.NotNull(dictValue);
32+
Assert.Equal(2, dictValue.Count);
33+
Assert.Equal("value1", dictValue["dict-k1"]);
34+
Assert.Equal("value2", dictValue["dict-k2"]);
35+
}
36+
finally
37+
{
38+
await CleanupTestPreferencesAsync();
39+
}
40+
}
41+
42+
private static async Task SetupTestPreferencesAsync()
43+
{
44+
// Using the defaults command set up preferences for the test app
45+
await RunCommandAsync(DefaultsPath, $"write {TestAppId} myString \"this is a string\"");
46+
await RunCommandAsync(DefaultsPath, $"write {TestAppId} myInt -int 42");
47+
await RunCommandAsync(DefaultsPath, $"write {TestAppId} myDict -dict dict-k1 value1 dict-k2 value2");
48+
}
49+
50+
private static async Task CleanupTestPreferencesAsync()
51+
{
52+
// Delete the test app preferences
53+
// defaults delete com.example.gcm-test
54+
await RunCommandAsync(DefaultsPath, $"delete {TestAppId}");
55+
}
56+
}

src/shared/Core/Interop/MacOS/MacOSKeychain.cs

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -302,35 +302,18 @@ private static string GetStringAttribute(IntPtr dict, IntPtr key)
302302
return null;
303303
}
304304

305-
IntPtr buffer = IntPtr.Zero;
306-
try
305+
if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero)
307306
{
308-
if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero)
307+
if (CFGetTypeID(value) == CFStringGetTypeID())
309308
{
310-
if (CFGetTypeID(value) == CFStringGetTypeID())
311-
{
312-
int stringLength = (int)CFStringGetLength(value);
313-
int bufferSize = stringLength + 1;
314-
buffer = Marshal.AllocHGlobal(bufferSize);
315-
if (CFStringGetCString(value, buffer, bufferSize, CFStringEncoding.kCFStringEncodingUTF8))
316-
{
317-
return Marshal.PtrToStringAuto(buffer, stringLength);
318-
}
319-
}
320-
321-
if (CFGetTypeID(value) == CFDataGetTypeID())
322-
{
323-
int length = CFDataGetLength(value);
324-
IntPtr ptr = CFDataGetBytePtr(value);
325-
return Marshal.PtrToStringAuto(ptr, length);
326-
}
309+
return CFStringToString(value);
327310
}
328-
}
329-
finally
330-
{
331-
if (buffer != IntPtr.Zero)
311+
312+
if (CFGetTypeID(value) == CFDataGetTypeID())
332313
{
333-
Marshal.FreeHGlobal(buffer);
314+
int length = CFDataGetLength(value);
315+
IntPtr ptr = CFDataGetBytePtr(value);
316+
return Marshal.PtrToStringAuto(ptr, length);
334317
}
335318
}
336319

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using GitCredentialManager.Interop.MacOS.Native;
4+
using static GitCredentialManager.Interop.MacOS.Native.CoreFoundation;
5+
6+
namespace GitCredentialManager.Interop.MacOS;
7+
8+
public class MacOSPreferences
9+
{
10+
private readonly string _appId;
11+
12+
public MacOSPreferences(string appId)
13+
{
14+
EnsureArgument.NotNull(appId, nameof(appId));
15+
16+
_appId = appId;
17+
}
18+
19+
public string GetString(string key)
20+
{
21+
return TryGet(key, CFStringToString, out string value)
22+
? value
23+
: null;
24+
}
25+
26+
public int? GetInteger(string key)
27+
{
28+
return TryGet(key, CFNumberToInt32, out int value)
29+
? value
30+
: null;
31+
}
32+
33+
public IDictionary<string, string> GetDictionary(string key)
34+
{
35+
return TryGet(key, CFDictionaryToDictionary, out IDictionary<string, string> value)
36+
? value
37+
: null;
38+
}
39+
40+
private bool TryGet<T>(string key, Func<IntPtr, T> converter, out T value)
41+
{
42+
IntPtr cfValue = IntPtr.Zero;
43+
IntPtr keyPtr = IntPtr.Zero;
44+
IntPtr appIdPtr = CreateAppIdPtr();
45+
46+
try
47+
{
48+
keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, CFStringEncoding.kCFStringEncodingUTF8);
49+
cfValue = CFPreferencesCopyAppValue(keyPtr, appIdPtr);
50+
51+
if (cfValue == IntPtr.Zero)
52+
{
53+
value = default;
54+
return false;
55+
}
56+
57+
value = converter(cfValue);
58+
return true;
59+
}
60+
finally
61+
{
62+
if (cfValue != IntPtr.Zero) CFRelease(cfValue);
63+
if (keyPtr != IntPtr.Zero) CFRelease(keyPtr);
64+
if (appIdPtr != IntPtr.Zero) CFRelease(appIdPtr);
65+
}
66+
}
67+
68+
private IntPtr CreateAppIdPtr()
69+
{
70+
return CFStringCreateWithCString(IntPtr.Zero, _appId, CFStringEncoding.kCFStringEncodingUTF8);
71+
}
72+
}

src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Runtime.InteropServices;
34
using static GitCredentialManager.Interop.MacOS.Native.LibSystem;
45

@@ -55,6 +56,9 @@ public static extern void CFDictionaryAddValue(
5556
public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, long numBytes,
5657
CFStringEncoding encoding, bool isExternalRepresentation);
5758

59+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
60+
public static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, CFStringEncoding encoding);
61+
5862
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
5963
public static extern long CFStringGetLength(IntPtr theString);
6064

@@ -82,15 +86,130 @@ public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes,
8286
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
8387
public static extern int CFArrayGetTypeID();
8488

89+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
90+
public static extern int CFNumberGetTypeID();
91+
8592
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
8693
public static extern IntPtr CFDataGetBytePtr(IntPtr theData);
8794

8895
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
8996
public static extern int CFDataGetLength(IntPtr theData);
97+
98+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
99+
public static extern IntPtr CFPreferencesCopyAppValue(IntPtr key, IntPtr appID);
100+
101+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
102+
public static extern bool CFNumberGetValue(IntPtr number, CFNumberType theType, out IntPtr valuePtr);
103+
104+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
105+
public static extern IntPtr CFDictionaryGetKeysAndValues(IntPtr theDict, IntPtr[] keys, IntPtr[] values);
106+
107+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
108+
public static extern long CFDictionaryGetCount(IntPtr theDict);
109+
110+
public static string CFStringToString(IntPtr cfString)
111+
{
112+
if (cfString == IntPtr.Zero)
113+
{
114+
throw new ArgumentNullException(nameof(cfString));
115+
}
116+
117+
if (CFGetTypeID(cfString) != CFStringGetTypeID())
118+
{
119+
throw new InvalidOperationException("Object is not a CFString.");
120+
}
121+
122+
long length = CFStringGetLength(cfString);
123+
IntPtr buffer = Marshal.AllocHGlobal((int)length + 1);
124+
125+
try
126+
{
127+
if (!CFStringGetCString(cfString, buffer, length + 1, CFStringEncoding.kCFStringEncodingUTF8))
128+
{
129+
throw new InvalidOperationException("Failed to convert CFString to C string.");
130+
}
131+
132+
return Marshal.PtrToStringAnsi(buffer);
133+
}
134+
finally
135+
{
136+
Marshal.FreeHGlobal(buffer);
137+
}
138+
}
139+
140+
public static int CFNumberToInt32(IntPtr cfNumber)
141+
{
142+
if (cfNumber == IntPtr.Zero)
143+
{
144+
throw new ArgumentNullException(nameof(cfNumber));
145+
}
146+
147+
if (CFGetTypeID(cfNumber) != CFNumberGetTypeID())
148+
{
149+
throw new InvalidOperationException("Object is not a CFNumber.");
150+
}
151+
152+
if (!CFNumberGetValue(cfNumber, CFNumberType.kCFNumberIntType, out IntPtr valuePtr))
153+
{
154+
throw new InvalidOperationException("Failed to convert CFNumber to Int32.");
155+
}
156+
157+
return valuePtr.ToInt32();
158+
}
159+
160+
public static IDictionary<string, string> CFDictionaryToDictionary(IntPtr cfDict)
161+
{
162+
if (cfDict == IntPtr.Zero)
163+
{
164+
throw new ArgumentNullException(nameof(cfDict));
165+
}
166+
167+
if (CFGetTypeID(cfDict) != CFDictionaryGetTypeID())
168+
{
169+
throw new InvalidOperationException("Object is not a CFDictionary.");
170+
}
171+
172+
int count = (int)CFDictionaryGetCount(cfDict);
173+
var keys = new IntPtr[count];
174+
var values = new IntPtr[count];
175+
176+
CFDictionaryGetKeysAndValues(cfDict, keys, values);
177+
178+
var dict = new Dictionary<string, string>(capacity: count);
179+
for (int i = 0; i < count; i++)
180+
{
181+
string keyStr = CFStringToString(keys[i])!;
182+
string valueStr = CFStringToString(values[i]);
183+
184+
dict[keyStr] = valueStr;
185+
}
186+
187+
return dict;
188+
}
90189
}
91190

92191
public enum CFStringEncoding
93192
{
94193
kCFStringEncodingUTF8 = 0x08000100,
95194
}
195+
196+
public enum CFNumberType
197+
{
198+
kCFNumberSInt8Type = 1,
199+
kCFNumberSInt16Type = 2,
200+
kCFNumberSInt32Type = 3,
201+
kCFNumberSInt64Type = 4,
202+
kCFNumberFloat32Type = 5,
203+
kCFNumberFloat64Type = 6,
204+
kCFNumberCharType = 7,
205+
kCFNumberShortType = 8,
206+
kCFNumberIntType = 9,
207+
kCFNumberLongType = 10,
208+
kCFNumberLongLongType = 11,
209+
kCFNumberFloatType = 12,
210+
kCFNumberDoubleType = 13,
211+
kCFNumberCFIndexType = 14,
212+
kCFNumberNSIntegerType = 15,
213+
kCFNumberCGFloatType = 16
214+
}
96215
}

src/shared/TestInfrastructure/TestUtils.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Diagnostics;
23
using System.IO;
4+
using System.Threading.Tasks;
35

46
namespace GitCredentialManager.Tests
57
{
@@ -87,5 +89,37 @@ public static string GetUuid(int length = -1)
8789

8890
return uuid.Substring(0, length);
8991
}
92+
93+
public static async Task<string> RunCommandAsync(string filePath, string arguments, string workingDirectory = null)
94+
{
95+
using var process = new Process
96+
{
97+
StartInfo = new ProcessStartInfo
98+
{
99+
FileName = filePath,
100+
Arguments = arguments,
101+
RedirectStandardOutput = true,
102+
RedirectStandardError = true,
103+
UseShellExecute = false,
104+
CreateNoWindow = true,
105+
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory
106+
}
107+
};
108+
109+
process.Start();
110+
111+
string output = await process.StandardOutput.ReadToEndAsync();
112+
string error = await process.StandardError.ReadToEndAsync();
113+
114+
await process.WaitForExitAsync();
115+
116+
if (process.ExitCode != 0)
117+
{
118+
throw new InvalidOperationException(
119+
$"Command `{filePath} {arguments}` failed with exit code {process.ExitCode}. Error: {error}");
120+
}
121+
122+
return output;
123+
}
90124
}
91125
}

0 commit comments

Comments
 (0)