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/`.