Skip to content

feat: opt-in local control API for external controllers - #476

Open
davidprokopec wants to merge 5 commits into
ronitsingh10:mainfrom
davidprokopec:feat/external-control-api
Open

davidprokopec wants to merge 5 commits into
ronitsingh10:mainfrom
davidprokopec:feat/external-control-api

Conversation

@davidprokopec

@davidprokopec davidprokopec commented Oct 20, 2026 •

Copy link
Copy Markdown

Motivation

I use FineTune for its per-app volume, output routing, and microphone/device switching, and I want to build richer integrations around those existing capabilities—not implement a competing audio engine. For example, a hardware controller should be able to adjust FineTune-managed volume, mute, and routing while its faders and lights stay synchronized with changes made in FineTune itself.

Keeping FineTune responsible for audio processing lets external tools focus on control and automation without maintaining another capture/routing pipeline. FineTune’s troubleshooting guide notes that some audio-processing and routing applications can interfere with its process taps. Multiple audio tools are not universally incompatible, but overlapping pipelines can create configuration-dependent problems; this API avoids adding a second pipeline. This is not a claim of a confirmed FineTune/Wave Link incompatibility.

Hardware controllers such as a Stream Deck or a Novation Launchpad can already drive FineTune through finetune:// URLs, but only one way: they can't read the current volume, mute or routing, so faders and button lights drift as soon as something changes in FineTune's own UI. This PR adds a small, opt-in local API so a controller can read FineTune's state, follow changes live, and send absolute commands.

What it adds

  • External controller access: a new toggle under Settings → General, off by default. While it's off, nothing listens and no files are created.
  • A Unix-domain socket at ~/Library/Application Support/FineTune/Control/control.sock, using newline-delimited JSON (protocol finetune-control, version 1).
  • Read: a full state snapshot (apps with managed volume, read-only boost, mute and routing, plus devices, default input/output and the input-lock policy) and a subscription that pushes a new snapshot with an increasing revision whenever something changes. Changes are coalesced over a fixed 40 ms window.
  • Write: setAppVolume, setAppMute, setAppRouting (follow default or a single output; multi-output stays read-only in v1), setDefaultOutput, and setPreferredInput. Preferred input goes through FineTune's existing input-lock/priority path.
  • A response means the command was accepted. Routing applies asynchronously, so each app's routing state reports pending / applied / failed separately from what was requested.
  • Protocol guide: guide/control-api.md (linked from the README).

Security model

  • Only processes running as the same macOS user can connect. That is the trust boundary; there is no token or pairing.
  • The Control directory is created with mode 0700 and the socket with 0600. The peer UID is checked with getpeereid before any bytes are read.
  • Ancestor directories are checked (canonical path, no symlinks followed, no group- or world-writable parents) and never chmodded.
  • An exclusive lock covers the listener's lifetime. Stale-socket cleanup is checked against the socket's identity, so another listener's socket is never removed.
  • At most 8 clients. Frames are capped at 1 MiB and each client's outbound queue is bounded; slow or stalled clients are disconnected. No network listener, helper or daemon.
  • On quit, socket cleanup runs asynchronously, with a 2-second fallback so termination can never hang.

Engine changes

  • Per-app routing status (pending / applied / failed) with per-PID generation guards, so a superseded async completion can't overwrite a newer request.
  • DeviceVolumeMonitor caches readability and backend metadata during its normal refreshes, so snapshots never probe the HAL on the main thread.
  • setLockedInputDevice returns the existing result of the setDefaultInputDevice call.
  • Two test-only initializer parameters (AudioRecordingPermission(status:), AutoEQProfileManager(loadAutomatically:)) default to the current production behaviour.
  • Existing URL actions and single-owner monitor callbacks are unchanged.

Verification

  • Unsigned Debug build and full test-bundle compilation pass (Xcode 27.1, macOS 15.4 target).
  • 37 new XCTests pass (protocol, service, socket/security, engine adapter, readback, termination, settings), run against the built module with standalone xctest and without launching the app. They use fakes and temporary sockets only.
  • Manual test on macOS with a locally signed Release build: once the toggle is on, the socket appears with 0700/0600 permissions, and hello/getState report the devices, defaults, input policy and permission correctly. A Novation Launchpad client (Lunchpad) subscribed and changed a real app's volume and mute, the client's faders and lights followed changes made in FineTune's own UI, and output routing changes worked.
  • Not yet run: the full app-hosted test suite (it launches the app), and permission revoke/re-grant, hot-plug and crash-recovery scenarios.

Non-goals

Controller plugins and mappings, network or remote access, audio streaming or meters, EQ/boost editing, Bluetooth control, a generic RPC layer, and changes to the URL actions.

This is independent of #222.

@davidprokopec
davidprokopec marked this pull request as ready for review October 20, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant