Avalonia: macOS and Linux support (multi-target, hotkeys, themes, plugins, packaging)

Draft
TA
2h ago
+20280-8838

Summary

Makes the Avalonia Flow Launcher usable on macOS (Apple Silicon) and Linux (x64/arm64, Wayland and X11), without changing Windows behavior.

  • macOS: ships as a signed menu-bar .app with a global hotkey (default ⌥Space), Flow themes, app indexing and 10 bundled plugins.
  • Linux: a native Wayland client with an X11 fallback. It uses global hotkeys through the GlobalShortcuts portal or X key grabs, indexes desktop entries, sends notifications to the desktop's notification server, and starts every program in its own systemd scope. It ships as a self-contained tarball, and there is a user install script for NixOS.

Structure

  • Targets. Flow.Launcher.Plugin, Infrastructure, Core, Avalonia and 10 bundled plugins target net10.0;net10.0-windows10.0.19041.0. net10.0 covers macOS and Linux. The Explorer and WindowsSettings plugins stay Windows-only.
  • No #if. Platform code is split by file, with the rules defined in Directory.Build.targets:
    • *.Windows.cs compiles only for the Windows target.
    • *.CrossPlatform.cs compiles only for net10.0.
    • *.xaml.cs (WPF code-behind) is excluded from net10.0.
    • Types are partial, and shared code calls platform behavior through partial methods. Inside *.CrossPlatform.cs, macOS and Linux branch on OperatingSystem.IsMacOS() / IsLinux(). Windows code was moved verbatim.
  • Shared Win32/registry code. It stays in shared files and is marked [SupportedOSPlatform("windows")]. .editorconfig keeps CA1416 silent, because CsWin32's per-API version annotations would flood the Windows build with warnings.
  • WPF enums on net10.0. Key, ModifierKeys, MessageBox* and WindowState are generated from the WPF reference assemblies with identical names and values. Settings and hotkeys therefore stay compatible.

Shared changes (all platforms)

  • Themes. ThemeLoader parses Flow's WPF theme .xaml with System.Xml, then resolves BasedOn styles, Base.xaml, dynamic and static resources, and Light/Dark palettes into Avalonia resources. Themes switch live, and the settings preview uses the same resources. Before this change, the Avalonia main window ignored Flow themes on every OS.
  • SVG icons. ImageLoader renders .svg through Svg.Controls.Avalonia, which the app already pulled in via Markdown.Avalonia.
  • Plugin loader. It now reuses identical assemblies the host ships, even if they are not loaded yet. Before, plugins loaded their own MemoryPack.Core, so the host could not serialize plugin caches; for example, the Program index was rebuilt on every start.
  • Stale home page results. Typing quickly cancels each query before its first batch arrives, and that batch was the one that replaced the home page results. The home page then stayed below the new results, and the window stayed at full height. A pending clear is now kept until a batch actually applies it.
  • SharedCommands.AppProcess. New SDK helper for starting programs on the user's behalf. On Linux it uses a per-app systemd scope (see below); elsewhere it is Process.Start. Program, Shell, Url, SearchWeb, FilesFolders and the host's open/run paths use it.
  • Flow.Launcher.Localization 0.0.7. 0.0.6 only matched \Languages\ paths, so Localize was not generated off Windows.

macOS features

  • Hotkeys. Global hotkeys use Carbon RegisterEventHotKey, which needs no Accessibility permission. Registration is exclusive and detects conflicts with system shortcuts. The hotkey recorder follows the active keyboard layout.
  • Window behavior. NSApp activateIgnoringOtherApps brings Flow to the front when it is shown. Hiding Flow returns focus to the previous app. The window opens on the current Space.
  • Icons. .icns and .app icons are converted once with sips and cached as PNG. Plugin icon paths written with \ are normalized.
  • Plugins:
    • Program: indexes .app bundles, falling back to plutil for binary or damaged plists.
    • Sys: power commands via pmset / osascript, plus Trash.
    • Shell: runs $SHELL -l -c, or opens Terminal.app.
    • ProcessKiller: works.
    • BrowserBookmark: reads Chromium and Firefox macOS profiles.
    • Url: opens a custom browser with open -a.
    • PluginsManager: works.
  • Host services.
    • ShowMsgBox uses a real osascript dialog. Previously it returned None, which let PluginsManager install plugins without confirmation.
    • Folders open in Finder via open / open -R.
    • Python and Node are found from the login shell or Homebrew.
    • Windows-only settings are hidden.
  • Packaging. Scripts/macos/build-app.sh builds Output/macOS/Flow Launcher.app: self-contained, ad-hoc signed, LSUIElement (no Dock icon). Start at login is handled by a LaunchAgent. Build and install steps are in docs/macos.md.

