From 0d8ab8d83c9e8d10ebed2388425021f1e7f7e4c0 Mon Sep 17 00:00:00 2001 From: oyvhvi <69249229+oyvhvi@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:40:55 +0200 Subject: [PATCH 1/2] Ship an Intel macOS build, with a universal shield tested on both architectures The shield is now one universal binary pinned to macOS 13. Previously it was arm64-only and stamped with the build machine's macOS version (26.0 on CI), so it would not launch on older Macs. The test suite also runs on an Intel runner, and each macOS release is smoke-tested on its own architecture. A macOS build attempted off a Mac now fails instead of silently omitting the shield. Build instructions move from CONFIGURATION.md to their own BUILDING.md. --- .github/workflows/build-hydra.yml | 34 +++++++++- Hydra/Hydra.csproj | 18 ++++- Hydra/Platform/MacOs/MacShieldProcess.cs | 14 ++-- Hydra/Update/SelfUpdater.cs | 1 + README.md | 7 +- Tests/Platform/MacShieldTests.cs | 84 ++++++++++++++++++++++++ docs/BUILDING.md | 29 ++++++++ docs/CONFIGURATION.md | 19 ------ 8 files changed, 173 insertions(+), 33 deletions(-) create mode 100644 Tests/Platform/MacShieldTests.cs create mode 100644 docs/BUILDING.md diff --git a/.github/workflows/build-hydra.yml b/.github/workflows/build-hydra.yml index 996dd6d8..ad535b3f 100644 --- a/.github/workflows/build-hydra.yml +++ b/.github/workflows/build-hydra.yml @@ -55,6 +55,8 @@ jobs: runner: windows-latest - os: macos runner: macos-latest + - os: macos-intel + runner: macos-15-intel steps: - uses: actions/checkout@v7 @@ -104,12 +106,23 @@ jobs: run: npm test build: - runs-on: macos-latest + runs-on: ${{ matrix.runner }} needs: [version] strategy: matrix: - rid: [osx-arm64, win-x64, linux-x64, linux-arm64] + include: + - rid: osx-arm64 + runner: macos-latest + # built on Intel so the smoke test below runs the binary natively + - rid: osx-x64 + runner: macos-15-intel + - rid: win-x64 + runner: macos-latest + - rid: linux-x64 + runner: macos-latest + - rid: linux-arm64 + runner: macos-latest steps: - uses: actions/checkout@v7 @@ -122,6 +135,22 @@ jobs: - name: Publish Hydra (${{ matrix.rid }}) run: dotnet publish Hydra --runtime ${{ matrix.rid }} --self-contained -p:Version=${{ needs.version.outputs.version }} + # the shipped single file starts, and the shield it extracts answers on this architecture + - name: Smoke test + if: startsWith(matrix.rid, 'osx-') + timeout-minutes: 5 + env: + DOTNET_BUNDLE_EXTRACT_BASE_DIR: ${{ runner.temp }}/bundle + PUBLISH: Hydra/bin/Release/net10.0/${{ matrix.rid }}/publish + run: | + rc=0 + "$PUBLISH/Hydra" --smoke-test 2> usage.txt || rc=$? + test "$rc" -eq 2 + grep -q '^Usage: hydra' usage.txt + shield=$(find "$DOTNET_BUNDLE_EXTRACT_BASE_DIR" -path '*/hydra-shield.app/Contents/MacOS/hydra-shield') + chmod +x "$shield" + test "$(printf '1\n0\n' | "$shield")" = "$(printf '1\n0')" + - name: Package artifact env: RID: ${{ matrix.rid }} @@ -167,6 +196,7 @@ jobs: "${notes[@]}" \ --generate-notes \ hydra-osx-arm64/hydra-osx-arm64.tar.gz \ + hydra-osx-x64/hydra-osx-x64.tar.gz \ hydra-win-x64/hydra-win-x64.tar.gz \ hydra-win-x64/hydra-win-x64.zip \ hydra-linux-x64/hydra-linux-x64.tar.gz \ diff --git a/Hydra/Hydra.csproj b/Hydra/Hydra.csproj index 502c7835..1117abe8 100644 --- a/Hydra/Hydra.csproj +++ b/Hydra/Hydra.csproj @@ -67,13 +67,27 @@ + + + + + + + + 13.0 + + + + + - + - + + diff --git a/Hydra/Platform/MacOs/MacShieldProcess.cs b/Hydra/Platform/MacOs/MacShieldProcess.cs index 8cbb0766..480dda03 100644 --- a/Hydra/Platform/MacOs/MacShieldProcess.cs +++ b/Hydra/Platform/MacOs/MacShieldProcess.cs @@ -30,7 +30,7 @@ internal sealed class MacShieldProcess(MacNetworkState networkState, bool needsW // IFileTransferDialog public event Action? CancelRequested; - private readonly string _binaryPath = Path.Combine(AppContext.BaseDirectory, "Resources", "MacShield", "hydra-shield.app", "Contents", "MacOS", "hydra-shield"); + internal static readonly string BinaryPath = Path.Combine(AppContext.BaseDirectory, "Resources", "MacShield", "hydra-shield.app", "Contents", "MacOS", "hydra-shield"); private readonly SemaphoreSlim _sendSemaphore = new(1, 1); private volatile TaskCompletionSource? _pendingReply; // completed by ReadOutput when echo arrives private Process? _process; @@ -142,7 +142,7 @@ public void Dispose() private void StartProcess() { if (OperatingSystem.IsMacOS()) EnsureExecutable(); - if (!File.Exists(_binaryPath)) return; + if (!File.Exists(BinaryPath)) return; // start under the process lock and re-check _stopping so a restart racing Stop() can't // resurrect the shield after shutdown (which would orphan an event-absorbing overlay window) @@ -152,7 +152,7 @@ private void StartProcess() _process?.Dispose(); _process = Process.Start(new ProcessStartInfo { - FileName = _binaryPath, + FileName = BinaryPath, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -325,11 +325,11 @@ private async Task ReadOutput() private static string Base64(string s) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(s)); [System.Runtime.Versioning.SupportedOSPlatform("macos")] - private void EnsureExecutable() + private static void EnsureExecutable() { - if (!File.Exists(_binaryPath)) return; - var mode = File.GetUnixFileMode(_binaryPath); + if (!File.Exists(BinaryPath)) return; + var mode = File.GetUnixFileMode(BinaryPath); if ((mode & UnixFileMode.UserExecute) == 0) - File.SetUnixFileMode(_binaryPath, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + File.SetUnixFileMode(BinaryPath, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); } } diff --git a/Hydra/Update/SelfUpdater.cs b/Hydra/Update/SelfUpdater.cs index cc54caeb..2441b6aa 100644 --- a/Hydra/Update/SelfUpdater.cs +++ b/Hydra/Update/SelfUpdater.cs @@ -227,6 +227,7 @@ private static void TryDelete(string file) private static string? Rid() { if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) return "osx-arm64"; + if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.X64) return "osx-x64"; if (OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64) return "win-x64"; if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64) return "linux-x64"; if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) return "linux-arm64"; diff --git a/README.md b/README.md index c18d64be..91596a63 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ The local socket/named pipe the TUI connects through is on by default. Set `"man Run the binary directly to try Hydra out, or use `--install` to set it up as a service / LaunchAgent that auto-starts on login and survives reboots. -**macOS (Apple Silicon):** +**macOS (13 or later):** ```bash -curl -L https://github.com/PacAnimal/hydra/releases/latest/download/hydra-osx-arm64.tar.gz | tar xz +curl -L https://github.com/PacAnimal/hydra/releases/latest/download/hydra-osx-$(uname -m | sed s/x86_64/x64/).tar.gz | tar xz ./hydra # run directly — good for testing, no install needed ./hydra --install # installs as a login item, auto-starts on login ``` @@ -196,7 +196,8 @@ Contributions are welcome, big or small. Found a bug? [Open an issue](https://gi ## Full documentation -- [Configuration reference](docs/CONFIGURATION.md) — all config fields, screen layout options, network-aware profiles, hotkeys, Styx setup, and building from source +- [Configuration reference](docs/CONFIGURATION.md) — all config fields, screen layout options, network-aware profiles, hotkeys, and Styx setup +- [Building from source](docs/BUILDING.md) — requirements, publishing for each platform, and running the tests - [TUI architecture](docs/TUI_ARCHITECTURE.md) — management boundaries, security invariants, platform lifecycle behavior, and validation expectations - [TUI hotkeys](docs/HOTKEYS.md) — every keyboard shortcut in the terminal control center, tab by tab - [Styx protocol](Styx.md) — the relay's wire protocol, for implementing your own client or server against it diff --git a/Tests/Platform/MacShieldTests.cs b/Tests/Platform/MacShieldTests.cs new file mode 100644 index 00000000..4c8f81d0 --- /dev/null +++ b/Tests/Platform/MacShieldTests.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Hydra.Platform.MacOs; + +namespace Tests.Platform; + +// drives the real hydra-shield from the build output, so an Intel runner exercises its x86_64 slice +[TestFixture] +public class MacShieldTests +{ + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(30); + + [OneTimeSetUp] + public void RequireMacOs() + { + if (!OperatingSystem.IsMacOS()) Assert.Ignore("macOS-only: hydra-shield"); + } + + [Test] + public void Binary_IsUniversal() + { + var archs = Tool("lipo", "-archs", MacShieldProcess.BinaryPath).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + Assert.That(archs, Is.EquivalentTo(["x86_64", "arm64"])); + } + + [Test] + public void Binary_RunsOnMacOs13() + { + // swiftc stamps the build machine's own macOS version unless told otherwise, locking out every older mac + var minimums = Regex.Matches(Tool("vtool", "-show-build", MacShieldProcess.BinaryPath), @"minos (\S+)") + .Select(m => Version.Parse(m.Groups[1].Value)) + .ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(minimums, Has.Count.EqualTo(2)); + Assert.That(minimums, Has.All.LessThanOrEqualTo(new Version(13, 0))); + } + } + + [Test] + public async Task Shield_EchoesEachState_AndExitsWhenStdinCloses() + { + using var shield = Process.Start(new ProcessStartInfo(MacShieldProcess.BinaryPath) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + var stderr = shield.StandardError.ReadToEndAsync(); + using var deadline = new CancellationTokenSource(Deadline); + try + { + foreach (var state in new[] { "1", "0" }) + { + await shield.StandardInput.WriteLineAsync(state); + await shield.StandardInput.FlushAsync(deadline.Token); + Assert.That(await shield.StandardOutput.ReadLineAsync(deadline.Token), Is.EqualTo(state)); + } + + // the parent going away closes stdin, and the shield must not outlive it + shield.StandardInput.Close(); + await shield.WaitForExitAsync(deadline.Token); + } + catch (OperationCanceledException) + { + shield.Kill(); + await shield.WaitForExitAsync(); + Assert.Fail($"shield did not answer within {Deadline.TotalSeconds}s: {await stderr}"); + } + + Assert.That(shield.ExitCode, Is.Zero, await stderr); + } + + private static string Tool(string name, params string[] args) + { + using var tool = Process.Start(new ProcessStartInfo("xcrun", [name, .. args]) { RedirectStandardOutput = true })!; + var output = tool.StandardOutput.ReadToEnd(); + tool.WaitForExit(); + Assert.That(tool.ExitCode, Is.Zero, $"{name} failed"); + return output; + } +} diff --git a/docs/BUILDING.md b/docs/BUILDING.md new file mode 100644 index 00000000..50321807 --- /dev/null +++ b/docs/BUILDING.md @@ -0,0 +1,29 @@ +# Building Hydra + +Requirements: + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- macOS builds: a Mac with the Xcode Command Line Tools (`xcode-select --install`), which compile the Swift helper + bundled with Hydra. Windows and Linux builds can be made on any OS. + +```bash +git clone https://github.com/PacAnimal/hydra.git +cd hydra +dotnet build Hydra.sln +dotnet test Hydra.sln +``` + +Publish a self-contained single-file executable: + +```bash +dotnet publish Hydra --runtime osx-arm64 --self-contained # macOS Apple Silicon +dotnet publish Hydra --runtime osx-x64 --self-contained # macOS Intel +dotnet publish Hydra --runtime win-x64 --self-contained # Windows x64 +dotnet publish Hydra --runtime linux-x64 --self-contained # Linux x64 +dotnet publish Hydra --runtime linux-arm64 --self-contained # Linux arm64 (e.g. Raspberry Pi) +``` + +Output lands in `Hydra/bin/Release/net10.0//publish/`. Both macOS builds run on macOS 13 or later. + +`dotnet test` skips the Linux X11 and Windows-only tests on other platforms. `./run-tests.sh linux` runs the X11 +tests under Xvfb in Docker. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a6062da3..cd91cf1b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -20,7 +20,6 @@ See the [project README](../README.md) for installation and a quick-start guide. - [System sleep](#system-sleep) - [Remote-only mode](#remote-only-mode) - [Networking with Styx](#networking-with-styx) -- [Building from source](#building-from-source) --- @@ -685,21 +684,3 @@ Add `networkConfig` to `hydra.conf` on both machines. Use the same config string - Both machines must use the **same** network config string. - Traffic between Hydra instances is end-to-end encrypted — Styx only routes opaque bytes. - -## Building from source - -```bash -dotnet build Hydra.sln -dotnet test Hydra.sln -``` - -Publish a self-contained single-file executable: - -```bash -dotnet publish Hydra --runtime osx-arm64 --self-contained # macOS Apple Silicon -dotnet publish Hydra --runtime win-x64 --self-contained # Windows x64 -dotnet publish Hydra --runtime linux-x64 --self-contained # Linux x64 -dotnet publish Hydra --runtime linux-arm64 --self-contained # Linux arm64 (e.g. Raspberry Pi) -``` - -Output lands in `Hydra/bin/Release/net10.0//publish/`. From dbd21df9f529cf360e73c6c7337fcdcf2c984b44 Mon Sep 17 00:00:00 2001 From: oyvhvi <69249229+oyvhvi@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:45:13 +0200 Subject: [PATCH 2/2] Address review: never leave the shield running after a failed test, clearer smoke diagnostics, macOS 14 as the documented floor --- .github/workflows/build-hydra.yml | 4 +++- Hydra/Platform/MacOs/NativeMethods.cs | 2 +- README.md | 2 +- Tests/Platform/MacShieldTests.cs | 16 +++++++++++++--- docs/BUILDING.md | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-hydra.yml b/.github/workflows/build-hydra.yml index ad535b3f..fb99a6fd 100644 --- a/.github/workflows/build-hydra.yml +++ b/.github/workflows/build-hydra.yml @@ -55,6 +55,7 @@ jobs: runner: windows-latest - os: macos runner: macos-latest + # GitHub's last Intel image; when it retires, smoke-test osx-x64 under Rosetta (arch -x86_64) instead - os: macos-intel runner: macos-15-intel @@ -147,7 +148,8 @@ jobs: "$PUBLISH/Hydra" --smoke-test 2> usage.txt || rc=$? test "$rc" -eq 2 grep -q '^Usage: hydra' usage.txt - shield=$(find "$DOTNET_BUNDLE_EXTRACT_BASE_DIR" -path '*/hydra-shield.app/Contents/MacOS/hydra-shield') + shield=$(find "$DOTNET_BUNDLE_EXTRACT_BASE_DIR" -path '*/hydra-shield.app/Contents/MacOS/hydra-shield' -print -quit) + test -n "$shield" || { echo "no hydra-shield in the extracted bundle"; exit 1; } chmod +x "$shield" test "$(printf '1\n0\n' | "$shield")" = "$(printf '1\n0')" diff --git a/Hydra/Platform/MacOs/NativeMethods.cs b/Hydra/Platform/MacOs/NativeMethods.cs index c1464b65..58f64797 100644 --- a/Hydra/Platform/MacOs/NativeMethods.cs +++ b/Hydra/Platform/MacOs/NativeMethods.cs @@ -375,7 +375,7 @@ internal static unsafe partial int UCKeyTranslate( // [NSEvent otherEventWithType:location:modifierFlags:timestamp:windowNumber:context:subtype:data1:data2:] // used to inject NX_SYSDEFINED media key events (volume, brightness, eject, play/next/prev). - // CGPoint doubles go into fp registers per arm64 AAPCS HFA rules. + // CGPoint doubles go into fp registers on both arm64 (AAPCS HFA) and x86_64 (SysV SSE). [LibraryImport(ObjC, EntryPoint = "objc_msgSend")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial nint objc_msgSend_NSEvent_otherEvent( diff --git a/README.md b/README.md index 91596a63..9d87f517 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The local socket/named pipe the TUI connects through is on by default. Set `"man Run the binary directly to try Hydra out, or use `--install` to set it up as a service / LaunchAgent that auto-starts on login and survives reboots. -**macOS (13 or later):** +**macOS (14 or later):** ```bash curl -L https://github.com/PacAnimal/hydra/releases/latest/download/hydra-osx-$(uname -m | sed s/x86_64/x64/).tar.gz | tar xz ./hydra # run directly — good for testing, no install needed diff --git a/Tests/Platform/MacShieldTests.cs b/Tests/Platform/MacShieldTests.cs index 4c8f81d0..ec9a22c7 100644 --- a/Tests/Platform/MacShieldTests.cs +++ b/Tests/Platform/MacShieldTests.cs @@ -65,14 +65,24 @@ public async Task Shield_EchoesEachState_AndExitsWhenStdinCloses() } catch (OperationCanceledException) { - shield.Kill(); - await shield.WaitForExitAsync(); - Assert.Fail($"shield did not answer within {Deadline.TotalSeconds}s: {await stderr}"); + Assert.Fail($"shield did not answer within {Deadline.TotalSeconds}s: {await Kill(shield, stderr)}"); + } + finally + { + // a shield left running after "1" keeps absorbing the mouse until the test host exits + await Kill(shield, stderr); } Assert.That(shield.ExitCode, Is.Zero, await stderr); } + private static async Task Kill(Process shield, Task stderr) + { + if (!shield.HasExited) shield.Kill(); + await shield.WaitForExitAsync(); + return await stderr; + } + private static string Tool(string name, params string[] args) { using var tool = Process.Start(new ProcessStartInfo("xcrun", [name, .. args]) { RedirectStandardOutput = true })!; diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 50321807..7fb10ced 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -23,7 +23,7 @@ dotnet publish Hydra --runtime linux-x64 --self-contained # Linux x64 dotnet publish Hydra --runtime linux-arm64 --self-contained # Linux arm64 (e.g. Raspberry Pi) ``` -Output lands in `Hydra/bin/Release/net10.0//publish/`. Both macOS builds run on macOS 13 or later. +Output lands in `Hydra/bin/Release/net10.0//publish/`. Both macOS builds need macOS 14 or later, the oldest .NET 10 supports. `dotnet test` skips the Linux X11 and Windows-only tests on other platforms. `./run-tests.sh linux` runs the X11 tests under Xvfb in Docker.