Skip to content

Commit

Permalink
macospreferences: add class to read macOS app preferences
Browse files Browse the repository at this point in the history
  • Loading branch information
mjcheetham committed Jan 27, 2025
1 parent 4c32c09 commit 5f6d32a
Show file tree
Hide file tree
Showing 5 changed files with 327 additions and 25 deletions.
66 changes: 66 additions & 0 deletions src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
using GitCredentialManager.Interop.MacOS;
using static GitCredentialManager.Tests.TestUtils;

namespace GitCredentialManager.Tests.Interop.MacOS;

public class MacOSPreferencesTests
{
private const string TestAppId = "com.example.gcm-test";
private const string DefaultsPath = "/usr/bin/defaults";

[MacOSFact]
public async Task MacOSPreferences_ReadPreferences()
{
try
{
await SetupTestPreferencesAsync();

var pref = new MacOSPreferences(TestAppId);

// Exists
string stringValue = pref.GetString("myString");
int? intValue = pref.GetInteger("myInt");
IDictionary<string, string> dictValue = pref.GetDictionary("myDict");

Assert.NotNull(stringValue);
Assert.Equal("this is a string", stringValue);
Assert.NotNull(intValue);
Assert.Equal(42, intValue);
Assert.NotNull(dictValue);
Assert.Equal(2, dictValue.Count);
Assert.Equal("value1", dictValue["dict-k1"]);
Assert.Equal("value2", dictValue["dict-k2"]);

// Does not exist
string missingString = pref.GetString("missingString");
int? missingInt = pref.GetInteger("missingInt");
IDictionary<string, string> missingDict = pref.GetDictionary("missingDict");

Assert.Null(missingString);
Assert.Null(missingInt);
Assert.Null(missingDict);
}
finally
{
await CleanupTestPreferencesAsync();
}
}

private static async Task SetupTestPreferencesAsync()
{
// Using the defaults command set up preferences for the test app
await RunCommandAsync(DefaultsPath, $"write {TestAppId} myString \"this is a string\"");
await RunCommandAsync(DefaultsPath, $"write {TestAppId} myInt -int 42");
await RunCommandAsync(DefaultsPath, $"write {TestAppId} myDict -dict dict-k1 value1 dict-k2 value2");
}

private static async Task CleanupTestPreferencesAsync()
{
// Delete the test app preferences
// defaults delete com.example.gcm-test
await RunCommandAsync(DefaultsPath, $"delete {TestAppId}");
}
}
33 changes: 8 additions & 25 deletions src/shared/Core/Interop/MacOS/MacOSKeychain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,35 +302,18 @@ private static string GetStringAttribute(IntPtr dict, IntPtr key)
return null;
}

IntPtr buffer = IntPtr.Zero;
try
if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero)
{
if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero)
if (CFGetTypeID(value) == CFStringGetTypeID())
{
if (CFGetTypeID(value) == CFStringGetTypeID())
{
int stringLength = (int)CFStringGetLength(value);
int bufferSize = stringLength + 1;
buffer = Marshal.AllocHGlobal(bufferSize);
if (CFStringGetCString(value, buffer, bufferSize, CFStringEncoding.kCFStringEncodingUTF8))
{
return Marshal.PtrToStringAuto(buffer, stringLength);
}
}

if (CFGetTypeID(value) == CFDataGetTypeID())
{
int length = CFDataGetLength(value);
IntPtr ptr = CFDataGetBytePtr(value);
return Marshal.PtrToStringAuto(ptr, length);
}
return CFStringToString(value);
}
}
finally
{
if (buffer != IntPtr.Zero)

if (CFGetTypeID(value) == CFDataGetTypeID())
{
Marshal.FreeHGlobal(buffer);
int length = CFDataGetLength(value);
IntPtr ptr = CFDataGetBytePtr(value);
return Marshal.PtrToStringAuto(ptr, length);
}
}

Expand Down
96 changes: 96 additions & 0 deletions src/shared/Core/Interop/MacOS/MacOSPreferences.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using GitCredentialManager.Interop.MacOS.Native;
using static GitCredentialManager.Interop.MacOS.Native.CoreFoundation;

namespace GitCredentialManager.Interop.MacOS;

