Control your Mac from your iPhone. Jack is a monorepo project consisting of a macOS daemon and a React Native mobile app that communicate over WebSocket. It lets you control volume, brightness, media playback, and display settings from your phone over your local network.
- The daemon runs on your Mac as a background LaunchAgent. On startup it generates a secret token, stores it in
~/.jack/config.json, and advertises itself on your local network via mDNS (_jack._tcp.local). - The mobile app scans for the daemon using mDNS, or you can enter the IP and port manually. You paste the token once and your credentials are saved to the iOS Keychain.
- Once connected, the app sends commands over a WebSocket and receives real-time system status (volume, brightness, battery) every 3 seconds.
iPhone ──── mDNS discovery ────► Mac Daemon (port 9521)
◄─── WebSocket (WS) ─────
──── Commands ──────────►
◄─── Status / ACK ───────
jack/
├── apps/
│ ├── daemon/ # Node.js macOS daemon
│ └── mobile/ # Expo React Native iOS app
└── packages/
└── shared/ # Shared TypeScript types
This is a Yarn workspace monorepo. All packages are TypeScript.
Shared type definitions used by both the daemon and mobile app.
| Module | Contents |
|---|---|
commands.ts |
Discriminated union of all 12 command types |
events.ts |
Socket event name constants (command, ack, status, error) |
responses.ts |
Success/error response types |
status.ts |
SystemStatus type (volume, brightness, battery, etc.) |
A Node.js server that runs as a macOS LaunchAgent.
| Module | Responsibility |
|---|---|
src/index.ts |
Entry point — loads config, starts server, advertises mDNS |
src/server.ts |
Socket.IO server, auth middleware, status polling |
src/auth.ts |
Token generation and constant-time validation |
src/mdns.ts |
Bonjour/mDNS advertisement |
src/status.ts |
System status collection (osascript, pmset, Swift helpers) |
src/handlers/ |
Per-feature command handlers |
bin/brightness_helper |
Compiled Swift binary using DisplayServices |
bin/media_helper |
Compiled Swift binary using MediaRemote.framework |
Tech stack: Node.js 20, Socket.IO 4.7, bonjour-service, Zod, tsx
A React Native app built with Expo.
| Module | Responsibility |
|---|---|
app/connect.tsx |
mDNS discovery, manual entry, pairing |
app/dashboard.tsx |
Main control interface |
src/context/SocketContext.tsx |
WebSocket connection lifecycle |
src/hooks/useDiscovery.ts |
mDNS scanner (react-native-zeroconf) |
src/hooks/useSystemStatus.ts |
Real-time status from daemon |
src/services/storage.ts |
Credential persistence (expo-secure-store) |
src/components/controls/ |
Volume, brightness, media, display control components |
Tech stack: Expo 55, React Native 0.83, Expo Router, Socket.IO client 4.7, react-native-zeroconf
- macOS (Apple Silicon or Intel)
- Node.js 20+ (
brew install node) - Yarn (
npm install -g yarn) - Homebrew (for the
brightnessCLI)
Run the setup script from the daemon directory:
cd apps/daemon
bash install/setup.shThe script will:
- Install the
brightnessCLI via Homebrew - Install dependencies and build the project
- Install a LaunchAgent that auto-starts the daemon on login
- Print your token, port, and local IP address
After setup, the daemon starts automatically. You can check its logs:
tail -f /tmp/jack.daemon.log
tail -f /tmp/jack.daemon.error.logYour token and port are stored in ~/.jack/config.json:
{
"token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"port": 9521
}Accessibility permission (required for media controls and display lock):
Go to System Settings → Privacy & Security → Accessibility and add Terminal (or whichever app runs Node.js).
Install dependencies:
cd apps/mobile
yarn installRun on iOS simulator:
yarn iosRun on a physical device:
yarn ios:deviceRun with Expo Go (easiest for testing):
yarn start
# Scan the QR code with the Expo Go appProduction build (requires EAS account):
yarn build# Build the shared types package (required by daemon and mobile)
yarn build:shared
# Start the daemon in watch mode (hot reload)
yarn daemon
# Start the Expo dev server
yarn mobile
# Type-check all packages
yarn typecheck# Shared
cd packages/shared
yarn build # Compile TypeScript
yarn dev # Watch mode
# Daemon
cd apps/daemon
yarn dev # Start with tsx watch (hot reload)
yarn build # Compile to dist/
yarn start # Run compiled dist/index.js
# Mobile
cd apps/mobile
yarn start # Expo dev server
yarn typecheck # tsc --noEmitAll commands are sent over WebSocket on the command event. The daemon validates them with Zod before dispatch.
| Command | Payload | Description |
|---|---|---|
volume:set |
{ value: 0–100 } |
Set output volume |
volume:mute |
— | Mute audio |
volume:unmute |
— | Unmute audio |
volume:get |
— | Get current volume |
brightness:set |
{ value: 0–100 } |
Set display brightness |
brightness:get |
— | Get current brightness |
display:lock |
— | Lock the screen |
display:wake |
— | Wake the display |
media:playpause |
— | Toggle play/pause |
media:next |
— | Next track |
media:previous |
— | Previous track |
status:get |
— | Request current status |
| Event | Direction | Description |
|---|---|---|
command |
Client → Daemon | Send a command |
ack |
Daemon → Client | Command response (success or error) |
status |
Daemon → Client | System status, broadcast every 3 seconds |
error |
Daemon → Client | Unhandled error |
{
volume: number; // 0–100
muted: boolean;
brightness: number; // 0–100
battery: number; // 0–100
charging: boolean;
timestamp: number; // ms since epoch
}The daemon generates a random UUID token on first run and saves it to ~/.jack/config.json. The mobile app must supply this token in the Socket.IO handshake:
io(url, { auth: { token } })Token comparison uses constant-time XOR to prevent timing attacks. Invalid tokens are rejected before the WebSocket connection is established.
Credentials (host, port, token) are stored on the device using expo-secure-store, which uses the iOS Keychain on iOS and Android KeyStore on Android.
- Transport: WebSocket only (no HTTP long-polling). The daemon uses
socket.io4.7 withtransports: ['websocket']. - Discovery: mDNS service type
_jack._tcp.local, advertised on the daemon's port with aversion=2.0.0TXT record. - Media controls: The
media_helperSwift binary talks directly tomediaserverdviaMediaRemote.framework(private API), so no Accessibility permission is needed for play/pause/next/prev. - Brightness: The
brightness_helperSwift binary uses theDisplayServicesprivate framework. It accepts a float0.0–1.0and returns the current level. - Display lock: Uses
osascriptto send Control+Command+Q, with a fallback topmset displaysleepnow. - LaunchAgent: The daemon is managed by
launchdvia~/Library/LaunchAgents/com.jack.daemon.plist. It starts on login and restarts automatically on crash.
| Setting | Location | Default |
|---|---|---|
| Token | ~/.jack/config.json |
Auto-generated UUID |
| Port | ~/.jack/config.json |
9521 |
| mDNS service name | src/mdns.ts |
Jack Mac Daemon |
| mDNS service type | src/mdns.ts |
_jack._tcp.local |
| Status poll interval | src/server.ts |
3000ms |
| Socket timeout (mobile) | src/context/SocketContext.tsx |
8000ms |
| Reconnect attempts (mobile) | src/context/SocketContext.tsx |
5 |
| Daemon stdout log | LaunchAgent plist | /tmp/jack.daemon.log |
| Daemon stderr log | LaunchAgent plist | /tmp/jack.daemon.error.log |
MIT