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
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,19 @@ swift build -c release

## How It Works

Astation runs as a macOS menubar app with a WebSocket server. Multiple Atem instances connect to it, and the hub routes work to the focused (or first available) Atem.
Astation runs as a macOS menubar app with direct and relay WebSocket transports. Multiple Atem instances can use loopback, LAN, and relay connections concurrently, and the hub routes work to the focused (or first available) authenticated Atem.

### Atem Connections

| Atem location | Transport | First connection | Offline behavior |
|---------------|-----------|------------------|------------------|
| Same Mac | `ws://127.0.0.1:8080/ws` | Transparent same-user proof | Works with all radios disabled |
| Another LAN machine | `ws://<astation-ip>:8080/ws` | User-approved pairing | Works without internet or relay |
| Remote network | Public `wss://` relay | User-approved pairing | Requires internet and relay |

Loopback is identified from the socket peer address, not from a client-supplied header. LAN and relay clients receive a random challenge and must prove possession of their saved session token with HMAC-SHA256 before Astation registers the client or sends credentials.

Direct LAN transport is currently plaintext WebSocket. The authentication protocol prevents session-ID-only impersonation, but LAN deployment is not production-ready until WSS certificate pinning is implemented. See [`docs/specs/2026-07-21-device-authentication-v2.md`](docs/specs/2026-07-21-device-authentication-v2.md).

### Mark Task Routing

Expand Down Expand Up @@ -82,20 +94,20 @@ Messages carry only IDs, status, and descriptions -- no images or file lists flo
| `markTaskNotify` | Chisel -> Astation | New task available (with summary for display) |
| `markTaskAssignment` | Astation -> Atem | Route task to a specific Atem |
| `markTaskResult` | Atem -> Astation | Report task completion/failure |
| `statusUpdate` | Astation -> Atem | Connection status on connect |
| `statusUpdate` | Astation <-> Atem | Authentication challenge, proof, and connection status |
| `heartbeat` / `pong` | Atem <-> Astation | Keep-alive |
| `voice_toggle` | Astation -> Atem | Voice input state |
| `video_toggle` | Astation -> Atem | Video state |
| `atem_instance_list` | Astation -> Atem | Broadcast connected peers |
| `auth_request` / `auth_response` | Atem <-> Astation | Authentication grant flow |

### Auth Grant Flow
| `auth_request` / `auth_response` | Atem <-> Astation | Legacy browser/deep-link grant flow |

Atem instances authenticate via a deep-link flow:
### Device Authentication

1. Atem sends `auth_request` with session ID, hostname, and one-time password
2. Astation presents the request to the user for approval
3. On approval, sends `auth_response` with session token
1. Astation sends `auth_required` with its identity, connection scope, protocol version, and a fresh challenge.
2. Same-Mac Atems prove access to the `0600` bootstrap secret without an interactive prompt.
3. Paired LAN and relay Atems send `session_id`, `atem_id`, and an HMAC proof. The session token itself is never sent during reconnect.
4. An unknown device displays an eight-digit code and waits for explicit approval in Astation.
5. Astation processes application messages and sends account credentials only after authentication succeeds.

### Voice-Driven Coding

Expand Down
25 changes: 16 additions & 9 deletions Sources/Menubar/AstationApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ class AstationApp: NSObject, NSApplicationDelegate {
mainMenu.addItem(editMenuItem)
NSApp.mainMenu = mainMenu

// Initialize hub manager (business logic)
hubManager = AstationHubManager()
// Direct and relay transports must authenticate against the same devices.
let deviceSessionStore = SessionStore()
hubManager = AstationHubManager(deviceSessionStore: deviceSessionStore)

// Initialize auth grant controller for deep-link auth flow
authGrantController = AuthGrantController()
Expand All @@ -45,27 +46,33 @@ class AstationApp: NSObject, NSApplicationDelegate {
)

// Initialize WebSocket server
webSocketServer = AstationWebSocketServer(hubManager: hubManager)
webSocketServer = AstationWebSocketServer(
hubManager: hubManager,
sessionStore: deviceSessionStore
)

// Initialize status bar
statusBarController = StatusBarController(hubManager: hubManager, webSocketServer: webSocketServer)

// Start WebSocket server on all interfaces (0.0.0.0) so LAN clients can connect
// One listener supports offline loopback and authenticated LAN clients concurrently.
do {
try webSocketServer.start(host: "0.0.0.0", port: 8080)
let localIP = getLocalNetworkIP() ?? "127.0.0.1"
Log.info("WebSocket server started on all interfaces (port 8080)")
Log.info(" Local: ws://127.0.0.1:8080")
Log.info(" Network: ws://\(localIP):8080")
Log.info(" Local (same-user): ws://127.0.0.1:8080/ws")
Log.info(" LAN (paired): ws://\(localIP):8080/ws")
} catch {
Log.error("Failed to start WebSocket server: \(error)")
NSApp.terminate(nil)
return
}

// Wire broadcast handler so hubManager can broadcast to all connected Atems
hubManager.broadcastHandler = { [weak webSocketServer] message in
webSocketServer?.broadcastMessage(message)
hubManager.broadcastHandler = { [weak webSocketServer, weak hubManager] message in
DispatchQueue.main.async {
webSocketServer?.broadcastMessage(message)
hubManager?.broadcastToAuthenticatedIdentityRelayClients(message)
}
}

// Wire send handler so hubManager can send to a specific Atem by client ID
Expand Down Expand Up @@ -233,4 +240,4 @@ class AstationApp: NSObject, NSApplicationDelegate {
Log.info(" Pair deep link received with code: \(code)")
hubManager?.connectToRelay(code: code)
}
}
}
Loading
Loading