diff --git a/docs-ai/046-mobile-remote-control/000-plan.md b/docs-ai/046-mobile-remote-control/000-plan.md new file mode 100644 index 000000000..47ffb80ca --- /dev/null +++ b/docs-ai/046-mobile-remote-control/000-plan.md @@ -0,0 +1,63 @@ +# 046 — Mobile Remote Control: Plan + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-07-13 | +| **Primary PRs** | Pending | +| **Related** | #196, [013-prowl-cli](../013-prowl-cli/000-plan.md), `docs/components/cli.md` | + +## Background + +Issue #196 asks for mobile access to a running Prowl instance. The existing `prowl` +control plane is intentionally local-only: `supacode/CLIService/CLISocketServer.swift` +uses an owner-only Unix socket and verifies the peer UID. Its generic command router +also includes terminal input and destructive actions, and its responses contain local +paths and agent-session metadata. It must not be placed behind a network listener. + +The issue does not define a mobile client, transport, or remote write capability. The +owner identified a proof of concept as the appropriate first increment. + +## Goals + +- Add an opt-in, loopback-only read-only bridge for a separately authenticated private + tunnel or overlay. +- Require a high-entropy bearer credential stored outside `~/.prowl/settings.json`. +- Return a path-free agent projection and bounded viewport text through opaque IDs. +- Let Settings start and stop the listener immediately, with documented security bounds. + +### Non-goals + +- A native iOS client, Prowl-managed relay, public listener, TLS termination, push + streaming, or automatic tunnel configuration. +- Re-exporting the CLI protocol or exposing `send`, `key`, `focus`, `open`, `tab`, or + `pane` actions. +- Scrollback access, unbounded output, device-specific pairing, or per-device auditing. + +## Design / Approach + +1. Add a dedicated HTTP request router under `supacode/CLIService/`. It accepts only + authenticated `GET` requests for an agent summary and limited current viewport text; + it does not accept a `CommandEnvelope`. +2. Build remote DTOs directly from live app and terminal state in `supacode/App/supacodeApp.swift`. + They omit paths, CWDs, transcript paths, and raw terminal identifiers. Reads use a + short-lived opaque mapping plus line and UTF-8 byte caps. +3. Bind a small listener to `127.0.0.1` only. A Keychain-backed random bearer token is + never written to the global settings model or emitted through `SupaLogger`. +4. Add a public `remoteControlEnabled` setting through the global model, Settings + reducer, and Advanced settings view. An app-owned service coordinates lifecycle + changes without subscribing to the single-subscriber terminal event stream. +5. Cover authentication, allowlisting, redaction, output limits, settings persistence, + and start/stop behavior with tests, then document safe deployment. + +## Alternatives & decisions + +| Decision | Rejected alternative | Rationale | +| --- | --- | --- | +| Separate read-only protocol and DTOs | Forward the CLI socket or command router | The local trust model and payloads are unsafe at a network boundary. | +| Loopback listener plus user-managed private TLS tunnel | Bind LAN/public TCP directly | Prowl owns neither a relay nor certificate/identity infrastructure. | +| Keychain-backed rotating token | Persist it in `GlobalSettings` | Settings may be symlinked or copied into dotfiles and are not a secret store. | +| Request/response polling | Subscribe to terminal events | The terminal event stream currently permits one subscriber. | + +## Amendments +None. diff --git a/docs-ai/046-mobile-remote-control/001-action.md b/docs-ai/046-mobile-remote-control/001-action.md new file mode 100644 index 000000000..cfe251608 --- /dev/null +++ b/docs-ai/046-mobile-remote-control/001-action.md @@ -0,0 +1,31 @@ +# 046 — Mobile Remote Control: Action Log + +## Timeline +| Date | Change | Ref | +| --- | --- | --- | +| 2026-07-13 | Added the read-only loopback bridge, Keychain credential store, opt-in Settings lifecycle, and regression tests. | `3a03bcf2` | +| 2026-07-13 | Added the agent-facing deployment and safety manual, then updated the docs sync baseline. | Pending commit | +| 2026-07-28 | Hardened the TCP bridge after review: concurrent bounded client handling with a connection deadline, active-socket shutdown on stop, `SIGPIPE` suppression, and TCP integration tests. | PR #583 review | + +## Outcome & current state (as of 2026-07-13) + +- `supacode/CLIService/RemoteControlRouter.swift` accepts authenticated `GET` requests + for active-agent summaries and bounded viewport reads only. It uses opaque IDs and + returns no local paths, CWDs, or agent-session files. +- `supacode/CLIService/RemoteControlServer.swift` binds only `127.0.0.1:39466`; + `RemoteControlAccessTokenStore.swift` holds a 32-byte rotating bearer token in the + macOS Keychain. +- `supacode/App/supacodeApp.swift`, `RemoteControlClient.swift`, and the Settings + feature start and stop the bridge immediately through `remoteControlEnabled`. +- `docs/components/remote-control.md` documents the private-tunnel requirement, + endpoint limits, token rotation, and explicit write-operation exclusions. + +## Deviations from plan + +The proof of concept limits reads to the current viewport rather than scrollback, a +stricter safety boundary than planned. + +## Open questions + +The first increment has no native mobile client, managed relay, device-specific pairing, +or push transport. Those require a separately specified security and delivery model. diff --git a/docs-ai/README.md b/docs-ai/README.md index 96b99f53f..21b7e2c1c 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -92,3 +92,4 @@ behavior (`docs/` is the agent-facing manual for that). | 043 | [canvas-tile-layout](043-canvas-tile-layout/000-plan.md) | 2026-06-24 | Tile layout + default-layout setting | | 044 | [foundation-model-branch-names](044-foundation-model-branch-names/000-plan.md) | 2026-06-27 | On-device FM branch-name suggestions | | 045 | [native-agent-session-detection](045-native-agent-session-detection/000-plan.md) | 2026-07-12 | Native agent session identity (successor to 030's heuristics) | +| 046 | [mobile-remote-control](046-mobile-remote-control/000-plan.md) | 2026-07-13 | Opt-in read-only mobile remote-control bridge PoC | diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index d8d7e5cde..ce2e5c438 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "168d8e9c8e61655f57dcb8411092b0af9263e7e0", - "last_synced_date": "2026-07-10", - "note": "Release prep. All user-facing changes in range were doc-synced in their own PRs (#545 sound picker, #546 mute viewed surface, #542 editor additions, #543 TERM_PROGRAM, #541 gh hardening); #544 symlink-preserving writes and PR tri-state fixes need no doc changes." + "last_synced_commit": "3a03bcf2", + "last_synced_date": "2026-07-13", + "note": "Read-only mobile remote-control bridge: Advanced setting, loopback-only deployment, Keychain token rotation, and bounded agent viewport API." } diff --git a/docs/README.md b/docs/README.md index 84b0df511..7f9147f00 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,7 @@ its keyboard shortcuts, detailed behavior, settings, and gotchas. | [`components/settings.md`](components/settings.md) | The Settings window (`⌘,`): every tab and what it controls. | | [`components/updates.md`](components/updates.md) | Sparkle auto-updates: auto-check, `⌘⇧U`. | | [`components/cli.md`](components/cli.md) | The `prowl` CLI — let an agent inspect and drive panes (`list`, `read`, `send`, `key`, `focus`, `tab`, `pane`, `open`). | +| [`components/remote-control.md`](components/remote-control.md) | Experimental read-only mobile bridge: loopback-only deployment, private tunnel boundary, token rotation, agent summaries, and bounded viewport reads. | ## Reference (exact lookups) @@ -81,6 +82,7 @@ its keyboard shortcuts, detailed behavior, settings, and gotchas. | Review what an agent changed | [`components/diff-view.md`](components/diff-view.md) | | Open / merge / re-run CI on a pull request | [`components/github-pull-requests.md`](components/github-pull-requests.md) | | Drive a pane from a script or another agent | [`components/cli.md`](components/cli.md) | +| Check active agents from a trusted phone | [`components/remote-control.md`](components/remote-control.md) | | Look up a keyboard shortcut | [`reference/keyboard-shortcuts.md`](reference/keyboard-shortcuts.md) | | Change app behavior / a setting | [`components/settings.md`](components/settings.md), [`reference/settings-fields.md`](reference/settings-fields.md) | diff --git a/docs/components/cli.md b/docs/components/cli.md index d0886e289..dd6c79971 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -6,7 +6,7 @@ **Keywords:** prowl cli, command line, prowl list, prowl agents, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, pane id, automation, json, capture, socket -**Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) +**Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · [remote-control](remote-control.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) > This is the reference for the `prowl` binary. For an opinionated, safety-first > *workflow* guide (recipes, pitfalls, quoting), the repository also ships the @@ -20,6 +20,10 @@ agent, run something in another tab and grab the output, focus a worktree, open project, or close a scratch tab. It is **not** for ordinary editing/building inside a repo, and not for how-to questions about Prowl's settings. +`prowl` remains same-Mac, same-user IPC. It is not a remote/mobile endpoint; the +separate experimental [read-only mobile bridge](remote-control.md) has a narrower +API, a bearer token, and must be exposed only through a private authenticated tunnel. + ## Install From the app: **Settings → Advanced → Install Command Line Tool**, or Command diff --git a/docs/components/remote-control.md b/docs/components/remote-control.md new file mode 100644 index 000000000..b0de0a6c7 --- /dev/null +++ b/docs/components/remote-control.md @@ -0,0 +1,47 @@ +# Remote Control (Experimental) + +> A deliberately limited, read-only bridge for checking active Prowl agents from a +> companion mobile client. It is not a remote terminal or a replacement for the +> local `prowl` CLI. + +**Keywords:** remote control, mobile, bridge, bearer token, loopback, private tunnel, agent status, viewport + +**Related:** [active-agents](active-agents.md) · [settings](settings.md) · [cli](cli.md) + +## Enable and pair + +Open **Settings → Advanced → Remote Control (Experimental)** and enable the +read-only bridge. It listens only at `127.0.0.1:39466`; it is never reachable from +the LAN or public internet by itself. + +Copy the access token from the same section into a trusted companion client. The +token is stored in the macOS Keychain, not in `~/.prowl/settings.json`. **Rotate and +Copy Access Token** immediately revokes clients using the old token. + +To reach the bridge from a phone, provide your own authenticated private transport +that terminates TLS and forwards only to `127.0.0.1:39466`—for example, a private +overlay or an authenticated reverse tunnel. Prowl does not create or manage a tunnel. +Never forward `cli.sock` or expose the bridge directly on a LAN/public interface. + +## Read-only API + +Every request must send `Authorization: Bearer `. + +| Request | Result | +| --- | --- | +| `GET /v1/agents` | Active-agent summaries with opaque IDs, status, display/project/branch names, and no local paths or session files. | +| `GET /v1/agents//read?last=1…80` | The current terminal viewport for that active agent. Output is capped at 80 lines and 12 KiB UTF-8; `truncated` is `true` when either cap applies. | + +The API accepts no writes: there is no endpoint for text input, keys, focus, opening, +closing, tab, or pane operations. It also does not expose scrollback or live streaming. + +## Safety boundaries + +- The bearer token is required even on loopback because a tunnel is a separate trust + boundary from Prowl's same-UID CLI socket. +- The bridge uses opaque IDs that are refreshed from the current active-agent set; + re-fetch `/v1/agents` after an agent disappears or Prowl restarts. +- Terminal text can still contain sensitive project data. Pair only a device you trust, + protect the private tunnel, and rotate the token if it may have been disclosed. +- Turning the setting off stops the listener immediately. It retains the Keychain token + so a trusted setup can be re-enabled; rotate it to revoke prior access. diff --git a/docs/components/settings.md b/docs/components/settings.md index 3ea4cb240..c3f25ccad 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -21,7 +21,7 @@ window is a sidebar of tabs plus a detail pane. | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, delete-branch-on-delete, merged-worktree action, archived auto-delete period. | | **Updates** | Auto-check toggle, "Check for Updates Now". → [updates](updates.md) | -| **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout, and the **Install Command Line Tool** (`prowl` CLI) action. | +| **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout, the **Install Command Line Tool** (`prowl` CLI) action, and the experimental [read-only mobile bridge](remote-control.md). | | **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | | **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md) | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 277938baf..12e054272 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -47,6 +47,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `copyUntrackedOnWorktreeCreate` | Bool | `false` | Copy untracked files into new worktrees. | | `pullRequestMergeStrategy` | enum (`merge`/`squash`/`rebase`) | `merge` | Default PR merge strategy. | | `restoreTerminalLayoutOnLaunch` | Bool | `false` | Restore tabs/splits on launch. | +| `remoteControlEnabled` | Bool | `false` | Start the experimental, loopback-only read-only mobile bridge. Its bearer token is held in the macOS Keychain and is never stored in this JSON file. | | `terminalFontSize` | Float32? | `nil` | Remembered terminal font size. | | `archivedAutoDeletePeriod` | enum? (days) | `nil` | Auto-delete archived worktrees after N days; `nil` = never. | | `keybindingUserOverrides` | object | empty | User keyboard-shortcut remappings. | diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 0afcebf21..7841c2ac2 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -19,6 +19,15 @@ private final class SupacodeAppStoreBox { weak var store: StoreOf? } +@MainActor +private final class RemoteControlControllerBox { + var controller: RemoteControlController? + + func setEnabled(_ enabled: Bool) -> Bool { + controller?.setEnabled(enabled) ?? !enabled + } +} + private enum GhosttyCLI { static func argv(resolvedKeybindings: ResolvedKeybindingMap) -> [UnsafeMutablePointer?] { var args: [UnsafeMutablePointer?] = [] @@ -44,6 +53,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { } var terminalManager: WorktreeTerminalManager? var cliSocketServer: CLISocketServer? + var remoteControlController: RemoteControlController? func applicationDidFinishLaunching(_ notification: Notification) { WindowLifecycleDiagnostics.startMainThreadHeartbeat() @@ -81,7 +91,10 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { WindowLifecycleDiagnostics.logWithWindows("applicationWillTerminate") - defer { cliSocketServer?.stop() } + defer { + cliSocketServer?.stop() + remoteControlController?.stop() + } guard appStore?.state.settings.restoreTerminalLayoutOnLaunch == true else { return } guard appStore?.state.suppressLayoutSaveUntilRelaunch != true else { return } terminalManager?.persistLayoutSnapshotSync() @@ -104,6 +117,7 @@ struct SupacodeApp: App { @State private var pullRequestRefreshCoordinator: PullRequestRefreshCoordinator @State private var commandKeyObserver: CommandKeyObserver @State private var cliSocketServer: CLISocketServer + @State private var remoteControlController: RemoteControlController @State private var store: StoreOf @State private var memoryWatchdog: MemoryWatchdog @State private var askAgentHelp = AskAgentHelpPresenter() @@ -204,6 +218,7 @@ struct SupacodeApp: App { _pullRequestRefreshCoordinator = State(initialValue: coordinator) let keyObserver = CommandKeyObserver() _commandKeyObserver = State(initialValue: keyObserver) + let remoteControlControllerBox = RemoteControlControllerBox() var initialAppState = AppFeature.State(settings: SettingsFeature.State(settings: initialSettings)) if let cliOpenPath = Self.cliLaunchOpenPath() { initialAppState.launchRestoreMode = .cliOpenPath(cliOpenPath) @@ -229,12 +244,21 @@ struct SupacodeApp: App { values.pullRequestRefreshCoordinator = Self.makePullRequestRefreshCoordinatorClient( coordinator: coordinator ) + values.remoteControlClient = RemoteControlClient { enabled in + remoteControlControllerBox.setEnabled(enabled) + } } _store = State(initialValue: appStore) storeBox.store = appStore let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) _cliSocketServer = State(initialValue: cliServer) + let remoteControlController = Self.makeRemoteControlController( + appStore: appStore, + terminalManager: terminalManager + ) + remoteControlControllerBox.controller = remoteControlController + _remoteControlController = State(initialValue: remoteControlController) let watchdog = Self.makeMemoryWatchdog(appStore: appStore, terminalManager: terminalManager) #if !DEBUG @@ -248,6 +272,7 @@ struct SupacodeApp: App { appDelegate.appStore = appStore appDelegate.terminalManager = terminalManager appDelegate.cliSocketServer = cliServer + appDelegate.remoteControlController = remoteControlController SettingsWindowManager.shared.configure( store: appStore, ghosttyShortcuts: shortcuts, @@ -641,6 +666,60 @@ struct SupacodeApp: App { return cliServer } + @MainActor + private static func makeRemoteControlController( + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> RemoteControlController { + let accessTokenStore = RemoteControlAccessTokenStore.shared + let router = RemoteControlRouter( + accessTokenProvider: { + try accessTokenStore.loadOrCreate() + }, + agentsProvider: { + let repositoriesState = appStore.state.repositories + let metadata = SidebarListView.activeAgentWorktreeMetadata( + repositories: repositoriesState.repositories, + customTitles: repositoriesState.repositoryCustomTitles + ) + return repositoriesState.activeAgents.entries.compactMap { entry in + guard let terminalState = terminalManager.stateIfExists(for: entry.worktreeID), + terminalState.surfaceView(for: entry.surfaceID) != nil + else { + return nil + } + let display = SidebarListView.activeAgentRowDisplay( + for: entry, + repositories: repositoriesState.repositories, + metadata: metadata + ) + return RemoteControlAgentSnapshot( + paneID: entry.surfaceID, + type: entry.agent.rawValue, + name: entry.displayName, + status: entry.displayState.rawValue, + projectName: display.repositoryName, + branchName: display.branchName, + lastChangedAt: entry.lastChangedAt + ) + } + }, + viewportProvider: { paneID in + guard let entry = appStore.state.repositories.activeAgents.entries.first(where: { $0.surfaceID == paneID }), + let terminalState = terminalManager.stateIfExists(for: entry.worktreeID), + let surface = terminalState.surfaceView(for: paneID) + else { + return nil + } + return surface.readViewportContentsForCLI() + } + ) + return RemoteControlController( + server: RemoteControlServer(router: router), + accessTokenStore: accessTokenStore + ) + } + // MARK: - Open handler factory private static func makeOpenHandler( diff --git a/supacode/CLIService/RemoteControlAccessTokenStore.swift b/supacode/CLIService/RemoteControlAccessTokenStore.swift new file mode 100644 index 000000000..7264501c5 --- /dev/null +++ b/supacode/CLIService/RemoteControlAccessTokenStore.swift @@ -0,0 +1,120 @@ +import Foundation +import Security + +protocol RemoteControlAccessTokenSecretStorage: Sendable { + func load() throws -> Data? + func save(_ secret: Data) throws + func remove() throws +} + +enum RemoteControlAccessTokenStoreError: Error, Equatable, Sendable { + case keychainOperationFailed(OSStatus) + case randomGenerationFailed(OSStatus) +} + +@MainActor +final class RemoteControlAccessTokenStore { + static let shared = RemoteControlAccessTokenStore() + + private static let tokenByteCount = 32 + private let storage: any RemoteControlAccessTokenSecretStorage + + init(storage: any RemoteControlAccessTokenSecretStorage = KeychainTokenStorage()) { + self.storage = storage + } + + func loadOrCreate() throws -> String { + if let secret = try storage.load(), secret.count == Self.tokenByteCount { + return Self.base64URLEncoded(secret) + } + return try rotate() + } + + func rotate() throws -> String { + let secret = try Self.makeRandomSecret() + try storage.save(secret) + return Self.base64URLEncoded(secret) + } + + func remove() throws { + try storage.remove() + } + + private static func makeRandomSecret() throws -> Data { + var bytes = [UInt8](repeating: 0, count: tokenByteCount) + let byteCount = bytes.count + let status = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) + } + guard status == errSecSuccess else { + throw RemoteControlAccessTokenStoreError.randomGenerationFailed(status) + } + return Data(bytes) + } + + private static func base64URLEncoded(_ secret: Data) -> String { + secret.base64EncodedString() + .replacing("+", with: "-") + .replacing("/", with: "_") + .replacing("=", with: "") + } +} + +private struct KeychainTokenStorage: RemoteControlAccessTokenSecretStorage { + private let service = "com.onevcat.prowl.remote-control" + private let account = "access-token" + + func load() throws -> Data? { + var item: CFTypeRef? + let status = SecItemCopyMatching(baseQuery(returningData: true) as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let secret = item as? Data else { + throw RemoteControlAccessTokenStoreError.keychainOperationFailed(errSecDecode) + } + return secret + case errSecItemNotFound: + return nil + default: + throw RemoteControlAccessTokenStoreError.keychainOperationFailed(status) + } + } + + func save(_ secret: Data) throws { + var query = baseQuery() + query[kSecValueData] = secret + query[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(query as CFDictionary, nil) + switch addStatus { + case errSecSuccess: + return + case errSecDuplicateItem: + let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, [kSecValueData: secret] as CFDictionary) + guard updateStatus == errSecSuccess else { + throw RemoteControlAccessTokenStoreError.keychainOperationFailed(updateStatus) + } + default: + throw RemoteControlAccessTokenStoreError.keychainOperationFailed(addStatus) + } + } + + func remove() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw RemoteControlAccessTokenStoreError.keychainOperationFailed(status) + } + } + + private func baseQuery(returningData: Bool = false) -> [CFString: Any] { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + if returningData { + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + } + return query + } +} diff --git a/supacode/CLIService/RemoteControlController.swift b/supacode/CLIService/RemoteControlController.swift new file mode 100644 index 000000000..542981ceb --- /dev/null +++ b/supacode/CLIService/RemoteControlController.swift @@ -0,0 +1,33 @@ +@MainActor +final class RemoteControlController { + private let server: RemoteControlServer + private let accessTokenStore: RemoteControlAccessTokenStore + private let logger = SupaLogger("RemoteControl") + + init(server: RemoteControlServer, accessTokenStore: RemoteControlAccessTokenStore = .shared) { + self.server = server + self.accessTokenStore = accessTokenStore + } + + func setEnabled(_ enabled: Bool) -> Bool { + if !enabled { + server.stop() + logger.info("Remote control bridge stopped") + return true + } + guard !server.isRunning else { return true } + do { + _ = try accessTokenStore.loadOrCreate() + try server.start() + logger.info("Remote control bridge started on loopback") + return true + } catch { + logger.warning("Unable to start remote control bridge") + return false + } + } + + func stop() { + server.stop() + } +} diff --git a/supacode/CLIService/RemoteControlRouter.swift b/supacode/CLIService/RemoteControlRouter.swift new file mode 100644 index 000000000..1c2fd9549 --- /dev/null +++ b/supacode/CLIService/RemoteControlRouter.swift @@ -0,0 +1,287 @@ +import Foundation + +nonisolated struct RemoteControlHTTPRequest: Sendable { + let method: String + let target: String + let headers: [String: String] + + init(method: String, target: String, headers: [String: String] = [:]) { + self.method = method.uppercased() + self.target = target + var normalizedHeaders: [String: String] = [:] + for (name, value) in headers { + normalizedHeaders[name.lowercased()] = value + } + self.headers = normalizedHeaders + } +} + +nonisolated struct RemoteControlHTTPResponse: Sendable { + let statusCode: Int + let headers: [String: String] + let body: Data + + nonisolated static func json( + statusCode: Int, + payload: T, + headers: [String: String] = [:] + ) -> Self { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let body = (try? encoder.encode(payload)) ?? Data() + var responseHeaders = [ + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + ] + responseHeaders.merge(headers, uniquingKeysWith: { _, new in new }) + return Self(statusCode: statusCode, headers: responseHeaders, body: body) + } +} + +nonisolated struct RemoteControlAgentSnapshot: Sendable { + let paneID: UUID + let type: String + let name: String + let status: String + let projectName: String + let branchName: String + let lastChangedAt: Date +} + +@MainActor +final class RemoteControlRouter { + typealias AccessTokenProvider = @MainActor () throws -> String + typealias AgentsProvider = @MainActor () -> [RemoteControlAgentSnapshot] + typealias ViewportProvider = @MainActor (UUID) -> String? + + static let maximumLineCount = 80 + static let maximumTextByteCount = 12 * 1024 + + private let accessTokenProvider: AccessTokenProvider + private let agentsProvider: AgentsProvider + private let viewportProvider: ViewportProvider + private let dateFormatter: ISO8601DateFormatter + private var opaqueIDByPaneID: [UUID: String] = [:] + private var paneIDByOpaqueID: [String: UUID] = [:] + + init( + accessTokenProvider: @escaping AccessTokenProvider, + agentsProvider: @escaping AgentsProvider, + viewportProvider: @escaping ViewportProvider + ) { + self.accessTokenProvider = accessTokenProvider + self.agentsProvider = agentsProvider + self.viewportProvider = viewportProvider + dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime] + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + } + + func route(_ request: RemoteControlHTTPRequest) -> RemoteControlHTTPResponse { + guard authorize(request) else { + return errorResponse( + statusCode: 401, + code: "UNAUTHORIZED", + message: "A valid bearer token is required.", + headers: ["WWW-Authenticate": "Bearer"] + ) + } + guard let components = URLComponents(string: "http://localhost\(request.target)") else { + return errorResponse(statusCode: 400, code: "INVALID_REQUEST", message: "Invalid request target.") + } + if components.path == "/v1/agents" { + guard request.method == "GET" else { return methodNotAllowed() } + guard components.queryItems?.isEmpty != false else { + return errorResponse( + statusCode: 400, code: "INVALID_REQUEST", message: "This endpoint has no query parameters.") + } + return agentsResponse() + } + + let pathComponents = components.path.split(separator: "/", omittingEmptySubsequences: true) + guard pathComponents.count == 4, + pathComponents[0] == "v1", + pathComponents[1] == "agents", + pathComponents[3] == "read" + else { + return errorResponse(statusCode: 404, code: "NOT_FOUND", message: "Endpoint not found.") + } + guard request.method == "GET" else { return methodNotAllowed() } + return readResponse(opaqueID: String(pathComponents[2]), queryItems: components.queryItems ?? []) + } + + private func authorize(_ request: RemoteControlHTTPRequest) -> Bool { + guard let authorization = request.headers["authorization"], + let accessToken = try? accessTokenProvider() + else { return false } + return constantTimeEqual(authorization, "Bearer \(accessToken)") + } + + private func agentsResponse() -> RemoteControlHTTPResponse { + let snapshots = agentsProvider() + refreshOpaqueIDs(for: snapshots) + let agents = snapshots.map { snapshot in + RemoteControlAgent( + id: opaqueIDByPaneID[snapshot.paneID] ?? "", + type: snapshot.type, + name: snapshot.name, + status: snapshot.status, + project: .init(name: snapshot.projectName, branch: snapshot.branchName), + lastChangedAt: dateFormatter.string(from: snapshot.lastChangedAt) + ) + } + return .json(statusCode: 200, payload: RemoteControlAgentsPayload(count: agents.count, agents: agents)) + } + + private func readResponse(opaqueID: String, queryItems: [URLQueryItem]) -> RemoteControlHTTPResponse { + guard queryItems.allSatisfy({ $0.name == "last" }), queryItems.count <= 1 else { + return errorResponse(statusCode: 400, code: "INVALID_REQUEST", message: "Only one 'last' parameter is allowed.") + } + let requestedLineCount: Int + if let rawLineCount = queryItems.first?.value { + guard let lineCount = Int(rawLineCount), (1...Self.maximumLineCount).contains(lineCount) else { + return errorResponse( + statusCode: 400, + code: "INVALID_REQUEST", + message: "'last' must be between 1 and \(Self.maximumLineCount)." + ) + } + requestedLineCount = lineCount + } else { + requestedLineCount = Self.maximumLineCount + } + + guard let paneID = paneIDByOpaqueID[opaqueID], let viewport = viewportProvider(paneID) else { + return errorResponse(statusCode: 404, code: "TARGET_NOT_FOUND", message: "Agent target not found.") + } + let result = limit(viewport, maximumLines: requestedLineCount, maximumBytes: Self.maximumTextByteCount) + return .json( + statusCode: 200, + payload: RemoteControlReadPayload( + agentID: opaqueID, + source: "viewport", + lineCount: lineCount(in: result.text), + text: result.text, + truncated: result.truncated + ) + ) + } + + private func refreshOpaqueIDs(for snapshots: [RemoteControlAgentSnapshot]) { + let activePaneIDs = Set(snapshots.map(\.paneID)) + for paneID in opaqueIDByPaneID.keys.filter({ !activePaneIDs.contains($0) }) { + guard let opaqueID = opaqueIDByPaneID.removeValue(forKey: paneID) else { continue } + paneIDByOpaqueID.removeValue(forKey: opaqueID) + } + for paneID in activePaneIDs where opaqueIDByPaneID[paneID] == nil { + let opaqueID = UUID().uuidString.lowercased() + opaqueIDByPaneID[paneID] = opaqueID + paneIDByOpaqueID[opaqueID] = paneID + } + } + + private func methodNotAllowed() -> RemoteControlHTTPResponse { + errorResponse( + statusCode: 405, + code: "METHOD_NOT_ALLOWED", + message: "This endpoint only accepts GET requests.", + headers: ["Allow": "GET"] + ) + } + + private func errorResponse( + statusCode: Int, + code: String, + message: String, + headers: [String: String] = [:] + ) -> RemoteControlHTTPResponse { + .json( + statusCode: statusCode, payload: RemoteControlErrorPayload(error: .init(code: code, message: message)), + headers: headers) + } + + private func constantTimeEqual(_ lhs: String, _ rhs: String) -> Bool { + let lhsBytes = Array(lhs.utf8) + let rhsBytes = Array(rhs.utf8) + let count = max(lhsBytes.count, rhsBytes.count) + var difference = lhsBytes.count ^ rhsBytes.count + for index in 0.. (text: String, truncated: Bool) { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + let lineLimitedText = lines.suffix(maximumLines).map(String.init).joined(separator: "\n") + let wasLineTruncated = lines.count > maximumLines + guard lineLimitedText.utf8.count > maximumBytes else { return (lineLimitedText, wasLineTruncated) } + + var byteCount = 0 + var suffix: [Character] = [] + for character in lineLimitedText.reversed() { + let characterByteCount = String(character).utf8.count + guard byteCount + characterByteCount <= maximumBytes else { break } + suffix.append(character) + byteCount += characterByteCount + } + return (String(suffix.reversed()), true) + } + + private func lineCount(in text: String) -> Int { + guard !text.isEmpty else { return 0 } + return text.split(separator: "\n", omittingEmptySubsequences: false).count + } +} + +private struct RemoteControlAgentsPayload: Codable { + let count: Int + let agents: [RemoteControlAgent] +} + +private struct RemoteControlAgent: Codable { + let id: String + let type: String + let name: String + let status: String + let project: Project + let lastChangedAt: String + + private enum CodingKeys: String, CodingKey { + case id, type, name, status, project + case lastChangedAt = "last_changed_at" + } + + struct Project: Codable { + let name: String + let branch: String + } +} + +private struct RemoteControlReadPayload: Codable { + let agentID: String + let source: String + let lineCount: Int + let text: String + let truncated: Bool + + private enum CodingKeys: String, CodingKey { + case agentID = "agent_id" + case source + case lineCount = "line_count" + case text + case truncated + } +} + +private struct RemoteControlErrorPayload: Codable { + let error: RemoteControlError + + struct RemoteControlError: Codable { + let code: String + let message: String + } +} diff --git a/supacode/CLIService/RemoteControlServer.swift b/supacode/CLIService/RemoteControlServer.swift new file mode 100644 index 000000000..7d81d98fa --- /dev/null +++ b/supacode/CLIService/RemoteControlServer.swift @@ -0,0 +1,362 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated enum RemoteControlServerError: Error { + case socketCreationFailed + case socketConfigurationFailed + case bindFailed + case listenFailed + case portResolutionFailed + case clientRequestFailed +} + +@MainActor +final class RemoteControlServer { + nonisolated static let loopbackHost = "127.0.0.1" + nonisolated static let port: UInt16 = 39466 + + private nonisolated static let clientIOTimeout = timeval(tv_sec: 2, tv_usec: 0) + private nonisolated static let clientDeadlineSeconds: Double = 5 + private nonisolated static let maximumHeaderByteCount = 16 * 1024 + + private let router: RemoteControlRouter + private let requestedPort: UInt16 + private let acceptQueue = DispatchQueue(label: "com.onevcat.prowl.remote-control-accept", qos: .userInitiated) + private let clientQueue = DispatchQueue( + label: "com.onevcat.prowl.remote-control-client", + qos: .userInitiated, + attributes: .concurrent + ) + private let connections = RemoteControlConnectionRegistry() + private var acceptSource: (any DispatchSourceRead)? + private var serverFD: Int32 = -1 + + private(set) var isRunning = false + private(set) var boundPort: UInt16? + + init(router: RemoteControlRouter, port: UInt16 = RemoteControlServer.port) { + self.router = router + requestedPort = port + } + + func start() throws { + guard !isRunning else { return } + serverFD = socket(AF_INET, SOCK_STREAM, 0) + guard serverFD >= 0 else { throw RemoteControlServerError.socketCreationFailed } + defer { + if !isRunning, serverFD >= 0 { + Darwin.close(serverFD) + serverFD = -1 + } + } + + try Self.configureListener(serverFD) + var address = Self.loopbackAddress(port: requestedPort) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketPointer in + bind(serverFD, socketPointer, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { throw RemoteControlServerError.bindFailed } + guard listen(serverFD, 16) == 0 else { throw RemoteControlServerError.listenFailed } + boundPort = try Self.resolveBoundPort(of: serverFD) + + let source = Self.makeAcceptSource( + serverFD: serverFD, + acceptQueue: acceptQueue, + clientQueue: clientQueue, + connections: connections, + server: self + ) + acceptSource = source + isRunning = true + source.resume() + } + + func stop() { + isRunning = false + boundPort = nil + connections.shutdownActive() + if let acceptSource { + self.acceptSource = nil + acceptSource.cancel() + } + guard serverFD >= 0 else { return } + let listeningFD = serverFD + serverFD = -1 + // Closing on the serial accept queue after cancel() guarantees no in-flight accept still uses the + // descriptor and that the listening port is released synchronously before stop() returns. + acceptQueue.sync { + _ = Darwin.close(listeningFD) + } + } + + private func response(for request: RemoteControlHTTPRequest) -> RemoteControlHTTPResponse { + guard isRunning else { + return .json( + statusCode: 503, + payload: RemoteControlServerErrorPayload( + error: .init(code: "SERVICE_UNAVAILABLE", message: "Remote control is disabled.") + ) + ) + } + return router.route(request) + } + + private nonisolated static func configureListener(_ fileDescriptor: Int32) throws { + var reuseAddress: Int32 = 1 + guard + setsockopt(fileDescriptor, SOL_SOCKET, SO_REUSEADDR, &reuseAddress, socklen_t(MemoryLayout.size)) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + + let descriptorFlags = fcntl(fileDescriptor, F_GETFD) + guard descriptorFlags >= 0, fcntl(fileDescriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + + let statusFlags = fcntl(fileDescriptor, F_GETFL) + guard statusFlags >= 0, fcntl(fileDescriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + } + + private nonisolated static func configureClient(_ fileDescriptor: Int32) throws { + let descriptorFlags = fcntl(fileDescriptor, F_GETFD) + guard descriptorFlags >= 0, fcntl(fileDescriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + + // Accepted sockets inherit O_NONBLOCK from the listener on Darwin; client I/O relies on blocking + // reads and writes bounded by the socket timeouts below plus a connection-level deadline. + let statusFlags = fcntl(fileDescriptor, F_GETFL) + guard statusFlags >= 0, fcntl(fileDescriptor, F_SETFL, statusFlags & ~O_NONBLOCK) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + + var noSigPipe: Int32 = 1 + guard setsockopt(fileDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + + var timeout = clientIOTimeout + guard setsockopt(fileDescriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) == 0, + setsockopt(fileDescriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) == 0 + else { throw RemoteControlServerError.socketConfigurationFailed } + } + + private nonisolated static func loopbackAddress(port: UInt16) -> sockaddr_in { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr = in_addr(s_addr: UInt32(0x7F00_0001).bigEndian) + return address + } + + private nonisolated static func resolveBoundPort(of fileDescriptor: Int32) throws -> UInt16 { + var address = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketPointer in + getsockname(fileDescriptor, socketPointer, &length) + } + } + guard result == 0 else { throw RemoteControlServerError.portResolutionFailed } + return UInt16(bigEndian: address.sin_port) + } + + private nonisolated static func makeAcceptSource( + serverFD: Int32, + acceptQueue: DispatchQueue, + clientQueue: DispatchQueue, + connections: RemoteControlConnectionRegistry, + server: RemoteControlServer? + ) -> any DispatchSourceRead { + let source = DispatchSource.makeReadSource(fileDescriptor: serverFD, queue: acceptQueue) + source.setEventHandler { [weak server] in + acceptPendingClients(serverFD: serverFD, server: server, connections: connections, clientQueue: clientQueue) + } + return source + } + + private nonisolated static func acceptPendingClients( + serverFD: Int32, + server: RemoteControlServer?, + connections: RemoteControlConnectionRegistry, + clientQueue: DispatchQueue + ) { + while true { + let clientFD = Darwin.accept(serverFD, nil, nil) + guard clientFD >= 0 else { + if errno == EINTR || errno == ECONNABORTED { continue } + return + } + do { + try configureClient(clientFD) + } catch { + Darwin.close(clientFD) + continue + } + connections.register(clientFD) + clientQueue.async { [weak server] in + handleAcceptedClient(clientFD: clientFD, server: server, connections: connections, responseQueue: clientQueue) + } + } + } + + private nonisolated static func handleAcceptedClient( + clientFD: Int32, + server: RemoteControlServer?, + connections: RemoteControlConnectionRegistry, + responseQueue: DispatchQueue + ) { + let request: RemoteControlHTTPRequest + do { + request = try readRequest(from: clientFD) + } catch { + try? write( + .json( + statusCode: 400, + payload: RemoteControlServerErrorPayload( + error: .init(code: "INVALID_REQUEST", message: "Malformed HTTP request.") + ) + ), + to: clientFD + ) + connections.close(clientFD) + return + } + + Task { @MainActor [weak server] in + let response = + server?.response(for: request) + ?? .json( + statusCode: 503, + payload: RemoteControlServerErrorPayload( + error: .init(code: "SERVICE_UNAVAILABLE", message: "Remote control is unavailable.") + ) + ) + responseQueue.async { + try? Self.write(response, to: clientFD) + connections.close(clientFD) + } + } + } + + private nonisolated static func readRequest(from fileDescriptor: Int32) throws -> RemoteControlHTTPRequest { + let terminator = Data([13, 10, 13, 10]) + let deadline = DispatchTime.now() + clientDeadlineSeconds + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while data.range(of: terminator) == nil { + guard DispatchTime.now() < deadline else { throw RemoteControlServerError.clientRequestFailed } + let count = buffer.withUnsafeMutableBytes { Darwin.read(fileDescriptor, $0.baseAddress, $0.count) } + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw RemoteControlServerError.clientRequestFailed } + data.append(contentsOf: buffer.prefix(Int(count))) + guard data.count <= maximumHeaderByteCount else { throw RemoteControlServerError.clientRequestFailed } + } + + guard let headerRange = data.range(of: terminator), headerRange.upperBound == data.endIndex, + let headerText = String(data: data[.. 0 { + guard DispatchTime.now() < deadline else { throw RemoteControlServerError.clientRequestFailed } + let written = Darwin.write(fileDescriptor, baseAddress, remaining) + if written < 0, errno == EINTR { continue } + guard written > 0 else { throw RemoteControlServerError.clientRequestFailed } + remaining -= written + baseAddress = baseAddress.advanced(by: written) + } + } + } + + private nonisolated static func statusReason(for statusCode: Int) -> String { + switch statusCode { + case 200: "OK" + case 400: "Bad Request" + case 401: "Unauthorized" + case 404: "Not Found" + case 405: "Method Not Allowed" + case 503: "Service Unavailable" + default: "Internal Server Error" + } + } +} + +/// Tracks in-flight client sockets so shutdown can unblock them and closes happen exactly once. +nonisolated private final class RemoteControlConnectionRegistry: @unchecked Sendable { + private let lock = NSLock() + private var activeFileDescriptors: Set = [] + + func register(_ fileDescriptor: Int32) { + lock.lock() + defer { lock.unlock() } + activeFileDescriptors.insert(fileDescriptor) + } + + /// The registry is the sole owner of client socket closes, so descriptors are closed exactly once + /// and never after their number has been reused elsewhere. + func close(_ fileDescriptor: Int32) { + lock.lock() + defer { lock.unlock() } + guard activeFileDescriptors.remove(fileDescriptor) != nil else { return } + _ = Darwin.close(fileDescriptor) + } + + /// Shuts down (but does not close) in-flight client sockets so blocked reads and writes return + /// promptly; each handler still closes its own descriptor through `close(_:)`. + func shutdownActive() { + lock.lock() + defer { lock.unlock() } + for fileDescriptor in activeFileDescriptors { + _ = Darwin.shutdown(fileDescriptor, SHUT_RDWR) + } + } +} + +nonisolated private struct RemoteControlServerErrorPayload: Codable { + let error: Error + + nonisolated struct Error: Codable { + let code: String + let message: String + } +} diff --git a/supacode/Clients/RemoteControl/RemoteControlClient.swift b/supacode/Clients/RemoteControl/RemoteControlClient.swift new file mode 100644 index 000000000..a0f334188 --- /dev/null +++ b/supacode/Clients/RemoteControl/RemoteControlClient.swift @@ -0,0 +1,17 @@ +import ComposableArchitecture + +struct RemoteControlClient { + var setEnabled: @MainActor @Sendable (Bool) -> Bool +} + +extension RemoteControlClient: DependencyKey { + static let liveValue = RemoteControlClient(setEnabled: { _ in false }) + static let testValue = RemoteControlClient(setEnabled: { _ in true }) +} + +extension DependencyValues { + var remoteControlClient: RemoteControlClient { + get { self[RemoteControlClient.self] } + set { self[RemoteControlClient.self] = newValue } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 95784dd6b..fa3600229 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -81,6 +81,7 @@ struct AppFeature { case navigateSearchPrevious case endSearch case systemNotificationsPermissionFailed(errorMessage: String?) + case remoteControlConfigurationFailed case systemNotificationTapped(worktreeID: Worktree.ID, surfaceID: UUID) case alert(PresentationAction) case terminalEvent(TerminalClient.Event) @@ -100,6 +101,7 @@ struct AppFeature { @Dependency(NotificationSoundClient.self) var notificationSoundClient @Dependency(SystemNotificationClient.self) var systemNotificationClient @Dependency(DockClient.self) var dockClient + @Dependency(RemoteControlClient.self) var remoteControlClient @Dependency(TerminalClient.self) var terminalClient @Dependency(WorktreeInfoWatcherClient.self) var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient @@ -396,6 +398,7 @@ struct AppFeature { case .settings(.delegate(.settingsChanged(let settings))): let shouldCheckSystemNotificationPermission = settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled + let remoteControlEnabled = settings.remoteControlEnabled state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled state.settings.keybindingUserOverrides = settings.keybindingUserOverrides state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles @@ -484,9 +487,23 @@ struct AppFeature { }, .run { _ in await dockClient.setNotificationBadge(badgeCount) + }, + .run { send in + guard !(await remoteControlClient.setEnabled(remoteControlEnabled)), remoteControlEnabled else { return } + await send(.remoteControlConfigurationFailed) } ) + case .remoteControlConfigurationFailed: + return .merge( + .send(.settings(.setRemoteControlEnabled(false))), + .send( + .repositories( + .showToast(.warning("Unable to start remote control. Check whether its local port is in use.")) + ) + ) + ) + case .settings(.delegate(.terminalFontSizeChanged)): return .none diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index d0eb264b9..33508a981 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -23,6 +23,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var copyUntrackedOnWorktreeCreate: Bool var pullRequestMergeStrategy: PullRequestMergeStrategy var restoreTerminalLayoutOnLaunch: Bool + var remoteControlEnabled: Bool var terminalFontSize: Float32? var archivedAutoDeletePeriod: AutoDeletePeriod? var keybindingUserOverrides: KeybindingUserOverrideStore @@ -68,6 +69,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { copyUntrackedOnWorktreeCreate: false, pullRequestMergeStrategy: .merge, restoreTerminalLayoutOnLaunch: false, + remoteControlEnabled: false, archivedAutoDeletePeriod: nil, terminalFontSize: nil, keybindingUserOverrides: .empty, @@ -112,6 +114,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { copyUntrackedOnWorktreeCreate: Bool = false, pullRequestMergeStrategy: PullRequestMergeStrategy = .merge, restoreTerminalLayoutOnLaunch: Bool = false, + remoteControlEnabled: Bool = false, archivedAutoDeletePeriod: AutoDeletePeriod? = nil, terminalFontSize: Float32? = nil, keybindingUserOverrides: KeybindingUserOverrideStore = .empty, @@ -154,6 +157,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate self.pullRequestMergeStrategy = pullRequestMergeStrategy self.restoreTerminalLayoutOnLaunch = restoreTerminalLayoutOnLaunch + self.remoteControlEnabled = remoteControlEnabled self.archivedAutoDeletePeriod = archivedAutoDeletePeriod self.terminalFontSize = terminalFontSize self.keybindingUserOverrides = keybindingUserOverrides @@ -199,6 +203,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(copyUntrackedOnWorktreeCreate, forKey: .copyUntrackedOnWorktreeCreate) try container.encode(pullRequestMergeStrategy, forKey: .pullRequestMergeStrategy) try container.encode(restoreTerminalLayoutOnLaunch, forKey: .restoreTerminalLayoutOnLaunch) + try container.encode(remoteControlEnabled, forKey: .remoteControlEnabled) try container.encodeIfPresent(archivedAutoDeletePeriod?.rawValue, forKey: .archivedAutoDeletePeriod) try container.encodeIfPresent(terminalFontSize, forKey: .terminalFontSize) try container.encode(keybindingUserOverrides, forKey: .keybindingUserOverrides) @@ -245,6 +250,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy case restoreTerminalLayoutOnLaunch + case remoteControlEnabled case archivedAutoDeletePeriod case terminalFontSize case keybindingUserOverrides @@ -333,6 +339,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { restoreTerminalLayoutOnLaunch = try container.decodeIfPresent(Bool.self, forKey: .restoreTerminalLayoutOnLaunch) ?? Self.default.restoreTerminalLayoutOnLaunch + remoteControlEnabled = + try container.decodeIfPresent(Bool.self, forKey: .remoteControlEnabled) + ?? Self.default.remoteControlEnabled if let rawAutoDelete = try container.decodeIfPresent(Int.self, forKey: .archivedAutoDeletePeriod) { archivedAutoDeletePeriod = AutoDeletePeriod(rawValue: rawAutoDelete) } else { diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 5e1be01b1..87eb55fcb 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -31,6 +31,7 @@ struct SettingsFeature { var copyUntrackedOnWorktreeCreate: Bool var pullRequestMergeStrategy: PullRequestMergeStrategy var restoreTerminalLayoutOnLaunch: Bool + var remoteControlEnabled: Bool var terminalFontSize: Float32? var keybindingUserOverrides: KeybindingUserOverrideStore var defaultViewMode: DefaultViewMode @@ -90,6 +91,7 @@ struct SettingsFeature { copyUntrackedOnWorktreeCreate = settings.copyUntrackedOnWorktreeCreate pullRequestMergeStrategy = settings.pullRequestMergeStrategy restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch + remoteControlEnabled = settings.remoteControlEnabled terminalFontSize = settings.terminalFontSize keybindingUserOverrides = settings.keybindingUserOverrides defaultViewMode = settings.defaultViewMode @@ -138,6 +140,7 @@ struct SettingsFeature { copyUntrackedOnWorktreeCreate: copyUntrackedOnWorktreeCreate, pullRequestMergeStrategy: pullRequestMergeStrategy, restoreTerminalLayoutOnLaunch: restoreTerminalLayoutOnLaunch, + remoteControlEnabled: remoteControlEnabled, archivedAutoDeletePeriod: archivedAutoDeletePeriod, terminalFontSize: terminalFontSize, keybindingUserOverrides: keybindingUserOverrides, @@ -167,6 +170,7 @@ struct SettingsFeature { case settingsLoaded(GlobalSettings) case setSelection(SettingsSection?) case setSystemNotificationsEnabled(Bool) + case setRemoteControlEnabled(Bool) case setCommandFinishedNotificationThreshold(String) case setTerminalFontSize(Float32?) case clearTerminalLayoutSnapshotButtonTapped @@ -258,6 +262,7 @@ struct SettingsFeature { state.copyUntrackedOnWorktreeCreate = normalizedSettings.copyUntrackedOnWorktreeCreate state.pullRequestMergeStrategy = normalizedSettings.pullRequestMergeStrategy state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch + state.remoteControlEnabled = normalizedSettings.remoteControlEnabled state.terminalFontSize = normalizedSettings.terminalFontSize state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides state.defaultViewMode = normalizedSettings.defaultViewMode @@ -308,6 +313,11 @@ struct SettingsFeature { state.syncGlobalDefaults(from: state.globalSettings) return persist(state) + case .setRemoteControlEnabled(let isEnabled): + state.remoteControlEnabled = isEnabled + state.syncGlobalDefaults(from: state.globalSettings) + return persist(state) + case .setTerminalFontSize(let fontSize): guard state.terminalFontSize != fontSize else { return .none } state.terminalFontSize = fontSize diff --git a/supacode/Features/Settings/Views/AdvancedSettingsView.swift b/supacode/Features/Settings/Views/AdvancedSettingsView.swift index 4c19d3987..957f4279d 100644 --- a/supacode/Features/Settings/Views/AdvancedSettingsView.swift +++ b/supacode/Features/Settings/Views/AdvancedSettingsView.swift @@ -1,8 +1,10 @@ +import AppKit import ComposableArchitecture import SwiftUI struct AdvancedSettingsView: View { @Bindable var store: StoreOf + @State private var remoteControlTokenStatus: String? var body: some View { VStack(alignment: .leading) { @@ -110,9 +112,74 @@ struct AdvancedSettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + Section("Remote Control (Experimental)") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Enable read-only mobile bridge", isOn: $store.remoteControlEnabled) + .help("Start or stop the authenticated read-only bridge immediately") + + Text( + "The bridge listens only on 127.0.0.1. Use a private TLS tunnel or overlay to reach it from a phone." + ) + .foregroundStyle(.secondary) + .font(.callout) + + Text("It exposes agent status and limited viewport text only; it cannot send input or manage tabs.") + .foregroundStyle(.secondary) + .font(.callout) + + HStack(spacing: 8) { + Button("Copy Access Token") { + copyRemoteControlToken() + } + .help("Copy the Keychain-backed token required by a paired mobile client") + .buttonStyle(.bordered) + .disabled(!store.remoteControlEnabled) + + Button("Rotate and Copy Access Token") { + rotateAndCopyRemoteControlToken() + } + .help("Replace the current token and revoke clients using the previous token") + .buttonStyle(.bordered) + .disabled(!store.remoteControlEnabled) + } + + if let remoteControlTokenStatus { + Text(remoteControlTokenStatus) + .foregroundStyle(.secondary) + .font(.callout) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } } .formStyle(.grouped) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + + private func copyRemoteControlToken() { + do { + let token = try RemoteControlAccessTokenStore.shared.loadOrCreate() + copyToPasteboard(token) + remoteControlTokenStatus = "Access token copied." + } catch { + remoteControlTokenStatus = "Unable to access the Keychain." + } + } + + private func rotateAndCopyRemoteControlToken() { + do { + let token = try RemoteControlAccessTokenStore.shared.rotate() + copyToPasteboard(token) + remoteControlTokenStatus = "New access token copied; previous tokens are revoked." + } catch { + remoteControlTokenStatus = "Unable to access the Keychain." + } + } + + private func copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } } diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 592c8bb0a..29e51fba4 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -41,6 +41,26 @@ struct AppFeatureSettingsChangedTests { await store.finish() } + @Test(.dependencies) func settingsChangedConfiguresRemoteControlWithThePersistedValue() async { + var settings = GlobalSettings.default + settings.remoteControlEnabled = true + let configuredValues = LockIsolated<[Bool]>([]) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.remoteControlClient.setEnabled = { enabled in + configuredValues.withValue { $0.append(enabled) } + return true + } + } + store.exhaustivity = .off + + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(configuredValues.value == [true]) + } + @Test(.dependencies) func terminalFontSizeEventDoesNotFanOutGlobalSettingsEffects() async { let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index 97b57b022..3eea0d6ad 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -140,7 +140,7 @@ struct ExternalDiffToolTests { try runGit(["config", "user.name", "Test User"], in: repoURL) try "one\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) try runGit(["add", "tracked.txt"], in: repoURL) - try runGit(["commit", "-m", "Initial"], in: repoURL) + try runGit(["commit", "--no-verify", "-m", "Initial"], in: repoURL) try "two\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) try "new\n".write(to: repoURL.appending(path: "untracked.txt"), atomically: true, encoding: .utf8) diff --git a/supacodeTests/RemoteControlAccessTokenStoreTests.swift b/supacodeTests/RemoteControlAccessTokenStoreTests.swift new file mode 100644 index 000000000..3f9747475 --- /dev/null +++ b/supacodeTests/RemoteControlAccessTokenStoreTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct RemoteControlAccessTokenStoreTests { + @Test func loadOrCreateCreatesAndReusesBase64URLToken() throws { + let storage = InMemoryTokenStorage() + let store = RemoteControlAccessTokenStore(storage: storage) + + let created = try store.loadOrCreate() + let loaded = try store.loadOrCreate() + + #expect(created == loaded) + #expect(created.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) + #expect(decodedBase64URL(created)?.count == 32) + #expect(storage.secret == decodedBase64URL(created)) + } + + @Test func rotateReplacesStoredToken() throws { + let storage = InMemoryTokenStorage() + let store = RemoteControlAccessTokenStore(storage: storage) + + let original = try store.loadOrCreate() + let rotated = try store.rotate() + let reloaded = try RemoteControlAccessTokenStore(storage: storage).loadOrCreate() + + #expect(rotated != original) + #expect(reloaded == rotated) + } + + @Test func removeClearsStoredToken() throws { + let storage = InMemoryTokenStorage() + let store = RemoteControlAccessTokenStore(storage: storage) + let original = try store.loadOrCreate() + + try store.remove() + + #expect(storage.secret == nil) + #expect(try store.loadOrCreate() != original) + } + + @Test func invalidStoredSecretIsReplaced() throws { + let storage = InMemoryTokenStorage(secret: Data(repeating: 0, count: 31)) + let store = RemoteControlAccessTokenStore(storage: storage) + + let token = try store.loadOrCreate() + + #expect(decodedBase64URL(token)?.count == 32) + #expect(storage.secret?.count == 32) + } + + private func decodedBase64URL(_ token: String) -> Data? { + let base64 = token.replacing("-", with: "+").replacing("_", with: "/") + let padding = String(repeating: "=", count: (4 - base64.count % 4) % 4) + return Data(base64Encoded: base64 + padding) + } +} + +private final class InMemoryTokenStorage: RemoteControlAccessTokenSecretStorage, + @unchecked Sendable +{ + private let lock = NSLock() + private var value: Data? + + init(secret: Data? = nil) { + value = secret + } + + var secret: Data? { + lock.withLock { value } + } + + func load() throws -> Data? { + lock.withLock { value } + } + + func save(_ secret: Data) throws { + lock.withLock { value = secret } + } + + func remove() throws { + lock.withLock { value = nil } + } +} diff --git a/supacodeTests/RemoteControlRouterTests.swift b/supacodeTests/RemoteControlRouterTests.swift new file mode 100644 index 000000000..4d50b60db --- /dev/null +++ b/supacodeTests/RemoteControlRouterTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct RemoteControlRouterTests { + private let token = "test-access-token" + private let paneID = UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")! + + @Test func bridgeIsPinnedToIPv4Loopback() { + #expect(RemoteControlServer.loopbackHost == "127.0.0.1") + #expect(RemoteControlServer.port == 39466) + } + + @Test func unauthorizedRequestsDoNotReachLiveStateProviders() throws { + let recorder = RouterRecorder() + let response = makeRouter(recorder: recorder).route(.init(method: "GET", target: "/v1/agents")) + + #expect(response.statusCode == 401) + #expect(recorder.agentRequests == 0) + #expect(errorCode(in: response) == "UNAUTHORIZED") + } + + @Test func agentsUseOpaqueIDsAndDoNotIncludeViewportContent() throws { + let recorder = RouterRecorder() + recorder.viewport = "token=/Users/alice/secret/.env" + let response = makeRouter(recorder: recorder).route(authorizedRequest(target: "/v1/agents")) + let body = try #require(String(bytes: response.body, encoding: .utf8)) + + #expect(response.statusCode == 200) + #expect(!body.contains("/Users/alice/secret")) + #expect(!body.contains(paneID.uuidString)) + #expect(agentID(in: response) != paneID.uuidString) + } + + @Test func readRequiresOpaqueIDAndCapsLinesAndBytes() throws { + let recorder = RouterRecorder() + recorder.viewport = (0..<100).map { "\($0)-🦊" }.joined(separator: "\n") + let router = makeRouter(recorder: recorder) + let opaqueID = try #require(agentID(in: router.route(authorizedRequest(target: "/v1/agents")))) + + let rawIDResponse = router.route(authorizedRequest(target: "/v1/agents/\(paneID.uuidString)/read")) + let readResponse = router.route(authorizedRequest(target: "/v1/agents/\(opaqueID)/read?last=80")) + let payload = try #require(jsonObject(in: readResponse) as? [String: Any]) + let text = try #require(payload["text"] as? String) + + #expect(rawIDResponse.statusCode == 404) + #expect(readResponse.statusCode == 200) + #expect(payload["truncated"] as? Bool == true) + #expect(text.split(separator: "\n", omittingEmptySubsequences: false).count <= RemoteControlRouter.maximumLineCount) + #expect(text.utf8.count <= RemoteControlRouter.maximumTextByteCount) + #expect(recorder.viewportRequests == 1) + } + + @Test func readPreservesUTF8WhenTheViewportExceedsTheByteLimit() throws { + let recorder = RouterRecorder() + recorder.viewport = String(repeating: "🦊", count: RemoteControlRouter.maximumTextByteCount) + let router = makeRouter(recorder: recorder) + let opaqueID = try #require(agentID(in: router.route(authorizedRequest(target: "/v1/agents")))) + let response = router.route(authorizedRequest(target: "/v1/agents/\(opaqueID)/read")) + let payload = try #require(jsonObject(in: response) as? [String: Any]) + let text = try #require(payload["text"] as? String) + + #expect(payload["truncated"] as? Bool == true) + #expect(text.utf8.count <= RemoteControlRouter.maximumTextByteCount) + #expect(!text.contains("�")) + } + + @Test func writeLikePathsAndNonGETMethodsAreRejectedWithoutViewportAccess() throws { + let recorder = RouterRecorder() + let router = makeRouter(recorder: recorder) + let unknownResponse = router.route(authorizedRequest(target: "/v1/send")) + let methodResponse = router.route( + .init(method: "POST", target: "/v1/agents", headers: ["Authorization": "Bearer \(token)"])) + + #expect(unknownResponse.statusCode == 404) + #expect(methodResponse.statusCode == 405) + #expect(recorder.viewportRequests == 0) + } + + private func makeRouter(recorder: RouterRecorder) -> RemoteControlRouter { + RemoteControlRouter( + accessTokenProvider: { self.token }, + agentsProvider: { + recorder.agentRequests += 1 + return [ + RemoteControlAgentSnapshot( + paneID: self.paneID, + type: "codex", + name: "codex", + status: "working", + projectName: "Prowl", + branchName: "relay/mobile-control", + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + ] + }, + viewportProvider: { paneID in + recorder.viewportRequests += 1 + return paneID == self.paneID ? recorder.viewport : nil + } + ) + } + + private func authorizedRequest(target: String) -> RemoteControlHTTPRequest { + .init(method: "GET", target: target, headers: ["Authorization": "Bearer \(token)"]) + } + + private func jsonObject(in response: RemoteControlHTTPResponse) throws -> Any { + try JSONSerialization.jsonObject(with: response.body) + } + + private func errorCode(in response: RemoteControlHTTPResponse) -> String? { + guard let object = try? jsonObject(in: response), + let root = object as? [String: Any], + let error = root["error"] as? [String: Any] + else { return nil } + return error["code"] as? String + } + + private func agentID(in response: RemoteControlHTTPResponse) -> String? { + guard let object = try? jsonObject(in: response), + let root = object as? [String: Any], + let agents = root["agents"] as? [[String: Any]] + else { return nil } + return agents.first?["id"] as? String + } +} + +@MainActor +private final class RouterRecorder { + var agentRequests = 0 + var viewportRequests = 0 + var viewport = "ready" +} diff --git a/supacodeTests/RemoteControlServerTests.swift b/supacodeTests/RemoteControlServerTests.swift new file mode 100644 index 000000000..4e8951a5a --- /dev/null +++ b/supacodeTests/RemoteControlServerTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing + +@testable import supacode + +#if canImport(Darwin) + import Darwin +#endif + +@MainActor +struct RemoteControlServerTests { + private let token = "server-test-token" + private let paneID = UUID(uuidString: "1B9C6A34-27C4-4C39-8F0A-6C4C3E0F5A21")! + + @Test func halfOpenClientDoesNotBlockConcurrentValidRequests() async throws { + let server = makeServer() + try server.start() + defer { server.stop() } + let port = try #require(server.boundPort) + + let halfOpenFD = try connect(to: port) + defer { Darwin.close(halfOpenFD) } + + let statusCode = try await requestAgentsStatusCode(port: port) + #expect(statusCode == 200) + } + + @Test func disableAndReEnableRecoverWhileAClientIsStalled() async throws { + let server = makeServer() + try server.start() + let firstPort = try #require(server.boundPort) + let stalledFD = try connect(to: firstPort) + defer { Darwin.close(stalledFD) } + send("GET /v1/agents HTTP/1.1\r\nauthorization: incomplete", on: stalledFD) + + server.stop() + #expect(server.boundPort == nil) + + // The listening port must be released synchronously so re-enabling on the same fixed port works. + let restartedServer = makeServer(port: firstPort) + try restartedServer.start() + defer { restartedServer.stop() } + + let statusCode = try await requestAgentsStatusCode(port: try #require(restartedServer.boundPort)) + #expect(statusCode == 200) + } + + @Test func clientsDisconnectingBeforeResponsesDoNotTerminateTheServer() async throws { + let server = makeServer() + try server.start() + defer { server.stop() } + let port = try #require(server.boundPort) + + let malformedFD = try connect(to: port) + send("NOT-AN-HTTP-REQUEST\r\n\r\n", on: malformedFD) + abruptlyClose(malformedFD) + + let unauthorizedFD = try connect(to: port) + send("GET /v1/agents HTTP/1.1\r\nhost: 127.0.0.1\r\n\r\n", on: unauthorizedFD) + abruptlyClose(unauthorizedFD) + + let authorizedFD = try connect(to: port) + send("GET /v1/agents HTTP/1.1\r\nauthorization: Bearer \(token)\r\n\r\n", on: authorizedFD) + abruptlyClose(authorizedFD) + + let statusCode = try await requestAgentsStatusCode(port: port) + #expect(statusCode == 200) + } + + private func makeServer(port: UInt16 = 0) -> RemoteControlServer { + let router = RemoteControlRouter( + accessTokenProvider: { self.token }, + agentsProvider: { + [ + RemoteControlAgentSnapshot( + paneID: self.paneID, + type: "codex", + name: "codex", + status: "working", + projectName: "Prowl", + branchName: "relay/mobile-control", + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + ] + }, + viewportProvider: { _ in "ready" } + ) + return RemoteControlServer(router: router, port: port) + } + + private func requestAgentsStatusCode(port: UInt16) async throws -> Int { + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/agents")!) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 5 + let (_, response) = try await URLSession.shared.data(for: request) + return (response as? HTTPURLResponse)?.statusCode ?? -1 + } + + private func connect(to port: UInt16) throws -> Int32 { + let fileDescriptor = socket(AF_INET, SOCK_STREAM, 0) + try #require(fileDescriptor >= 0) + var noSigPipe: Int32 = 1 + _ = setsockopt(fileDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr = in_addr(s_addr: UInt32(0x7F00_0001).bigEndian) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketPointer in + Darwin.connect(fileDescriptor, socketPointer, socklen_t(MemoryLayout.size)) + } + } + try #require(result == 0) + return fileDescriptor + } + + private func send(_ text: String, on fileDescriptor: Int32) { + let bytes = Array(text.utf8) + _ = bytes.withUnsafeBytes { Darwin.write(fileDescriptor, $0.baseAddress, $0.count) } + } + + /// Closes with `SO_LINGER` zero so the peer observes an abrupt reset instead of a graceful close. + private func abruptlyClose(_ fileDescriptor: Int32) { + var lingerOption = linger(l_onoff: 1, l_linger: 0) + _ = setsockopt(fileDescriptor, SOL_SOCKET, SO_LINGER, &lingerOption, socklen_t(MemoryLayout.size)) + Darwin.close(fileDescriptor) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 64c20b57a..ab621a5a5 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -118,6 +118,23 @@ struct SettingsFeatureTests { #expect(settingsFile.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func remoteControlBindingPersistsAndNotifiesTheApp() async { + var initialSettings = GlobalSettings.default + initialSettings.remoteControlEnabled = false + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.remoteControlEnabled, true))) { + $0.remoteControlEnabled = true + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.remoteControlEnabled == true) + } + @Test(.dependencies) func selectingNotificationSoundPlaysPreview() async { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index edccf530c..54a6fd4f8 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -83,6 +83,26 @@ struct SettingsFilePersistenceTests { #expect(reloaded.global.showNotificationDotOnDock == true) } + @Test(.dependencies) func saveAndReloadRemoteControlEnabled() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { $0.global.remoteControlEnabled = true } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(reloaded.global.remoteControlEnabled == true) + } + @Test(.dependencies) func saveAndReloadShelfSpineTintPreferences() throws { let storage = SettingsTestStorage() @@ -167,6 +187,7 @@ struct SettingsFilePersistenceTests { #expect(settings.global.promptForWorktreeCreation == true) #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) #expect(settings.global.restoreTerminalLayoutOnLaunch == false) + #expect(settings.global.remoteControlEnabled == false) #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) #expect(settings.global.showRunButtonInToolbar == true) #expect(settings.global.showDefaultEditorInToolbar == true)