public class MacOSPreferences
{
private readonly string _appId;

public MacOSPreferences(string appId)
{
EnsureArgument.NotNull(appId, nameof(appId));

_appId = appId;
}

/// <summary>
/// Return a <see cref="string"/> typed value from the app preferences.
/// </summary>
/// <param name="key">Preference name.</param>
/// <exception cref="InvalidOperationException">Thrown if the preference is not a string.</exception>
/// <returns>
/// <see cref="string"/> or null if the preference with the given key does not exist.
/// </returns>
public string GetString(string key)
{
return TryGet(key, CFStringToString, out string value)
? value
: null;
}

/// <summary>
/// Return a <see cref="int"/> typed value from the app preferences.
/// </summary>
/// <param name="key">Preference name.</param>
/// <exception cref="InvalidOperationException">Thrown if the preference is not an integer.</exception>
/// <returns>
/// <see cref="int"/> or null if the preference with the given key does not exist.
/// </returns>
public int? GetInteger(string key)
{
return TryGet(key, CFNumberToInt32, out int value)
? value
: null;
}

/// <summary>
/// Return a <see cref="IDictionary{TKey,TValue}"/> typed value from the app preferences.
/// </summary>
/// <param name="key">Preference name.</param>
/// <exception cref="InvalidOperationException">Thrown if the preference is not a dictionary.</exception>
/// <returns>
/// <see cref="IDictionary{TKey,TValue}"/> or null if the preference with the given key does not exist.
/// </returns>
public IDictionary<string, string> GetDictionary(string key)
{
return TryGet(key, CFDictionaryToDictionary, out IDictionary<string, string> value)
? value
: null;
}

private bool TryGet<T>(string key, Func<IntPtr, T> converter, out T value)
{
IntPtr cfValue = IntPtr.Zero;
IntPtr keyPtr = IntPtr.Zero;
IntPtr appIdPtr = CreateAppIdPtr();

try
{
keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, CFStringEncoding.kCFStringEncodingUTF8);
cfValue = CFPreferencesCopyAppValue(keyPtr, appIdPtr);

if (cfValue == IntPtr.Zero)
{
value = default;
return false;
}

value = converter(cfValue);
return true;
}
finally
{
if (cfValue != IntPtr.Zero) CFRelease(cfValue);
if (keyPtr != IntPtr.Zero) CFRelease(keyPtr);
if (appIdPtr != IntPtr.Zero) CFRelease(appIdPtr);
}
}

private IntPtr CreateAppIdPtr()
{
return CFStringCreateWithCString(IntPtr.Zero, _appId, CFStringEncoding.kCFStringEncodingUTF8);
}
}
119 changes: 119 additions & 0 deletions src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using static GitCredentialManager.Interop.MacOS.Native.LibSystem;

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

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, CFStringEncoding encoding);

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern long CFStringGetLength(IntPtr theString);

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

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern int CFNumberGetTypeID();

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr CFDataGetBytePtr(IntPtr theData);

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern int CFDataGetLength(IntPtr theData);

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr CFPreferencesCopyAppValue(IntPtr key, IntPtr appID);

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern bool CFNumberGetValue(IntPtr number, CFNumberType theType, out IntPtr valuePtr);

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr CFDictionaryGetKeysAndValues(IntPtr theDict, IntPtr[] keys, IntPtr[] values);

[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern long CFDictionaryGetCount(IntPtr theDict);

public static string CFStringToString(IntPtr cfString)
{
if (cfString == IntPtr.Zero)
{
throw new ArgumentNullException(nameof(cfString));
}

if (CFGetTypeID(cfString) != CFStringGetTypeID())
{
throw new InvalidOperationException("Object is not a CFString.");
}

long length = CFStringGetLength(cfString);
IntPtr buffer = Marshal.AllocHGlobal((int)length + 1);

try
{
if (!CFStringGetCString(cfString, buffer, length + 1, CFStringEncoding.kCFStringEncodingUTF8))
{
throw new InvalidOperationException("Failed to convert CFString to C string.");
}

return Marshal.PtrToStringAnsi(buffer);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}

public static int CFNumberToInt32(IntPtr cfNumber)
{
if (cfNumber == IntPtr.Zero)
{
throw new ArgumentNullException(nameof(cfNumber));
}

if (CFGetTypeID(cfNumber) != CFNumberGetTypeID())
{
throw new InvalidOperationException("Object is not a CFNumber.");
}

if (!CFNumberGetValue(cfNumber, CFNumberType.kCFNumberIntType, out IntPtr valuePtr))
{
throw new InvalidOperationException("Failed to convert CFNumber to Int32.");
}

return valuePtr.ToInt32();
}

public static IDictionary<string, string> CFDictionaryToDictionary(IntPtr cfDict)
{
if (cfDict == IntPtr.Zero)
{
throw new ArgumentNullException(nameof(cfDict));
}

if (CFGetTypeID(cfDict) != CFDictionaryGetTypeID())
{
throw new InvalidOperationException("Object is not a CFDictionary.");
}

int count = (int)CFDictionaryGetCount(cfDict);
var keys = new IntPtr[count];
var values = new IntPtr[count];

CFDictionaryGetKeysAndValues(cfDict, keys, values);

var dict = new Dictionary<string, string>(capacity: count);
for (int i = 0; i < count; i++)
{
string keyStr = CFStringToString(keys[i])!;
string valueStr = CFStringToString(values[i]);

dict[keyStr] = valueStr;
}

return dict;
}
}

public enum CFStringEncoding
{
kCFStringEncodingUTF8 = 0x08000100,
}

public enum CFNumberType
{
kCFNumberSInt8Type = 1,
kCFNumberSInt16Type = 2,
kCFNumberSInt32Type = 3,
kCFNumberSInt64Type = 4,
kCFNumberFloat32Type = 5,
kCFNumberFloat64Type = 6,
kCFNumberCharType = 7,
kCFNumberShortType = 8,
kCFNumberIntType = 9,
kCFNumberLongType = 10,
kCFNumberLongLongType = 11,
kCFNumberFloatType = 12,
kCFNumberDoubleType = 13,
kCFNumberCFIndexType = 14,
kCFNumberNSIntegerType = 15,
kCFNumberCGFloatType = 16
}
}
Loading

0 comments on commit 5f6d32a

Please sign in to comment.