// 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);
}
}