diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index 049f6fb23..d36904140 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -53,9 +53,9 @@ static IReadOnlyList AppNames(IEnumerable names) } /// - /// The existing file or folder the shortcut at points to (a .lnk on Windows); null for - /// anything else, a broken shortcut, or a platform whose shortcuts Filee doesn't follow (macOS aliases and Linux - /// .desktop links aren't followed yet). + /// The existing file or folder the shortcut at points to (a .lnk on Windows, a Finder + /// alias on macOS); null for anything else, a broken shortcut, or a platform whose shortcuts Filee doesn't follow + /// (Linux .desktop links aren't followed yet). /// string? ShortcutTarget(string path) => null; diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index 8fe002a5e..a7a53a6b5 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -34,6 +34,8 @@ public sealed class MacOSPlatformServices : IPlatformServices public TrashRefusal? MoveToTrash(string path) => NativeMethods.MoveToTrash(path); + public string? ShortcutTarget(string path) => NativeMethods.AliasTarget(path); + public bool IsFileManagerAt(int x, int y) => string.Equals(ProcessNameAt(x, y), "Finder", StringComparison.OrdinalIgnoreCase); diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs index 2fe25218a..507ff0f2d 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -16,6 +16,11 @@ internal static class NativeMethods private static readonly nint PidKey = CFStringCreateWithCString(0, "kCGWindowOwnerPID", 0x08000100); private static readonly nint AlphaKey = CFStringCreateWithCString(0, "kCGWindowAlpha", 0x08000100); private static readonly nint LayerKey = CFStringCreateWithCString(0, "kCGWindowLayer", 0x08000100); + private static readonly nint IsAliasFileKey = CoreFoundationConstant("kCFURLIsAliasFileKey"); + private static readonly nint BooleanTrue = CoreFoundationConstant("kCFBooleanTrue"); + + private static nint CoreFoundationConstant(string name) => + Marshal.ReadIntPtr(NativeLibrary.GetExport(NativeLibrary.Load(CoreFoundation), name)); /// Process names of the app windows on screen (layer 0), front to back. internal static IEnumerable WindowOwnerNames() @@ -209,6 +214,129 @@ internal static bool SetShowInDock(bool show) [return: MarshalAs(UnmanagedType.I1)] private static extern bool SendTrash(nint receiver, nint selector, nint url, nint resultingUrl, out nint error); + /// + /// The existing file or folder the Finder alias at points to; null for anything that + /// isn't an alias, a broken alias, one whose target is in a Trash, or one whose target is on a volume that isn't + /// mounted: resolving never shows UI or mounts a server, since a drag waits on it. + /// + internal static string? AliasTarget(string path) + { + const nuint withoutUi = 1 << 8, withoutMounting = 1 << 9; + var url = FileUrl(path); + if (url == 0) + return null; + nint data = 0, target = 0; + try + { + // Every dropped path comes through here, on the UI thread: only an alias (metadata, no read) is opened. + if (!IsAliasFile(url)) + return null; + data = CFURLCreateBookmarkDataFromFile(0, url, out var error); + if (data == 0) + { + Release(error); + return null; + } + target = CFURLCreateByResolvingBookmarkData(0, data, withoutUi | withoutMounting, 0, 0, out _, out error); + Release(error); + if (target == 0) + return null; + var buffer = new byte[4096]; + if (!CFURLGetFileSystemRepresentation(target, true, buffer, buffer.Length)) + return null; + // The file system representation is decomposed (NFD); dropped paths and the names Filee writes are + // composed, and APFS and HFS+ find the file either way, so a "보고서" alias and its target match. + var resolved = System.Text.Encoding.UTF8.GetString(buffer, 0, Array.IndexOf(buffer, (byte)0)) + .Normalize(System.Text.NormalizationForm.FormC); + // A bookmark follows its file into the Trash, where a converted copy would be lost: that alias is broken. + return !InTrash(resolved) && (File.Exists(resolved) || Directory.Exists(resolved)) ? resolved : null; + } + finally + { + Release(target); + Release(data); + CFRelease(url); + } + } + + /// Writes a Finder alias at to (for the tests). + internal static void CreateAlias(string path, string target) + { + const nuint suitableForBookmarkFile = 1 << 10; + var targetUrl = FileUrl(target); + var aliasUrl = FileUrl(path); + nint data = 0; + try + { + data = CFURLCreateBookmarkData(0, targetUrl, suitableForBookmarkFile, 0, 0, out var error); + if (data == 0 || !CFURLWriteBookmarkDataToFile(data, aliasUrl, 0, out error)) + { + Release(error); + throw new IOException($"Could not write an alias at {path}."); + } + } + finally + { + Release(data); + Release(aliasUrl); + Release(targetUrl); + } + } + + private static bool IsAliasFile(nint url) + { + if (!CFURLCopyResourcePropertyForKey(url, IsAliasFileKey, out var value, out var error)) + { + Release(error); + return false; + } + var isAlias = value == BooleanTrue; + Release(value); + return isAlias; + } + + private static bool InTrash(string path) + { + var trash = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".Trash") + "/"; + return (path + "/").StartsWith(trash, StringComparison.Ordinal) || path.Contains("/.Trashes/", StringComparison.Ordinal); + } + + private static nint FileUrl(string path) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(path)); + return CFURLCreateFromFileSystemRepresentation(0, bytes, bytes.Length, Directory.Exists(path)); + } + + private static void Release(nint value) + { + if (value != 0) + CFRelease(value); + } + + [DllImport(CoreFoundation)] + private static extern nint CFURLCreateFromFileSystemRepresentation(nint allocator, byte[] buffer, nint length, [MarshalAs(UnmanagedType.I1)] bool isDirectory); + + [DllImport(CoreFoundation)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool CFURLGetFileSystemRepresentation(nint url, [MarshalAs(UnmanagedType.I1)] bool resolveAgainstBase, byte[] buffer, nint maxLength); + + [DllImport(CoreFoundation)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool CFURLCopyResourcePropertyForKey(nint url, nint key, out nint value, out nint error); + + [DllImport(CoreFoundation)] + private static extern nint CFURLCreateBookmarkDataFromFile(nint allocator, nint fileUrl, out nint error); + + [DllImport(CoreFoundation)] + private static extern nint CFURLCreateByResolvingBookmarkData(nint allocator, nint bookmark, nuint options, nint relativeToUrl, nint resourcePropertiesToInclude, [MarshalAs(UnmanagedType.I1)] out bool isStale, out nint error); + + [DllImport(CoreFoundation)] + private static extern nint CFURLCreateBookmarkData(nint allocator, nint url, nuint options, nint resourcePropertiesToInclude, nint relativeToUrl, out nint error); + + [DllImport(CoreFoundation)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool CFURLWriteBookmarkDataToFile(nint bookmark, nint fileUrl, nuint options, out nint error); + private readonly struct AutoreleasePool : IDisposable { private readonly nint _pool = Send(Send(objc_getClass("NSAutoreleasePool"), sel_registerName("alloc")), sel_registerName("init")); diff --git a/tests/Filee.Platform.Tests/MacOSAliasTests.cs b/tests/Filee.Platform.Tests/MacOSAliasTests.cs new file mode 100644 index 000000000..d496c7066 --- /dev/null +++ b/tests/Filee.Platform.Tests/MacOSAliasTests.cs @@ -0,0 +1,108 @@ +// Finder aliases dropped on Filee convert the file they point to, like Windows shortcuts (#159). + +using Filee.Platform.MacOS; + +// Each test checks for macOS before it touches the native methods. +#pragma warning disable CA1416 + +namespace Filee.Platform.Tests; + +public sealed class MacOSAliasTests : IDisposable +{ + // The temporary folder is under /var, a symbolic link to /private/var, and a resolved alias gives the real path. + private readonly string _folder = RealPath(Directory.CreateTempSubdirectory("filee-alias-").FullName); + + private static string RealPath(string path) => + path.StartsWith("/var/", StringComparison.Ordinal) ? "/private" + path : path; + + public void Dispose() + { + try { Directory.Delete(_folder, true); } catch (IOException) { } + } + + private string Write(string name) + { + var path = Path.Combine(_folder, name); + File.WriteAllText(path, "x"); + return path; + } + + private string Alias(string name, string target) + { + var path = Path.Combine(_folder, name); + NativeMethods.CreateAlias(path, target); + return path; + } + + [Fact] + public void An_alias_resolves_to_its_file_or_folder_and_a_broken_one_to_nothing() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Finder aliases need macOS."); + return; + } + var report = Write("보고서 report.docx"); + var folder = Directory.CreateDirectory(Path.Combine(_folder, "photos")).FullName; + var gone = Write("gone.pdf"); + var toReport = Alias("report.docx alias", report); + var toFolder = Alias("photos alias", folder); + var broken = Alias("gone alias", gone); + File.Delete(gone); + var platform = new MacOSPlatformServices(); + + Assert.Equal(report, platform.ShortcutTarget(toReport)); + Assert.Equal(folder, platform.ShortcutTarget(toFolder)); + Assert.Null(platform.ShortcutTarget(broken)); + Assert.Null(platform.ShortcutTarget(report)); + Assert.Null(platform.ShortcutTarget(folder)); + Assert.Null(platform.ShortcutTarget(Path.Combine(_folder, "missing alias"))); + } + + [Fact] + public void An_alias_to_a_file_in_a_trash_is_broken() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Finder aliases need macOS."); + return; + } + // The layout of a volume's Trash; the user's ~/.Trash is matched the same way. + var trash = Directory.CreateDirectory(Path.Combine(_folder, ".Trashes", "501")).FullName; + var trashed = Path.Combine(trash, "report.docx"); + File.WriteAllText(trashed, "x"); + var toTrashed = Alias("report alias", trashed); + + Assert.Null(new MacOSPlatformServices().ShortcutTarget(toTrashed)); + } + + [Fact] + public void Writing_an_alias_to_a_missing_file_throws() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Finder aliases need macOS."); + return; + } + Assert.Throws(() => Alias("missing alias", Path.Combine(_folder, "missing.pdf"))); + } + + [Fact] + public void An_alias_dropped_with_its_target_gives_the_file_once_and_a_broken_one_stays() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Finder aliases need macOS."); + return; + } + var report = Write("report.docx"); + var photo = Write("photo.jpg"); + var toReport = Alias("report alias", report); + var broken = Write("broken alias"); + Core.Platform.IPlatformServices platform = new MacOSPlatformServices(); + + var files = platform.ResolveShortcuts([toReport, photo, report, broken]); + + Assert.Equal([report, photo, broken], files); + } +}