From 74c2f3b60380bfd0b7d7e9a9ade2017a5303557b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Nozal?= Date: Tue, 30 Jun 2026 11:15:23 +0200 Subject: [PATCH] feat: add optional LAN mode to local API --- README.md | 14 ++++++++ .../Sources/CursorAPI/AppModel.swift | 33 +++++++++++++++++-- .../Sources/CursorAPI/ContentView.swift | 24 ++++++++++++-- .../CursorAPICore/LocalAPIServer.swift | 24 +++++++++----- .../Sources/CursorAPICore/Models.swift | 8 +++++ scripts/build-app.sh | 30 +++++++++++++++++ scripts/run-dev.sh | 11 +++++++ 7 files changed, 131 insertions(+), 13 deletions(-) create mode 100755 scripts/build-app.sh create mode 100755 scripts/run-dev.sh diff --git a/README.md b/README.md index 6efee54..f749c7f 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,20 @@ The bridge process also accepts `CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`; the default Release packages prefer a bundled Node runtime for the local SDK bridge and fall back to Bun when Node is unavailable. +### macOS app development + +Two helper scripts live under `scripts/` for iterating on the local macOS app: + +- `scripts/run-dev.sh` — kills any running instance and runs the Swift package + directly with `swift run`. Fastest for UI or API changes, but does not build a + signed app bundle. +- `scripts/build-app.sh` — builds a development app bundle with + `macos/CursorAPI/Scripts/package-app.sh --development` and opens it. Requires + Node/Bun for the bundled SDK bridge step. + +Both scripts assume the macOS app is named `API for Cursor` and kill the running +instance before launching so the new build can bind to the configured port. + ## Cloudflare The Worker uses Cloudflare Vite and D1. diff --git a/macos/CursorAPI/Sources/CursorAPI/AppModel.swift b/macos/CursorAPI/Sources/CursorAPI/AppModel.swift index 55adf1f..e2416ce 100644 --- a/macos/CursorAPI/Sources/CursorAPI/AppModel.swift +++ b/macos/CursorAPI/Sources/CursorAPI/AppModel.swift @@ -1,5 +1,6 @@ import Combine import CursorAPICore +import Darwin import Foundation import ServiceManagement @@ -61,8 +62,36 @@ final class CursorAPIAppModel: ObservableObject { updateStatusText() } + /// The URL shown to the user and copied into agent configs. When the server is bound to + /// all interfaces (`0.0.0.0`), replace the host with the LAN IP of `en0` so remote clients + /// on the same network can reach the local API without editing the endpoint manually. var baseURL: String { - settings.baseURL.absoluteString + if settings.bindHost == "0.0.0.0" { + let ip = lanIPAddress() ?? "0.0.0.0" + return "http://\(ip):\(settings.port)/v1" + } + return settings.baseURL.absoluteString + } + + /// Looks up the IPv4 address assigned to the primary Wi-Fi interface (`en0`). + /// Falls back to `nil` when the interface is unavailable or the device is offline. + private func lanIPAddress() -> String? { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0 else { return nil } + defer { freeifaddrs(ifaddr) } + var ptr = ifaddr + while let ifa = ptr?.pointee { + let name = String(cString: ifa.ifa_name) + if name == "en0", ifa.ifa_addr.pointee.sa_family == UInt8(AF_INET) { + var addr = ifa.ifa_addr.pointee + var hostname = [UInt8](repeating: 0, count: Int(NI_MAXHOST)) + getnameinfo(&addr, socklen_t(ifa.ifa_addr.pointee.sa_len), &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST) + let end = hostname.firstIndex(of: 0) ?? hostname.endIndex + return String(decoding: hostname[.. UInt16 { + /// Tries to bind the server on the preferred port, falling back to the next available ports. + /// The host parameter is forwarded to each attempt so LAN binding is applied consistently. + public func start(preferredPort: UInt16, fallbackLimit: Int, host: String = "127.0.0.1") throws -> UInt16 { var lastError: (any Error)? let maxAttempts = max(1, fallbackLimit) for offset in 0..? - init(port: UInt16) { + init(port: UInt16, host: String) { self.port = port + self.host = host } func succeed() { @@ -1261,13 +1267,13 @@ private final class ListenerStartResult: @unchecked Sendable { } func fail(_ error: any Error) { - complete(.failure(CursorAPIError.transport("Could not listen on 127.0.0.1:\(port): \(error.localizedDescription)"))) + complete(.failure(CursorAPIError.transport("Could not listen on \(host):\(port): \(error.localizedDescription)"))) } func wait(timeout: TimeInterval = 2.0) throws { let deadline = DispatchTime.now() + timeout if semaphore.wait(timeout: deadline) == .timedOut { - throw CursorAPIError.transport("Timed out starting local API on 127.0.0.1:\(port).") + throw CursorAPIError.transport("Timed out starting local API on \(host):\(port).") } let completed = lock.withLock { result } if case .failure(let error) = completed { diff --git a/macos/CursorAPI/Sources/CursorAPICore/Models.swift b/macos/CursorAPI/Sources/CursorAPICore/Models.swift index e396ff5..d6f7096 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/Models.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/Models.swift @@ -8,6 +8,9 @@ public struct CursorAPISettings: Codable, Equatable, Sendable { public static let legacyCursorAPIBaseURL = "https://api.cursor.com" public var port: UInt16 + /// Network interface the local server binds to. Defaults to loopback only (`127.0.0.1`); + /// may be set to `0.0.0.0` to accept connections from the local network. + public var bindHost: String public var cursorAPIKey: String public var keychainCursorAPIKeyAvailable: Bool public var cursorAPIBaseURL: String @@ -19,6 +22,7 @@ public struct CursorAPISettings: Codable, Equatable, Sendable { public init( port: UInt16 = 8787, + bindHost: String = "127.0.0.1", cursorAPIKey: String = "", keychainCursorAPIKeyAvailable: Bool = false, cursorAPIBaseURL: String = "", @@ -29,6 +33,7 @@ public struct CursorAPISettings: Codable, Equatable, Sendable { menuBarOnly: Bool = false ) { self.port = port + self.bindHost = bindHost self.cursorAPIKey = cursorAPIKey self.keychainCursorAPIKeyAvailable = keychainCursorAPIKeyAvailable self.cursorAPIBaseURL = cursorAPIBaseURL @@ -62,6 +67,7 @@ public struct CursorAPISettings: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case port + case bindHost case cursorAPIKey case cursorAPIBaseURL case backendBaseURL @@ -74,6 +80,7 @@ public struct CursorAPISettings: Codable, Equatable, Sendable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) port = try container.decodeIfPresent(UInt16.self, forKey: .port) ?? 8787 + bindHost = try container.decodeIfPresent(String.self, forKey: .bindHost) ?? "127.0.0.1" cursorAPIKey = try container.decodeIfPresent(String.self, forKey: .cursorAPIKey) ?? "" keychainCursorAPIKeyAvailable = false cursorAPIBaseURL = try container.decodeIfPresent(String.self, forKey: .cursorAPIBaseURL) ?? "" @@ -87,6 +94,7 @@ public struct CursorAPISettings: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(port, forKey: .port) + try container.encode(bindHost, forKey: .bindHost) try container.encode(cursorAPIKey, forKey: .cursorAPIKey) try container.encode(cursorAPIBaseURL, forKey: .cursorAPIBaseURL) try container.encode(backendBaseURL, forKey: .backendBaseURL) diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..7040c5c --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Convenience script for local macOS development: package a development app +# bundle and open it. Development builds allow a missing bundled SDK bridge +# and display "SDK Bridge Missing" in the UI until the full bridge is built. + +# Kill any running instance so the new build can bind to the port on launch. +pkill -x "API for Cursor" 2>/dev/null && echo "Stopped running instance." || true + +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +app_dir="$repo_dir/macos/CursorAPI" + +# Ensure the Node dependencies used by the SDK bridge packaging step exist. +if [ ! -d "$repo_dir/node_modules/@cursor/sdk" ]; then + echo "Installing Node dependencies for the bundled SDK bridge..." + (cd "$repo_dir" && npm install) +fi + +cd "$app_dir" +"$app_dir/Scripts/package-app.sh" --development + +APP="dist/API for Cursor.app" +echo "" +echo "Built: $APP" +echo "cd $app_dir" +echo "To launch: open \"$APP\"" +echo "If macOS blocks it: xattr -dr com.apple.quarantine \"$APP\"" + +open "$APP" diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh new file mode 100755 index 0000000..a541c8f --- /dev/null +++ b/scripts/run-dev.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Convenience script for local macOS development: run the Swift package directly +# without building a full app bundle. Useful for fast iteration on the UI or API. + +# Kill any running instance so the dev build can bind to the port. +pkill -x "API for Cursor" 2>/dev/null && echo "Stopped running instance." || true + +cd "$(dirname "$0")/../macos/CursorAPI" +swift run