diff --git a/.github/workflows/build-hydra.yml b/.github/workflows/build-hydra.yml
index 996dd6d8..fb99a6fd 100644
--- a/.github/workflows/build-hydra.yml
+++ b/.github/workflows/build-hydra.yml
@@ -55,6 +55,9 @@ 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
steps:
- uses: actions/checkout@v7
@@ -104,12 +107,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 +136,23 @@ 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' -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')"
+
- name: Package artifact
env:
RID: ${{ matrix.rid }}
@@ -167,6 +198,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/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/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..9d87f517 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 (14 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..ec9a22c7
--- /dev/null
+++ b/Tests/Platform/MacShieldTests.cs
@@ -0,0 +1,94 @@
+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)
+ {
+ 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 })!;
+ 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..7fb10ced
--- /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 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.
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/`.