Linux features

  • Windowing.
    • Native Wayland via Avalonia.Wayland (UseWaylandWithFallback). FLOW_LAUNCHER_BACKEND=x11 forces XWayland/X11.
    • The search window and notifications have their own app ids (com.flowlauncher.FlowLauncher.Search / .Notification), so compositor rules can match them. Under X11 the search window is _NET_WM_WINDOW_TYPE_DIALOG, so tiling window managers float it without rules.
    • Hyprland keeps floating Wayland windows at their mapped size and ignores client resizes. Under Hyprland the search window therefore reserves the height of the longest list, keeps its border top-aligned and leaves the rest transparent. docs/linux.md has the window rules for Hyprland, Sway and niri.
  • Hotkeys.
    • Wayland: org.freedesktop.portal.GlobalShortcuts, after registering the app id with the portal's host Registry.
    • X11: XCB key grabs that ignore Num Lock and Caps Lock and report conflicts.
    • Without either: Flow.Launcher.Avalonia --toggle, sent to the running instance through the single-instance socket. It can be bound in any compositor.
  • Desktop integration.
    • Flow installs its desktop entry on start, unless a package already provides one. Start at login uses an XDG autostart entry.
    • Notifications go to org.freedesktop.Notifications (GNOME, KDE, mako, dunst, …), and buttons become notification actions. Flow's own notification window is only a fallback when no server is running.
    • Files are revealed through org.freedesktop.FileManager1.
    • Blur themes are drawn opaque, because there is no portable blur protocol.
    • The runtime picker for plugin environments uses zenity or kdialog.
  • Per-app scopes. Programs started from Flow run in app-flowlauncher-<app>-<random>.scope, created by systemd-run --user --scope. Under uwsm this is in app-graphical.slice, like uwsm app. They therefore survive Flow exiting, restarting or crashing. Before, stopping Flow's autostart unit killed everything launched from it. AppProcess waits until the process is in its scope before returning, so an immediate Flow exit (e.g. RestartApp) cannot take it down.
  • Plugins:
    • Program: indexes desktop entries from the XDG data dirs, including Flatpak and Nix profiles. It honors NoDisplay, Hidden, OnlyShowIn / NotShowIn and TryExec, parses Exec, and resolves icons from the GTK/KDE icon theme (SVG preferred). Terminal=true entries run in $TERMINAL or a known terminal.
    • Sys: power commands via systemctl, session and lock via loginctl, and the freedesktop Trash.
    • Shell: runs $SHELL, and opens a terminal for "leave open".
    • BrowserBookmark: reads Chromium-family and Firefox profiles, including Flatpak and Snap.
    • Url: works.
    • ProcessKiller: hides session-critical processes.
  • Packaging.
    • Scripts/linux/build.sh [linux-x64|linux-arm64] builds a self-contained tarball. It runs in CI as the build-linux job for both RIDs.
    • flake.nix provides a dev shell (.NET 10 SDK, jj, native libs) and a runtime-libs package.
    • Scripts/linux/install-nixos.sh installs into ~/.local. It patches the apphost's loader and every native library's RUNPATH to the GC-rooted runtime-libs, so no LD_LIBRARY_PATH leaks into launched programs.
    • Docs are in docs/linux.md.

