Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 31 additions & 2 deletions macos/CursorAPI/Sources/CursorAPI/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Combine
import CursorAPICore
import Darwin
import Foundation
import ServiceManagement

Expand Down Expand Up @@ -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<ifaddrs>?
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[..<end], as: UTF8.self)
}
ptr = ifa.ifa_next
}
return nil
}

var chatCompletionsURL: String {
Expand Down Expand Up @@ -215,7 +244,7 @@ final class CursorAPIAppModel: ObservableObject {
needsKeychainPermission = settings.keychainCursorAPIKeyAvailable && !settings.hasInlineCursorAPIKey
}
let requestedPort = settings.port
let activePort = try server.start(preferredPort: requestedPort, fallbackLimit: Self.portFallbackLimit)
let activePort = try server.start(preferredPort: requestedPort, fallbackLimit: Self.portFallbackLimit, host: settings.bindHost)
settings.port = activePort
isRunning = true
updateStatusText()
Expand Down
24 changes: 22 additions & 2 deletions macos/CursorAPI/Sources/CursorAPI/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SwiftUI
struct ContentView: View {
@ObservedObject var model: CursorAPIAppModel
@State private var showingAPIKeyDialog = false
@State private var page: TopPage = .connection

var body: some View {
ZStack {
Expand All @@ -25,7 +26,11 @@ struct ContentView: View {

ScrollView {
VStack(alignment: .leading, spacing: 16) {
guidedContent
if page == .settings {
SettingsPage(model: model, apiKeyReplacementRequestID: 0)
} else {
guidedContent
}
}
.padding(24)
}
Expand All @@ -43,6 +48,8 @@ struct ContentView: View {
}
}

/// Tabs shown in the window header that switch between the guided connection page and the
/// full settings page where the bind interface and port can be changed.
enum TopPage: String, CaseIterable, Identifiable {
case connection = "Connection"
case settings = "Settings"
Expand All @@ -56,6 +63,8 @@ struct ContentView: View {

Spacer()

HeaderPageTabs(selection: $page)

MenuBarModeToggle(
isOn: Binding(
get: { model.settings.menuBarOnly },
Expand Down Expand Up @@ -1280,11 +1289,22 @@ struct SettingsPage: View {
}

SettingsGroup(title: "Local Server", icon: "server.rack") {
SettingsFieldRow(title: "Port", subtitle: "Loopback listener port") {
SettingsFieldRow(title: "Port", subtitle: "Listener port") {
TextField("8787", value: $model.settings.port, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
/// Allows the user to choose between loopback-only (default) and LAN mode. LAN mode
/// binds the server on `0.0.0.0` and shows the primary Wi-Fi IP in the endpoint URL.
SettingsFieldRow(title: "Bind Interface", subtitle: "Network interface to accept connections on") {
Picker("", selection: $model.settings.bindHost) {
Text("Localhost").tag("127.0.0.1")
Text("LAN (all interfaces)").tag("0.0.0.0")
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 180)
}
SettingsFieldRow(title: "Launch at Login", subtitle: "Start \(CursorAPIBrand.displayName) when macOS signs in") {
Toggle("", isOn: $model.settings.launchAtLogin)
.labelsHidden()
Expand Down
24 changes: 15 additions & 9 deletions macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,15 @@ public final class LocalAPIServer: @unchecked Sendable {
self.requestObserver = requestObserver
}

public func start(port: UInt16) throws {
/// Starts the local API on a specific port and host. Defaults to loopback; pass `0.0.0.0`
/// to listen on all interfaces for LAN access.
public func start(port: UInt16, host: String = "127.0.0.1") throws {
stop()
let endpointPort = NWEndpoint.Port(rawValue: port)!
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: endpointPort)
let listener = try NWListener(using: parameters)
let startResult = ListenerStartResult(port: port)
let startResult = ListenerStartResult(port: port, host: host)
listener.newConnectionHandler = { [weak self] connection in
self?.accept(connection)
}
Expand Down Expand Up @@ -150,7 +152,9 @@ public final class LocalAPIServer: @unchecked Sendable {
}

@discardableResult
public func start(preferredPort: UInt16, fallbackLimit: Int) throws -> 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..<maxAttempts {
Expand All @@ -159,7 +163,7 @@ public final class LocalAPIServer: @unchecked Sendable {
break
}
do {
try start(port: candidate)
try start(port: candidate, host: host)
return candidate
} catch {
lastError = error
Expand Down Expand Up @@ -1078,7 +1082,7 @@ public final class LocalAPIServer: @unchecked Sendable {
"status": status,
"service": CursorAPIBrand.displayName,
"baseUrl": settings.baseURL.absoluteString,
"host": "127.0.0.1",
"host": settings.bindHost,
"routingConfigured": sdkBridgeConfigured,
"sdkConfigured": sdkBridgeConfigured,
"apiKeyConfigured": apiKeyConfigured,
Expand Down Expand Up @@ -1248,26 +1252,28 @@ private extension String {

private final class ListenerStartResult: @unchecked Sendable {
private let port: UInt16
private let host: String
private let semaphore = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var result: Result<Void, any Error>?

init(port: UInt16) {
init(port: UInt16, host: String) {
self.port = port
self.host = host
}

func succeed() {
complete(.success(()))
}

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 {
Expand Down
8 changes: 8 additions & 0 deletions macos/CursorAPI/Sources/CursorAPICore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = "",
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) ?? ""
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions scripts/build-app.sh
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions scripts/run-dev.sh
Original file line number Diff line number Diff line change
@@ -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