Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Filee.Core/Platform/IPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ static IReadOnlyList<string> AppNames(IEnumerable<string?> names)
}

/// <summary>
/// The existing file or folder the shortcut at <paramref name="path"/> 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 <paramref name="path"/> 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).
/// </summary>
string? ShortcutTarget(string path) => null;

Expand Down
2 changes: 2 additions & 0 deletions src/Filee.Platform.MacOS/MacOSPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
128 changes: 128 additions & 0 deletions src/Filee.Platform.MacOS/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

/// <summary>Process names of the app windows on screen (layer 0), front to back.</summary>
internal static IEnumerable<string> WindowOwnerNames()
Expand Down Expand Up @@ -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);

/// <summary>
/// The existing file or folder the Finder alias at <paramref name="path"/> 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.
/// </summary>
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);
}
}

/// <summary>Writes a Finder alias at <paramref name="path"/> to <paramref name="target"/> (for the tests).</summary>
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"));
Expand Down
108 changes: 108 additions & 0 deletions tests/Filee.Platform.Tests/MacOSAliasTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOException>(() => 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);
}
}
Loading