Building Arin

For working on Arin rather than with it. If you only want to run it, Install is shorter.

Needs a Rust toolchain and macOS 14 or newer. The platform code is macOS only, and the core is not: arin-core and arin-protocol build and test on Linux with no platform crate in the tree, which is what keeps the v2 ports cheap.

git clone https://github.com/anistark/arin && cd arin
cargo build --workspace
cargo test --workspace

The whole test suite is headless. Platform behaviour arrives through traits and the tests wire up fakes, so nothing wants a display and nothing wants a permission.

The task runner

just has the recipes, and just on its own lists them.

just dev        # the daemon with no renderer
just run point 412 88 --display 1 --label Save
just test
just lint       # fmt and clippy, at the strictness CI uses
just ci         # everything CI runs, under the environment CI runs it in
just ci-linux   # the Linux half of CI, in a container
just toolchain  # the compiler here, against the one CI would resolve

just ci passing locally means CI passes, which is the point of it existing. It runs the commands in .github/workflows/ci.yml job for job, and it exports what that file exports, which is the half that is easy to miss: RUSTFLAGS: -D warnings is set at the workflow level, so a warning fails CI in a test target as readily as in library code. just test on its own does not set it, because an unused import should not stop you running the test you are halfway through writing.

The half a Mac cannot run is Linux, and it is just ci-linux. CI builds the workspace on macOS and Linux, lints on Linux, and builds core on Linux alone, where a platform crate in the tree fails outright. That recipe runs those three jobs in a container, at the native architecture rather than CI's x86_64, and just ci calls it when Docker is up. When Docker is down just ci names the jobs it skipped rather than reporting a plain green.

Run it before pushing anything behind a #[cfg(target_os = ...)]. The other side of that cfg is code no macOS build ever compiles, and a binding the macOS branch alone reads is dead code on Linux, which -D warnings turns into a failed build.

Nothing pins a compiler version on either side. rust-toolchain.toml names a channel, and CI's dtolnay/rust-toolchain@stable resolves that same channel on every run, so the two drift whenever a laptop goes a while without rustup update. just toolchain prints both and says which. It also notices when the cargo on PATH came from Homebrew rather than rustup, which reads no rust-toolchain.toml at all.

A Nix shell with the toolchain and these tools is nix develop. It is offered rather than required: Arin is developed with rustup and the system Xcode, and what the shell has to stay is one in which just ci passes.

Running it while you work

cargo run --bin arin -- --socket /tmp/a.sock daemon --headless
cargo run --bin arin -- --socket /tmp/a.sock point 412 88 --display 1 --label Save

--headless runs the socket, the protocol, and the whole state machine, and draws nothing. It is how the daemon is exercised without a display, and how a platform backend that does not exist yet is worked around.

A custom --socket keeps a development daemon out of the way of an installed one. Unix socket paths run out at around 104 bytes, so keep it short.

For the real renderer you want the bundle, because the menu bar item and the Screen Recording grant are properties of it:

just bundle
open target/bundle/Arin.app

just bundle always signs, ad-hoc when you have no certificate. That is not a formality. Skipping codesign does not leave the bundle unsigned: the linker ad-hoc signs the binary on Apple silicon whatever you do, so the bundle ends up claiming sealed resources it does not have, codesign --verify fails on it, and its identifier is the linker's arin-<hash> rather than com.anistark.arin. macOS will not keep a Screen Recording grant against that, so the permission reads as missing however many times you switch it on.

A grant made against an ad-hoc signed build holds until that build is replaced, which means every rebuild asks again. That is the cost of developing without a certificate.

Two bundles carrying one identifier also compete for a single Screen Recording record, so if a development build and an installed one are both on the machine, expect to tccutil reset ScreenCapture com.anistark.arin between them. just bundle warns when it notices the other one.

The invariants

Three things CI enforces, each protecting a promise rather than a preference.

just core        # core and the protocol stand alone, with no platform crate
just draw-only   # no input synthesis API is referenced anywhere
just lint        # fmt, and clippy with warnings denied

draw-only is the product boundary. Arin never synthesises input: no clicks, no keystrokes, no scrolling. That is what keeps the permission surface to Screen Recording alone and what makes the thing safe to leave running. If that check fires on something you wrote, the feature belongs in a different project.

core is what keeps the ports cheap. A macOS dependency in arin-core breaks it, and that is the intended outcome rather than an inconvenience.

Where things live

Crate What it is
arin-protocol Message types, validation, version negotiation. No IO.
arin-core The daemon. Socket, sessions, annotation state, anchors, scroll handling.
arin-resolve The resolver registry and its adapters. The only crate that reaches the network.
arin-mac The renderer and capture, in objc2. NSPanel, Core Animation, ScreenCaptureKit.
arin-mcp The MCP server, served by arin mcp.
arin-cli The arin binary, which is the only one.
arin-linux, arin-win Empty scaffolds for v2.

One binary, on purpose. MCP is arin mcp rather than a second executable, because an agent's config is written once and outlives several updates.

Every coordinate in the protocol is a logical point with an explicit display id, never a physical pixel. Conversion happens inside a platform crate and nowhere else. If you find yourself dividing by a scale factor outside one, something is wrong.

Before you send a patch

AGENTS.md is the context file: the architecture, the decisions already made and why, and the conventions. It is worth reading before a first change, and worth updating in the same commit as a change that contradicts it.

Branches are feat/, fix/, docs/, refactor/ or chore/ over a topic. Notable changes go under [Unreleased] in the changelog in the same branch as the change.

Two writing rules that apply to code comments as much as prose: no em-dashes and no semicolons in prose, and comments explain why rather than narrate what the next line does.