Not supported yet

  • Plugins. Explorer and WindowsSettings (both platforms).
  • Settings panels. WPF-only plugin settings panels, including Url and WebSearch.
  • Windows-only features. Dialog Jump, portable mode, Squirrel auto-update, theme blur/backdrop and the Korean IME option.
  • Clipboard. Copying files to the clipboard, and the {clipboard} / {active_explorer_path} shortcuts.
  • Linux/Wayland.
    • Clients cannot position windows, so Search Window Location has no effect and needs compositor rules.
    • The cursor's display is only known under X11.
    • "Ignore hotkeys in fullscreen" works only under X11.
    • On Hyprland the transparent part of the search window still takes clicks.

Verification

macOS 26, arm64

  • Build. dotnet build Flow.Launcher.sln --no-incremental builds both targets with 0 errors. The WPF app's Windows-only taskkill pre-build step was replaced with a no-op on PATH for this build.
  • Bundle launch. Scripts/macos/build-app.sh, then open -n "Output/macOS/Flow Launcher.app":
    • lsappinfo reports UIElement.
    • The log shows RegisterEventHotKey returned noErr, Registered toggle hotkey: Alt + Space, Theme <Win11Light> applied: 24 light / 24 dark resources resolved, Loaded 10 plugins and Plugins ready.
    • The Program cache is saved.
  • Screenshots. Window-only screenshots, taken by the theme work, confirmed Win11Light and several dark themes. A live theme switch was also checked.
  • Components. The osascript dialog result parsing, sips icon conversion for 5 apps, and LaunchAgent create/lint/remove were each run in isolation.

Linux (NixOS, Hyprland 0.57.1, x64)

  • Build. Flow.Launcher.Avalonia builds for net10.0 and net10.0-windows10.0.19041.0 (the Windows build was compiled only). The bundled plugins build for net10.0. Scripts/linux/build.sh linux-x64 produces the tarball.
  • Hyprland, native Wayland.
    • hyprctl globalshortcuts lists com.flowlauncher.FlowLauncher:alt-space, and dispatching it toggles the window.
    • With the documented rule the window floats and takes focus. Typing (via wtype) shows results: "nautilus" finds Files with its SVG theme icon.
    • Esc hides the window, and Settings opens.
  • X11 and XWayland.
    • In X11 mode, xdotool key alt+space toggles the window through the XCB grab.
    • Under XWayland in headless sway, the window resizes to its content.
  • Headless sway, isolated session.
    • The window sizes to its results under native Wayland.
    • With fast typing, the stale home page results are gone after the fix (2 results → 199 px, 1 result → 133 px).
    • The Hyprland reservation was checked by faking the instance signature: the window stays 580×439 with the border at content height.
  • Notifications. Checked with mako on a private D-Bus session. makoctl list shows Flow's notification (App name: Flow Launcher, Desktop entry: com.flowlauncher.FlowLauncher), and no Flow notification window is created.
  • Scopes. Checked with a throwaway program run as a systemd user service that exits right after launching.
    • The child started through AppProcess lands in app-graphical.slice/app-flowlauncher-sleep-….scope and survives the parent unit stopping, 3/3 times.
    • A plain Process.Start child is killed.
    • Arguments splitting and id escaping (My\x2dApp) were checked, and missing programs raise Win32Exception.
  • NixOS install. After install-nixos.sh the app runs without LD_LIBRARY_PATH (Wayland, fonts, icons). The XDG autostart unit started by uwsm runs it and registers the hotkey.

Not run

  • macOS. Pressing the hotkey and typing queries; this needs Accessibility permission for automation, which the test terminal didn't have.
  • Linux.
    • GNOME and KDE.
    • linux-arm64.
    • The build-linux CI job.
    • Clicking a notification action.
    • Launching an app from Flow and then restarting Flow on a live session; only the scope helper was tested this way.
  • Unit tests. The test project targets Windows/WPF.
  • Windows. Anything at run time.

Stacked on #4706 (.NET 10 + Avalonia 12); this PR targets avalonia-net10.