Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .github/workflows/build-hydra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 \
Expand Down
18 changes: 16 additions & 2 deletions Hydra/Hydra.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,27 @@
<Content Include="Resources/MacShield/hydra-shield.app/Contents/Info.plist" CopyToOutputDirectory="PreserveNewest" Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$(RuntimeIdentifier)' != 'win-x64' And '$(RuntimeIdentifier)' != 'linux-x64' And '$(RuntimeIdentifier)' != 'linux-arm64'" />
</ItemGroup>

<!-- without a Mac there is no swiftc, and a macOS build would silently ship without its shield -->
<Target Name="RequireMacForMacBuild" BeforeTargets="Build" Condition="$(RuntimeIdentifier.StartsWith('osx')) And !$([MSBuild]::IsOSPlatform('OSX'))">
<Error Text="macOS builds of Hydra must be made on a Mac: the hydra-shield helper is compiled with swiftc." />
</Target>

<!-- one universal shield serves both osx-arm64 and osx-x64; pinned minimum OS, or swiftc stamps the build machine's; 13.0 is the lowest the toolchain has x86_64 back-deploy libs for -->
<PropertyGroup>
<MacShieldMinOS>13.0</MacShieldMinOS>
</PropertyGroup>
<ItemGroup>
<ShieldArch Include="arm64;x86_64" />
</ItemGroup>

<!-- compile the Swift shield helper into a proper .app bundle; only runs on macOS where swiftc is available -->
<!-- uses a stamp file as Outputs — the binary path (inside hydra-shield.app/) doesn't exist on fresh checkout,
causing MSBuild's incremental check to silently skip the target; a stamp in the existing MacShield/ dir works reliably -->
<Target Name="CompileMacShield" BeforeTargets="Build;_CopyOutOfDateSourceItemsToOutputDirectory" Inputs="$(MSBuildProjectDirectory)/Resources/MacShield/main.swift;$(MSBuildProjectDirectory)/Resources/MacShield/ShieldInfo.plist" Outputs="$(MSBuildProjectDirectory)/Resources/MacShield/.shield-stamp" Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$(RuntimeIdentifier)' != 'win-x64' And '$(RuntimeIdentifier)' != 'linux-x64' And '$(RuntimeIdentifier)' != 'linux-arm64'">
<Target Name="CompileMacShield" BeforeTargets="Build;_CopyOutOfDateSourceItemsToOutputDirectory" Inputs="$(MSBuildProjectFullPath);$(MSBuildProjectDirectory)/Resources/MacShield/main.swift;$(MSBuildProjectDirectory)/Resources/MacShield/ShieldInfo.plist" Outputs="$(MSBuildProjectDirectory)/Resources/MacShield/.shield-stamp" Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$(RuntimeIdentifier)' != 'win-x64' And '$(RuntimeIdentifier)' != 'linux-x64' And '$(RuntimeIdentifier)' != 'linux-arm64'">
<MakeDir Directories="$(MSBuildProjectDirectory)/Resources/MacShield/hydra-shield.app/Contents/MacOS" />
<Copy SourceFiles="$(MSBuildProjectDirectory)/Resources/MacShield/ShieldInfo.plist" DestinationFiles="$(MSBuildProjectDirectory)/Resources/MacShield/hydra-shield.app/Contents/Info.plist" />
<Exec Command="xcrun swiftc -O -framework Network -framework CoreWLAN -framework CoreLocation -o &quot;$(MSBuildProjectDirectory)/Resources/MacShield/hydra-shield.app/Contents/MacOS/hydra-shield&quot; &quot;$(MSBuildProjectDirectory)/Resources/MacShield/main.swift&quot;" />
<Exec Command="xcrun swiftc -O -target %(ShieldArch.Identity)-apple-macos$(MacShieldMinOS) -framework Network -framework CoreWLAN -framework CoreLocation -o &quot;$(IntermediateOutputPath)hydra-shield-%(ShieldArch.Identity)&quot; &quot;$(MSBuildProjectDirectory)/Resources/MacShield/main.swift&quot;" />
<Exec Command="xcrun lipo -create -output &quot;$(MSBuildProjectDirectory)/Resources/MacShield/hydra-shield.app/Contents/MacOS/hydra-shield&quot; @(ShieldArch->'&quot;$(IntermediateOutputPath)hydra-shield-%(Identity)&quot;', ' ')" />
<Exec Command="codesign --force --sign - -i com.cathedral.hydra.shield --requirements &quot;=designated =&gt; identifier com.cathedral.hydra.shield&quot; &quot;$(MSBuildProjectDirectory)/Resources/MacShield/hydra-shield.app&quot;" />
<Touch Files="$(MSBuildProjectDirectory)/Resources/MacShield/.shield-stamp" AlwaysCreate="true" />
</Target>
Expand Down
14 changes: 7 additions & 7 deletions Hydra/Platform/MacOs/MacShieldProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? _pendingReply; // completed by ReadOutput when echo arrives
private Process? _process;
Expand Down Expand Up @@ -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)
Expand All @@ -152,7 +152,7 @@ private void StartProcess()
_process?.Dispose();
_process = Process.Start(new ProcessStartInfo
{
FileName = _binaryPath,
FileName = BinaryPath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion Hydra/Platform/MacOs/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions Hydra/Update/SelfUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
94 changes: 94 additions & 0 deletions Tests/Platform/MacShieldTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> Kill(Process shield, Task<string> 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;
}
}
29 changes: 29 additions & 0 deletions docs/BUILDING.md
Original file line number Diff line number Diff line change
@@ -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/<rid>/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.
19 changes: 0 additions & 19 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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/<rid>/publish/`.
Loading