// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Runtime.Versioning; namespace Duplicati.Library.Snapshots.MacOS; /// /// Wrapper for native MacOS Photos library access /// [SupportedOSPlatform("macOS")] internal static class MacOSPhotosNative { /// /// The name of the native library /// private const string LibraryName = "DuplicatiPhotos"; /// /// Lists all assets in the Photos library /// /// The list of assets public static IReadOnlyList ListAssets() { var result = NativeMethods.DuplicatiPhotosEnumerateAssets(out var assetsPtr, out var count, out var errorPtr); try { if (result != 0) throw new MacOSPhotosException(ConsumeErrorMessage(ref errorPtr) ?? "Failed to enumerate Photos assets."); if (assetsPtr == IntPtr.Zero || count == UIntPtr.Zero) return Array.Empty(); var assetCount = checked((int)count.ToUInt64()); var structSize = Marshal.SizeOf(); var assets = new List(assetCount); for (var index = 0; index < assetCount; index++) { var entryPtr = assetsPtr + (index * structSize); var native = Marshal.PtrToStructure(entryPtr); var identifier = Marshal.PtrToStringUTF8(native.Identifier) ?? string.Empty; var fileName = Marshal.PtrToStringUTF8(native.FileName) ?? string.Empty; var uti = Marshal.PtrToStringUTF8(native.UniformTypeIdentifier); assets.Add(new NativeAsset( identifier, fileName, uti, (MacOSPhotoMediaType)native.MediaType, native.Size, native.PixelWidth, native.PixelHeight, NormalizeOptional(native.CreationSeconds), NormalizeOptional(native.ModificationSeconds))); } return assets; } finally { if (assetsPtr != IntPtr.Zero) NativeMethods.DuplicatiPhotosFreeAssets(assetsPtr, count); if (errorPtr != IntPtr.Zero) NativeMethods.DuplicatiPhotosFreeString(errorPtr); } } /// /// Opens a read stream for the specified asset identifier /// /// The unique identifier of the asset /// The size of the asset, or null to determine size automatically /// The read stream for the asset public static Stream OpenReadStream(string identifier, long? size = null) { if (string.IsNullOrWhiteSpace(identifier)) throw new ArgumentException("Identifier is required", nameof(identifier)); var result = NativeMethods.DuplicatiPhotosOpenAsset(identifier, out var handle, out var errorPtr); if (result != 0 || handle.IsInvalid) { var message = ConsumeErrorMessage(ref errorPtr) ?? "Failed to open Photos asset."; handle.Dispose(); throw new MacOSPhotosException(message); } // If size is not provided, try to get it from the handle var assetSize = size ?? GetAssetSize(handle); return new MacOSPhotosNativeStream(handle, assetSize); } /// /// Gets the size of the asset from the handle /// /// The asset handle /// The size of the asset internal static long GetAssetSize(SafeAssetHandle handle) { if (handle.IsInvalid) throw new ObjectDisposedException(nameof(SafeAssetHandle)); var result = NativeMethods.DuplicatiPhotosGetAssetSize(handle, out var size, out var errorPtr); if (result != 0) { var message = ConsumeErrorMessage(ref errorPtr) ?? "Failed to get Photos asset size."; throw new MacOSPhotosException(message); } if (errorPtr != IntPtr.Zero) { NativeMethods.DuplicatiPhotosFreeString(errorPtr); } return size; } /// /// Reads data from the asset into the provided buffer /// /// The asset handle /// The buffer to read data into /// The number of bytes read internal static int ReadAsset(SafeAssetHandle handle, Span buffer) { if (handle.IsInvalid) throw new ObjectDisposedException(nameof(SafeAssetHandle)); if (buffer.Length == 0) return 0; var errorPtr = IntPtr.Zero; int bytesRead; // TODO: Optimize to read directly into the provided buffer var tempbuffer = new byte[buffer.Length]; var result = NativeMethods.DuplicatiPhotosReadAsset(handle, tempbuffer, (nuint)tempbuffer.Length, out errorPtr); if (result < 0) { var message = ConsumeErrorMessage(ref errorPtr) ?? "Failed to read Photos asset data."; throw new MacOSPhotosException(message); } tempbuffer.AsSpan(0, (int)result).CopyTo(buffer); bytesRead = checked((int)result); if (errorPtr != IntPtr.Zero) { NativeMethods.DuplicatiPhotosFreeString(errorPtr); errorPtr = IntPtr.Zero; } return bytesRead; } /// /// Normalizes an optional double value, returning null if it is NaN /// /// The double value /// >The normalized value or null private static double? NormalizeOptional(double value) => double.IsNaN(value) ? null : value; /// /// Consumes an error message pointer and frees the native memory /// /// The error message pointer /// The error message string or null private static string? ConsumeErrorMessage(ref IntPtr pointer) { if (pointer == IntPtr.Zero) return null; try { return Marshal.PtrToStringUTF8(pointer); } finally { NativeMethods.DuplicatiPhotosFreeString(pointer); pointer = IntPtr.Zero; } } /// /// Represents a native asset in the Photos library /// /// The unique identifier of the asset /// The original file name /// The Uniform Type Identifier of the asset /// The media type of the asset /// The size of the asset in bytes /// The pixel width of the asset /// >The pixel height of the asset /// The creation time in seconds since Unix epoch /// >The modification time in seconds since Unix epoch public sealed record NativeAsset( string Identifier, string FileName, string? UniformTypeIdentifier, MacOSPhotoMediaType MediaType, long Size, int PixelWidth, int PixelHeight, double? CreationSeconds, double? ModificationSeconds); /// /// Native representation of an asset /// [StructLayout(LayoutKind.Sequential)] private struct NativeAssetNative { /// /// The unique identifier of the asset /// internal IntPtr Identifier; /// /// The original file name /// internal IntPtr FileName; /// /// The Uniform Type Identifier of the asset /// internal IntPtr UniformTypeIdentifier; /// /// The size of the asset in bytes /// internal long Size; /// /// The media type of the asset /// internal int MediaType; /// /// The pixel width of the asset /// internal int PixelWidth; /// /// The pixel height of the asset /// internal int PixelHeight; /// /// The creation time in seconds since Unix epoch /// internal double CreationSeconds; /// /// The modification time in seconds since Unix epoch /// internal double ModificationSeconds; } /// /// Safe handle for a Photos asset /// internal sealed class SafeAssetHandle : SafeHandle { /// /// Initializes a new instance of the class /// private SafeAssetHandle() : base(IntPtr.Zero, true) { } /// /// Indicates if the handle is invalid /// public override bool IsInvalid => handle == IntPtr.Zero; /// /// Releases the handle /// /// True if the handle was released successfully; otherwise, false protected override bool ReleaseHandle() { if (!IsInvalid) NativeMethods.DuplicatiPhotosCloseAsset(handle); return true; } } /// /// Native method imports /// private static class NativeMethods { /// /// Enumerates the assets in the Photos library /// /// The pointer to the array of assets /// The number of assets /// The error message, if any /// The result code [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern int DuplicatiPhotosEnumerateAssets(out IntPtr assets, out UIntPtr count, out IntPtr errorMessage); /// /// Frees the assets array /// /// The pointer to the array of assets /// The number of assets [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern void DuplicatiPhotosFreeAssets(IntPtr assets, UIntPtr count); /// /// Frees a string allocated by the native library /// /// The string pointer [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern void DuplicatiPhotosFreeString(IntPtr value); /// /// Opens an asset for reading /// /// The unique identifier of the asset /// The output asset handle /// The error message, if any /// The result code [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern int DuplicatiPhotosOpenAsset([MarshalAs(UnmanagedType.LPUTF8Str)] string identifier, out SafeAssetHandle handle, out IntPtr errorMessage); /// /// Reads data from the asset into the provided buffer /// /// The asset handle /// The buffer to read data into /// The length of the buffer /// The error message, if any /// The number of bytes read [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern nint DuplicatiPhotosReadAsset(SafeAssetHandle handle, byte[] buffer, nuint length, out IntPtr errorMessage); /// /// Closes the asset handle /// /// The asset handle [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern void DuplicatiPhotosCloseAsset(IntPtr handle); /// /// Gets the size of the asset /// /// The asset handle /// The size of the asset /// The error message, if any /// The result code [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern int DuplicatiPhotosGetAssetSize(SafeAssetHandle handle, out long size, out IntPtr errorMessage); } }