DEV Community

Cover image for Porting a .NET desktop app to macOS and Linux, and the bugs a VM didn't show me
KnieLemon
KnieLemon

Posted on Fully Autonomous

Porting a .NET desktop app to macOS and Linux, and the bugs a VM didn't show me

Filee is a file converter you use with a drag gesture. You hold a key, drag files, and a ring of formats opens at the cursor. Drop the files on one and the converted copies land next to the originals. It's C# on .NET 10 with Avalonia for the UI, MIT-licensed, and until last week it ran only on Windows.

Avalonia is cross-platform, so the port looked like a packaging job. It wasn't. Here is what broke, roughly in the order I found it.

I don't own a Mac

My test setup was a macOS 15 VM in VMware on Windows, plus GitHub's macOS runners. The VM built and ran the tests fine. The app itself died on start:

Avalonia.Native was not able to start the RenderTimer. Native error code is: -6661
Enter fullscreen mode Exit fullscreen mode

Avalonia drives its render loop from macOS's display link, and a VMware macOS guest without 3D acceleration has none. Turning on "Accelerate 3D graphics" in the VM settings fixed it, even though System Information still shows no Metal support. Before I found that, I made Filee show a system alert in that case instead of quitting silently.

A crash caused by a null tooltip

On the GitHub runners, which do have a display, the app also quit right after starting:

System.ArgumentNullException: Value cannot be null. (Parameter 'chars')
   at Avalonia.Native.Interop.Impl.__MicroComIAvnTrayIconProxy.SetToolTipText(String text)
Enter fullscreen mode Exit fullscreen mode

The tray icon was attached before its tooltip was set. Windows doesn't mind a null tooltip; the macOS backend passes it to native code. One line fixed it:

_tray = new TrayIcon
{
    Icon = new WindowIcon(iconStream),
    // Set before the icon is attached: on macOS the tooltip goes to native code, which can't take null.
    ToolTipText = "Filee",
    Menu = new NativeMenu { Items = { _open, _pause, new NativeMenuItemSeparator(), _quit } },
    IsVisible = true,
};
Enter fullscreen mode Exit fullscreen mode

The workflow that caught it now runs on every pull request. It installs the package on Intel and Apple silicon runners, starts the app, and saves a screenshot with screencapture -x. Those screenshots are the closest I have to a real Mac.

English on a Korean Mac

Started from my SSH session, Filee came up in Korean. Started from Finder, it came up in English. An app launched from Finder gets no LANG variable, so .NET's CultureInfo.InstalledUICulture has nothing to go on. macOS keeps the user's languages elsewhere, and CoreFoundation hands them out:

[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern nint CFLocaleCopyPreferredLanguages();
Enter fullscreen mode Exit fullscreen mode

The first entry is something like ko-KR or zh-Hans-CN. I also had to list the app's localizations in Info.plist (CFBundleLocalizations) so the system panels follow the same language.

The Dock was under every drag

The gesture only fires over the file manager. To check that, Filee asks which app owns the window under the cursor, walking CGWindowListCopyWindowInfo from front to back. On macOS the answer was always "Dock", so Option+drag never did anything.

The Dock keeps a transparent window over the whole screen. App windows sit at layer 0 and the desktop below it, so the fix is to skip everything above:

var layerValue = CFDictionaryGetValue(window, LayerKey);
if (layerValue != 0 && CFNumberGetInt(layerValue, 3, out var layer) && layer > 0)
    continue;
Enter fullscreen mode Exit fullscreen mode

I found it by adding a FILEE_TRACE_GESTURES=1 switch that logs each press, its modifiers and the window it landed on. The modifiers were right; the window was the Dock.

Finder's right-click menu: Automator didn't run

My first "Convert with Filee" entry was a Quick Action: an Automator workflow with a shell script that started a second Filee process to hand over the files. The menu item showed up. Clicking it did nothing. A workflow made in Automator itself didn't run either, and its runner processes piled up.

Instead of debugging Automator, I made Filee.app provide the service itself. Info.plist declares it:

<key>NSServices</key>
<array>
    <dict>
        <key>NSMenuItem</key><dict><key>default</key><string>Convert with Filee</string></dict>
        <key>NSMessage</key><string>convertFiles</string>
        <key>NSPortName</key><string>Filee</string>
        <key>NSRequiredContext</key><dict/>
        <key>NSSendFileTypes</key><array><string>public.item</string></array>
    </dict>
</array>
Enter fullscreen mode Exit fullscreen mode

and at start the app registers an object that answers convertFiles:userData:error:. There's no Objective-C in the project, so the class is made at run time from C#:

var type = objc_allocateClassPair(objc_getClass("NSObject"), "FileeServicesProvider", 0);
class_addMethod(type, sel_registerName("convertFiles:userData:error:"),
    Marshal.GetFunctionPointerForDelegate(Method), "v@:@@^@");
objc_registerClassPair(type);
Enter fullscreen mode Exit fullscreen mode

macOS now hands the selected files straight to the running app. No shell, no second process.

Signing a .NET app bundle

codesign on the main executable failed with "code object is not signed at all" pointing at Microsoft.CSharp.dll. A .NET app keeps its assemblies next to the executable in Contents/MacOS, and codesign treats every file there as code. Signing every file first (non-Mach-O files get their signature in extended attributes), then the executable, then the bundle, works.

A smaller one: my entitlements file came out of git archive on Windows with CRLF line endings, and codesign refused to parse it. *.plist text eol=lf in .gitattributes settled that.

Files nobody locks

Filee can watch a folder and convert what lands in it, but only once a file is complete. On Windows the check is easy: a file still being written can't be opened exclusively. macOS and Linux don't lock it, so a copy that paused for a few seconds got converted half-written, then again when it finished.

There the watcher now waits longer for a file to stop changing. On macOS it also skips files whose creation date is 24 January 1984, the date Finder gives a file while it's still copying it.

Linux and Wayland

Linux went more smoothly: Ubuntu 24.04, an install.sh that puts the app in the menu and adds file-manager actions for Nautilus, Nemo, Thunar and Dolphin. The drag gesture needs X11, though. Wayland doesn't let one app watch another's input, so there you use the drop window and the right-click action. I haven't found a good Wayland answer yet.

Where it stands

macOS and Linux are previews in 1.7. The macOS build isn't notarized yet, and everything I know about real Macs comes from CI screenshots and bug reports. If you try it on one and something breaks, the issues are open.

I wrote this article with help from an AI assistant, which I also used while writing the code. The bugs, logs and fixes above are from the project's history.

Top comments (0)