diff --git a/CHANGELOG.md b/CHANGELOG.md index e088c57..c0a30df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - 采用 Apache License 2.0,并移除仓库中的个人签名 Team ID、Bundle ID 和示例路径。 - 为 Xcode 16.x 增加 `ConcentricRectangle` 的编译期回退,同时在新 SDK 上保留系统同心圆角效果。 +- 将生产客户端从旧 `/ws/app` WebSocket 迁移到 Runtime v1 HTTP、Session SSE 与 Run SSE,并按持久 Cursor 从快照恢复。 +- 新增原生一次性配对、内存 Access Token、Keychain Refresh Rotation、退出撤销和明确配对状态。 +- Session/Run、Bootstrap 历史同步和跨客户端 Run 发现使用与 Mobile Web 相同的版本化 Runtime 合同。 +- 执行权限档位通过 Runtime v1 查询并随 Run 持久提交,迁移后继续支持只读、工作区与完整权限。 +- 真机脚本改用 `devrun crweb` 和 Runtime Gateway,不再依赖旧 WebSocket `turn.acknowledged` 延迟刷新。 ## 0.0.1 - 2026-08-13 diff --git a/CodexRemote.xcodeproj/project.pbxproj b/CodexRemote.xcodeproj/project.pbxproj index 866eba7..ab810fc 100644 --- a/CodexRemote.xcodeproj/project.pbxproj +++ b/CodexRemote.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ A1000000000000000000000A /* PromptComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000A /* PromptComposer.swift */; }; A1000000000000000000000B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000B /* SettingsView.swift */; }; A1000000000000000000000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000C /* Assets.xcassets */; }; - A1000000000000000000000D /* RelayClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000D /* RelayClient.swift */; }; A1000000000000000000000E /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000E /* Localization.swift */; }; A1000000000000000000000F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = V10000000000000000000001 /* Localizable.strings */; }; A10000000000000000000010 /* VoiceTranscriptionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000011 /* VoiceTranscriptionController.swift */; }; @@ -35,15 +34,21 @@ A1000000000000000000001A /* WorkspaceComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001C /* WorkspaceComponents.swift */; }; A1000000000000000000001B /* ComposerDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001D /* ComposerDock.swift */; }; A1000000000000000000001C /* TurnSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001E /* TurnSessionStore.swift */; }; - A1000000000000000000001D /* RelayWireTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001F /* RelayWireTypes.swift */; }; A1000000000000000000001E /* WorkspaceSupportViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000020 /* WorkspaceSupportViews.swift */; }; A1000000000000000000001F /* ProjectDrawerComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000021 /* ProjectDrawerComponents.swift */; }; A10000000000000000000020 /* MemoryDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000022 /* MemoryDiagnostics.swift */; }; A10000000000000000000021 /* Diagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000023 /* Diagnostics.swift */; }; A10000000000000000000022 /* DiagnosticFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000024 /* DiagnosticFileStore.swift */; }; A10000000000000000000023 /* MetricKitDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000025 /* MetricKitDiagnostics.swift */; }; + A10000000000000000000024 /* RuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000026 /* RuntimeModels.swift */; }; + A10000000000000000000025 /* SSEDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000027 /* SSEDecoder.swift */; }; + A10000000000000000000026 /* KeychainCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000028 /* KeychainCredentialStore.swift */; }; + A10000000000000000000027 /* RuntimeAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000029 /* RuntimeAuthSession.swift */; }; + A10000000000000000000028 /* RuntimeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000002A /* RuntimeClient.swift */; }; + A10000000000000000000029 /* RuntimeRelayService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000002B /* RuntimeRelayService.swift */; }; A20000000000000000000001 /* WorkspaceSkeletonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* WorkspaceSkeletonUITests.swift */; }; A30000000000000000000001 /* TurnSessionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000001 /* TurnSessionStoreTests.swift */; }; + A30000000000000000000002 /* RuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* RuntimeClientTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -77,7 +82,6 @@ B1000000000000000000000A /* PromptComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptComposer.swift; sourceTree = ""; }; B1000000000000000000000B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B1000000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B1000000000000000000000D /* RelayClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayClient.swift; sourceTree = ""; }; B1000000000000000000000E /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; B1000000000000000000000F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; B10000000000000000000010 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -95,17 +99,23 @@ B1000000000000000000001C /* WorkspaceComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceComponents.swift; sourceTree = ""; }; B1000000000000000000001D /* ComposerDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDock.swift; sourceTree = ""; }; B1000000000000000000001E /* TurnSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnSessionStore.swift; sourceTree = ""; }; - B1000000000000000000001F /* RelayWireTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWireTypes.swift; sourceTree = ""; }; B10000000000000000000020 /* WorkspaceSupportViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSupportViews.swift; sourceTree = ""; }; B10000000000000000000021 /* ProjectDrawerComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDrawerComponents.swift; sourceTree = ""; }; B10000000000000000000022 /* MemoryDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDiagnostics.swift; sourceTree = ""; }; B10000000000000000000023 /* Diagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Diagnostics.swift; sourceTree = ""; }; B10000000000000000000024 /* DiagnosticFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticFileStore.swift; sourceTree = ""; }; B10000000000000000000025 /* MetricKitDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricKitDiagnostics.swift; sourceTree = ""; }; + B10000000000000000000026 /* RuntimeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeModels.swift; sourceTree = ""; }; + B10000000000000000000027 /* SSEDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEDecoder.swift; sourceTree = ""; }; + B10000000000000000000028 /* KeychainCredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainCredentialStore.swift; sourceTree = ""; }; + B10000000000000000000029 /* RuntimeAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeAuthSession.swift; sourceTree = ""; }; + B1000000000000000000002A /* RuntimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeClient.swift; sourceTree = ""; }; + B1000000000000000000002B /* RuntimeRelayService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeRelayService.swift; sourceTree = ""; }; B20000000000000000000000 /* CodexRemoteUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodexRemoteUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B20000000000000000000001 /* WorkspaceSkeletonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSkeletonUITests.swift; sourceTree = ""; }; B30000000000000000000000 /* CodexRemoteTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodexRemoteTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B30000000000000000000001 /* TurnSessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnSessionStoreTests.swift; sourceTree = ""; }; + B30000000000000000000002 /* RuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeClientTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -195,6 +205,7 @@ children = ( B10000000000000000000003 /* RelayModels.swift */, B10000000000000000000015 /* ThreadTranscript.swift */, + B10000000000000000000026 /* RuntimeModels.swift */, ); path = Models; sourceTree = ""; @@ -202,14 +213,25 @@ D10000000000000000000006 /* Services */ = { isa = PBXGroup; children = ( + D1000000000000000000000A /* Runtime */, B10000000000000000000004 /* RelayService.swift */, - B1000000000000000000000D /* RelayClient.swift */, - B1000000000000000000001F /* RelayWireTypes.swift */, B10000000000000000000005 /* MockRelayService.swift */, ); path = Services; sourceTree = ""; }; + D1000000000000000000000A /* Runtime */ = { + isa = PBXGroup; + children = ( + B10000000000000000000027 /* SSEDecoder.swift */, + B10000000000000000000028 /* KeychainCredentialStore.swift */, + B10000000000000000000029 /* RuntimeAuthSession.swift */, + B1000000000000000000002A /* RuntimeClient.swift */, + B1000000000000000000002B /* RuntimeRelayService.swift */, + ); + path = Runtime; + sourceTree = ""; + }; D10000000000000000000007 /* Features */ = { isa = PBXGroup; children = ( @@ -263,6 +285,7 @@ isa = PBXGroup; children = ( B30000000000000000000001 /* TurnSessionStoreTests.swift */, + B30000000000000000000002 /* RuntimeClientTests.swift */, ); path = CodexRemoteTests; sourceTree = ""; @@ -409,10 +432,14 @@ A10000000000000000000020 /* MemoryDiagnostics.swift in Sources */, A10000000000000000000003 /* RelayModels.swift in Sources */, A10000000000000000000013 /* ThreadTranscript.swift in Sources */, + A10000000000000000000024 /* RuntimeModels.swift in Sources */, A10000000000000000000004 /* RelayService.swift in Sources */, + A10000000000000000000025 /* SSEDecoder.swift in Sources */, + A10000000000000000000026 /* KeychainCredentialStore.swift in Sources */, + A10000000000000000000027 /* RuntimeAuthSession.swift in Sources */, + A10000000000000000000028 /* RuntimeClient.swift in Sources */, + A10000000000000000000029 /* RuntimeRelayService.swift in Sources */, A10000000000000000000005 /* MockRelayService.swift in Sources */, - A1000000000000000000000D /* RelayClient.swift in Sources */, - A1000000000000000000001D /* RelayWireTypes.swift in Sources */, A10000000000000000000006 /* WorkspaceViewModel.swift in Sources */, A1000000000000000000001C /* TurnSessionStore.swift in Sources */, A10000000000000000000007 /* WorkspaceView.swift in Sources */, @@ -447,6 +474,7 @@ buildActionMask = 2147483647; files = ( A30000000000000000000001 /* TurnSessionStoreTests.swift in Sources */, + A30000000000000000000002 /* RuntimeClientTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CodexRemote/App/CodexRemoteApp.swift b/CodexRemote/App/CodexRemoteApp.swift index e17cb88..5257d6c 100644 --- a/CodexRemote/App/CodexRemoteApp.swift +++ b/CodexRemote/App/CodexRemoteApp.swift @@ -78,8 +78,8 @@ private enum AppBootstrap { let defaults = UserDefaults.standard if arguments.contains("--reset-connection-settings") { [ - "relayURL.simulator", - "relayURL.iPhone", + "runtimeURL.simulator", + "runtimeURL.iPhone", "relayConnectionProfile", "relayConnectionEnabled" ].forEach { defaults.removeObject(forKey: $0) } @@ -89,25 +89,24 @@ private enum AppBootstrap { .filter { $0.hasPrefix("executionPermissionProfile.") } .forEach { defaults.removeObject(forKey: $0) } } - applyRelayURLLaunchArgument(arguments, defaults: defaults) + applyRuntimeURLLaunchArgument(arguments, defaults: defaults) let service: RelayServiceProtocol = arguments.contains { $0.hasPrefix("--demo-") } ? MockRelayService() - : RelayClient() + : RuntimeRelayService() return WorkspaceViewModel(service: service) } - private static func applyRelayURLLaunchArgument(_ arguments: [String], defaults: UserDefaults) { - guard let optionIndex = arguments.firstIndex(of: "--relay-url"), + private static func applyRuntimeURLLaunchArgument(_ arguments: [String], defaults: UserDefaults) { + guard let optionIndex = arguments.firstIndex(of: "--runtime-url"), arguments.indices.contains(optionIndex + 1) else { return } let rawValue = arguments[optionIndex + 1] guard let url = URL(string: rawValue), let scheme = url.scheme?.lowercased(), - ["ws", "wss"].contains(scheme), + ["http", "https"].contains(scheme), url.host != nil, url.user == nil, url.password == nil, - url.path == "/ws/app", url.query == nil, url.fragment == nil else { return } diff --git a/CodexRemote/Features/Settings/SettingsView.swift b/CodexRemote/Features/Settings/SettingsView.swift index 28f9392..b72f430 100644 --- a/CodexRemote/Features/Settings/SettingsView.swift +++ b/CodexRemote/Features/Settings/SettingsView.swift @@ -15,6 +15,26 @@ struct SettingsView: View { .pickerStyle(.menu) } + Section { + LabeledContent("Device access", value: authenticationLabel) + .accessibilityIdentifier("auth.status") + if viewModel.authenticationState.isAuthenticated { + Button("Sign out", role: .destructive, action: viewModel.signOut) + .accessibilityIdentifier("auth.signOut") + } else { + TextField("Pairing link or code", text: $viewModel.pairingInput, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + Button("Pair device", action: viewModel.pairDevice) + .disabled(viewModel.pairingInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } header: { + Text("Authentication") + } footer: { + Text("Refresh credentials are stored in this iPhone's Keychain and are never written to diagnostics.") + } + Section { LabeledContent { HStack(spacing: 6) { @@ -44,14 +64,14 @@ struct SettingsView: View { .disabled(viewModel.connectionTestState == .testing) .accessibilityIdentifier("connection.profile") - TextField("WebSocket URL", text: $viewModel.relayURL) + TextField("Runtime URL", text: $viewModel.runtimeURL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .font(.system(.body, design: .monospaced)) .keyboardType(.URL) .accessibilityIdentifier("connection.url") .disabled(viewModel.connectionTestState == .testing) - .onChange(of: viewModel.relayURL) { _, _ in + .onChange(of: viewModel.runtimeURL) { _, _ in viewModel.connectionSettingsDidChange() } @@ -89,7 +109,7 @@ struct SettingsView: View { } Section("About") { - LabeledContent("Protocol", value: "2.0") + LabeledContent("Protocol", value: "Runtime v1") LabeledContent("Version", value: appVersion) } } @@ -103,6 +123,16 @@ struct SettingsView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" } + private var authenticationLabel: String { + switch viewModel.authenticationState { + case .checking: "Checking" + case .pairing: "Pairing" + case .authenticated(let clientName): clientName + case .unauthenticated: "Not paired" + case .unavailable: "Unavailable" + } + } + @ViewBuilder private var connectionFeedback: some View { switch viewModel.connectionTestState { @@ -112,7 +142,7 @@ struct SettingsView: View { HStack(spacing: 8) { ProgressView() .controlSize(.small) - Text("Checking Relay status…") + Text("Checking Runtime status…") } .font(.footnote) .foregroundStyle(AppTheme.textSecondary) @@ -169,9 +199,9 @@ struct SettingsView: View { private var connectionHelp: LocalizedStringKey { switch viewModel.connectionProfile { case .simulator: - "Simulator uses the Mac loopback address and port 18767." + "Simulator uses the source Runtime Gateway on Mac loopback port 18874." case .iPhone: - "Physical iPhone uses port 18768. Replace 127.0.0.1 with this Mac's LAN IP or .local hostname." + "Physical iPhone uses the Runtime Gateway. Replace 127.0.0.1 with this Mac's LAN IP or .local hostname." } } } diff --git a/CodexRemote/Features/Workspace/ProjectDrawerComponents.swift b/CodexRemote/Features/Workspace/ProjectDrawerComponents.swift index eaeb660..f6759a4 100644 --- a/CodexRemote/Features/Workspace/ProjectDrawerComponents.swift +++ b/CodexRemote/Features/Workspace/ProjectDrawerComponents.swift @@ -161,10 +161,12 @@ struct DrawerProjectRow: View { .lineLimit(1) if isRunning { Circle().fill(AppTheme.amber).frame(width: 6, height: 6) } } - Text(verbatim: compactPath(project.path)) - .font(.system(size: 10)) - .foregroundStyle(AppTheme.textSecondary) - .lineLimit(1) + if !project.path.isEmpty { + Text(verbatim: compactPath(project.path)) + .font(.system(size: 10)) + .foregroundStyle(AppTheme.textSecondary) + .lineLimit(1) + } } Spacer() Text(verbatim: "\(project.threadCount)") diff --git a/CodexRemote/Features/Workspace/ProjectDrawerView.swift b/CodexRemote/Features/Workspace/ProjectDrawerView.swift index 4c3d30a..9526322 100644 --- a/CodexRemote/Features/Workspace/ProjectDrawerView.swift +++ b/CodexRemote/Features/Workspace/ProjectDrawerView.swift @@ -288,10 +288,12 @@ struct ProjectDetailView: View { Text(verbatim: project.name) .font(.system(size: 24, weight: .bold)) .foregroundStyle(AppTheme.textPrimary) - Text(verbatim: compactPath(project.path)) - .font(.system(size: 13)) - .foregroundStyle(AppTheme.textSecondary) - .lineLimit(2) + if !project.path.isEmpty { + Text(verbatim: compactPath(project.path)) + .font(.system(size: 13)) + .foregroundStyle(AppTheme.textSecondary) + .lineLimit(2) + } } Spacer() } diff --git a/CodexRemote/Features/Workspace/WorkspaceSupportViews.swift b/CodexRemote/Features/Workspace/WorkspaceSupportViews.swift index 98b2ead..471f1e9 100644 --- a/CodexRemote/Features/Workspace/WorkspaceSupportViews.swift +++ b/CodexRemote/Features/Workspace/WorkspaceSupportViews.swift @@ -1,5 +1,76 @@ import SwiftUI +struct RuntimePairingView: View { + @ObservedObject var viewModel: WorkspaceViewModel + + var body: some View { + VStack(spacing: 22) { + Spacer(minLength: 24) + Image(systemName: "iphone.and.arrow.forward.inward") + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(AppTheme.textSecondary) + .accessibilityHidden(true) + + VStack(spacing: 8) { + Text("Pair this iPhone") + .font(.title2.weight(.semibold)) + Text(pairingMessage) + .font(.subheadline) + .foregroundStyle(AppTheme.textSecondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + TextField("Pairing link or code", text: $viewModel.pairingInput, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + .lineLimit(1...3) + .padding(13) + .background(Color(uiColor: .secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .accessibilityIdentifier("auth.pairingInput") + + Button(action: viewModel.pairDevice) { + Group { + if case .pairing = viewModel.authenticationState { + ProgressView().tint(.white) + } else { + Label("Pair device", systemImage: "link") + } + } + .frame(maxWidth: .infinity) + .frame(height: 46) + } + .buttonStyle(.borderedProminent) + .tint(AppTheme.textPrimary) + .disabled(viewModel.pairingInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPairing) + .accessibilityIdentifier("auth.pair") + } + .frame(maxWidth: 460) + + Spacer() + } + .padding(.horizontal, 28) + .background(AppTheme.background) + } + + private var isPairing: Bool { + if case .pairing = viewModel.authenticationState { return true } + return false + } + + private var pairingMessage: LocalizedStringKey { + switch viewModel.authenticationState { + case .checking: "Checking this device's access…" + case .pairing: "Exchanging the one-time pairing code…" + case .unauthenticated(let message): LocalizedStringKey(message ?? "Paste the one-time link printed by codex-remote pair.") + case .unavailable(let message): LocalizedStringKey(message) + case .authenticated: "This iPhone is paired." + } + } +} + struct NewSessionProjectPicker: View { @Environment(\.dismiss) private var dismiss @@ -33,11 +104,13 @@ struct NewSessionProjectPicker: View { .font(.body.weight(.medium)) .foregroundStyle(AppTheme.textPrimary) - Text(verbatim: project.path) - .font(.caption) - .foregroundStyle(AppTheme.textSecondary) - .lineLimit(1) - .truncationMode(.middle) + if !project.path.isEmpty { + Text(verbatim: project.path) + .font(.caption) + .foregroundStyle(AppTheme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } } Spacer(minLength: 8) diff --git a/CodexRemote/Features/Workspace/WorkspaceView.swift b/CodexRemote/Features/Workspace/WorkspaceView.swift index 9e4e948..8e2e981 100644 --- a/CodexRemote/Features/Workspace/WorkspaceView.swift +++ b/CodexRemote/Features/Workspace/WorkspaceView.swift @@ -160,6 +160,12 @@ struct WorkspaceView: View { .task { await runStartupOverlayTiming() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in + viewModel.suspendRuntime() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + viewModel.resumeRuntime() + } .sheet(isPresented: $isNewSessionPickerPresented) { NewSessionProjectPicker(projects: viewModel.projects) { project in isNewSessionPickerPresented = false @@ -214,6 +220,9 @@ struct WorkspaceView: View { @ViewBuilder private var screenContent: some View { + if !viewModel.authenticationState.isAuthenticated, screen != .settings { + RuntimePairingView(viewModel: viewModel) + } else { switch screen { case .project(let projectID): if let project = resolvedProject(projectID) { @@ -249,6 +258,7 @@ struct WorkspaceView: View { case .settings: SettingsView(viewModel: viewModel) } + } } @ViewBuilder @@ -279,10 +289,12 @@ struct WorkspaceView: View { Button(action: viewModel.refreshProjects) { Label("Refresh", systemImage: "arrow.clockwise") } - Button { - UIPasteboard.general.string = project.path - } label: { - Label("Copy path", systemImage: "doc.on.doc") + if !project.path.isEmpty { + Button { + UIPasteboard.general.string = project.path + } label: { + Label("Copy path", systemImage: "doc.on.doc") + } } } label: { Image(systemName: "ellipsis") diff --git a/CodexRemote/Features/Workspace/WorkspaceViewModel.swift b/CodexRemote/Features/Workspace/WorkspaceViewModel.swift index 60eddb1..7fa2275 100644 --- a/CodexRemote/Features/Workspace/WorkspaceViewModel.swift +++ b/CodexRemote/Features/Workspace/WorkspaceViewModel.swift @@ -34,6 +34,8 @@ final class WorkspaceViewModel: ObservableObject { private static let transcriptCacheLimit = 2 @Published var connectionState: ConnectionState = .connecting + @Published private(set) var authenticationState: RuntimeAuthenticationState = .authenticated(clientName: "Local test") + @Published var pairingInput = "" @Published var agentName = "Mac Agent" @Published var agentState: AgentState = .offline @Published private(set) var executionCapabilities: ExecutionCapabilities? @@ -50,7 +52,7 @@ final class WorkspaceViewModel: ObservableObject { @Published var selectedProjectID: String? @Published var selectedThreadID: String? @Published var connectionProfile: RelayConnectionProfile - @Published var relayURL: String + @Published var runtimeURL: String @Published var connectionEnabled: Bool { didSet { guard oldValue != connectionEnabled else { return } @@ -76,7 +78,7 @@ final class WorkspaceViewModel: ObservableObject { let rawSavedProfile = defaults.string(forKey: "relayConnectionProfile") let savedProfile = rawSavedProfile.flatMap(RelayConnectionProfile.init(rawValue:)) ?? RelayConnectionProfile.current connectionProfile = savedProfile - relayURL = defaults.string(forKey: savedProfile.urlDefaultsKey) ?? savedProfile.defaultURL + runtimeURL = defaults.string(forKey: savedProfile.urlDefaultsKey) ?? savedProfile.defaultURL connectionEnabled = defaults.object(forKey: "relayConnectionEnabled") as? Bool ?? true self.service = service if let bootstrapState = service.bootstrapState { @@ -184,6 +186,28 @@ final class WorkspaceViewModel: ObservableObject { Task { await service.interruptTurn() } } + func pairDevice() { + let input = pairingInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty else { return } + Task { + await service.pair(codeOrLink: input) + if authenticationState.isAuthenticated { pairingInput = "" } + } + } + + func signOut() { + Task { await service.signOut() } + } + + func suspendRuntime() { + Task { await service.suspend() } + } + + func resumeRuntime() { + guard connectionEnabled else { return } + Task { await service.resume() } + } + func selectProject(_ projectID: String) { if projectID != selectedProjectID { selectedProjectID = projectID @@ -214,10 +238,10 @@ final class WorkspaceViewModel: ObservableObject { func selectConnectionProfile(_ profile: RelayConnectionProfile) { guard profile != connectionProfile else { return } let defaults = UserDefaults.standard - defaults.set(relayURL, forKey: connectionProfile.urlDefaultsKey) + defaults.set(runtimeURL, forKey: connectionProfile.urlDefaultsKey) connectionProfile = profile defaults.set(profile.rawValue, forKey: "relayConnectionProfile") - relayURL = defaults.string(forKey: profile.urlDefaultsKey) ?? profile.defaultURL + runtimeURL = defaults.string(forKey: profile.urlDefaultsKey) ?? profile.defaultURL disableConnection() } @@ -248,7 +272,7 @@ final class WorkspaceViewModel: ObservableObject { connectionTestTask?.cancel() clearConnectionData() connectionTestState = .testing - let url = relayURL + let url = runtimeURL connectionTestTask = Task { @MainActor [weak self] in guard let self else { return } do { @@ -325,6 +349,7 @@ final class WorkspaceViewModel: ObservableObject { pendingThreadReadKey = nil loadedThreadReadKey = nil resetTurn() + service.selectContext(projectID: projectID, threadID: nil) } func prepareThread(projectID: String, threadID: String) { @@ -332,6 +357,7 @@ final class WorkspaceViewModel: ObservableObject { let key = "\(projectID):\(threadID)" selectedThreadID = threadID resetTurn() + service.selectContext(projectID: projectID, threadID: threadID) if threadTranscriptsByKey[key] != nil { touchTranscript(key) loadedThreadReadKey = key @@ -344,7 +370,7 @@ final class WorkspaceViewModel: ObservableObject { private func saveConnectionSettings() { let defaults = UserDefaults.standard defaults.set(connectionProfile.rawValue, forKey: "relayConnectionProfile") - defaults.set(relayURL, forKey: connectionProfile.urlDefaultsKey) + defaults.set(runtimeURL, forKey: connectionProfile.urlDefaultsKey) } private func clearConnectionData() { @@ -400,6 +426,10 @@ final class WorkspaceViewModel: ObservableObject { private func apply(_ event: RelayEvent) { switch event { + case .runtimeOrigin(let value): + runtimeURL = value + case .authentication(let state): + authenticationState = state case .connection(let state): connectionState = state switch state { diff --git a/CodexRemote/Models/RelayModels.swift b/CodexRemote/Models/RelayModels.swift index 0fb2d2e..0d57021 100644 --- a/CodexRemote/Models/RelayModels.swift +++ b/CodexRemote/Models/RelayModels.swift @@ -244,6 +244,8 @@ struct ExecutionProfileSnapshot: Codable, Sendable { } enum RelayEvent: Sendable { + case runtimeOrigin(String) + case authentication(RuntimeAuthenticationState) case connection(ConnectionState) case agent(name: String, state: AgentState) case capabilities(ExecutionCapabilities?) @@ -264,85 +266,3 @@ enum RelayEvent: Sendable { case turnEnded(traceID: String?, turnID: String?, phase: TurnPhase, result: TurnResult?) case reset } - -struct RelayEnvelope: Codable, Sendable { - let specVersion: String - let messageID: String - let type: String - let occurredAt: Date - let traceID: String - let sender: RelaySender - let payload: Payload - - enum CodingKeys: String, CodingKey { - case type, sender, payload - case specVersion = "spec_version" - case messageID = "message_id" - case occurredAt = "occurred_at" - case traceID = "trace_id" - } -} - -struct RelayEnvelopeHeader: Decodable { - let specVersion: String - let type: String - let traceID: String - - enum CodingKeys: String, CodingKey { - case type - case specVersion = "spec_version" - case traceID = "trace_id" - } -} - -struct RelaySender: Codable, Sendable { - let kind: String - let id: String -} - -struct EmptyPayload: Codable, Sendable {} -struct ThreadListPayload: Codable, Sendable { - let projectID: String - enum CodingKeys: String, CodingKey { case projectID = "project_id" } -} -struct ExecutionProfileListPayload: Codable, Sendable { - let projectID: String - enum CodingKeys: String, CodingKey { case projectID = "project_id" } -} -struct ThreadReadPayload: Codable, Sendable { - let projectID: String - let threadID: String - enum CodingKeys: String, CodingKey { - case projectID = "project_id" - case threadID = "thread_id" - } -} -struct TurnStartPayload: Codable, Sendable { - let projectID: String - let threadID: String? - let prompt: String - let permissionProfileID: String? - enum CodingKeys: String, CodingKey { - case prompt - case projectID = "project_id" - case threadID = "thread_id" - case permissionProfileID = "permission_profile_id" - } -} -struct TurnInterruptPayload: Codable, Sendable { - let threadID: String - let turnID: String - enum CodingKeys: String, CodingKey { - case threadID = "thread_id" - case turnID = "turn_id" - } -} - -struct TurnAcknowledgedPayload: Codable, Sendable { - let turnID: String - let status: String - enum CodingKeys: String, CodingKey { - case status - case turnID = "turn_id" - } -} diff --git a/CodexRemote/Models/RuntimeModels.swift b/CodexRemote/Models/RuntimeModels.swift new file mode 100644 index 0000000..9dc134d --- /dev/null +++ b/CodexRemote/Models/RuntimeModels.swift @@ -0,0 +1,375 @@ +import Foundation + +enum JSONValue: Codable, Equatable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Bool.self) { self = .bool(value) } + else if let value = try? container.decode(Double.self) { self = .number(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode([String: JSONValue].self) { self = .object(value) } + else { self = .array(try container.decode([JSONValue].self)) } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } + + var stringValue: String? { + switch self { + case .string(let value): value + case .number(let value): String(value) + case .bool(let value): String(value) + default: nil + } + } + + var int64Value: Int64? { + switch self { + case .number(let value): Int64(value) + case .string(let value): Int64(value) + default: nil + } + } + + var objectValue: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } +} + +struct RuntimeEnvelope: Decodable, Sendable { + let success: Bool + let data: Value +} + +struct RuntimeErrorEnvelope: Decodable, Sendable { + struct Failure: Decodable, Sendable { + let code: String + let message: String + let retryable: Bool? + } + + let success: Bool + let error: Failure +} + +struct RuntimeProject: Codable, Sendable { + let projectID: String + let displayName: String + + enum CodingKeys: String, CodingKey { + case projectID = "project_id" + case displayName = "display_name" + } +} + +struct RuntimeProjectList: Decodable, Sendable { + let items: [RuntimeProject] + let agentPresence: String + + enum CodingKeys: String, CodingKey { + case items + case agentPresence = "agent_presence" + } +} + +struct RuntimeSession: Codable, Sendable { + let sessionID: String + let projectID: String + let codexThreadID: String? + let title: String + let lastSessionSequence: Int64 + let latestRunStatus: String? + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case title + case sessionID = "session_id" + case projectID = "project_id" + case codexThreadID = "codex_thread_id" + case lastSessionSequence = "last_session_sequence" + case latestRunStatus = "latest_run_status" + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +struct RuntimeSessionList: Decodable, Sendable { + let items: [RuntimeSession] +} + +struct RuntimeRunEvent: Codable, Sendable { + let agentSequence: Int64 + let type: String + let payload: [String: JSONValue] + let occurredAt: Date + + enum CodingKeys: String, CodingKey { + case type, payload + case agentSequence = "agent_sequence" + case occurredAt = "occurred_at" + } +} + +struct RuntimeRun: Codable, Sendable { + let runID: String + let sessionID: String + let status: String + let stateVersion: Int64 + let prompt: String? + let permissionProfileID: String? + let persistedThroughSequence: Int64 + let finalSequence: Int64? + let errorMessage: String? + let createdAt: Date + let startedAt: Date? + let finishedAt: Date? + let updatedAt: Date + let events: [RuntimeRunEvent]? + + enum CodingKeys: String, CodingKey { + case status, prompt, events + case runID = "run_id" + case sessionID = "session_id" + case stateVersion = "state_version" + case permissionProfileID = "permission_profile_id" + case persistedThroughSequence = "persisted_through_sequence" + case finalSequence = "final_sequence" + case errorMessage = "error_message" + case createdAt = "created_at" + case startedAt = "started_at" + case finishedAt = "finished_at" + case updatedAt = "updated_at" + } +} + +struct RuntimeSessionSnapshot: Decodable, Sendable { + let session: RuntimeSession + let runs: [RuntimeRun] + let snapshotSessionSequence: Int64 + let agentPresence: String + + enum CodingKeys: String, CodingKey { + case session, runs + case snapshotSessionSequence = "snapshot_session_sequence" + case agentPresence = "agent_presence" + } +} + +struct RuntimeCreatedRun: Decodable, Sendable { + let runID: String + let sessionID: String + let status: String + let sessionSequence: Int64 + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case status + case runID = "run_id" + case sessionID = "session_id" + case sessionSequence = "session_sequence" + case createdAt = "created_at" + } +} + +struct RuntimeSyncJob: Decodable, Sendable { + let syncID: String + let status: String + let processedSessions: Int64 + let totalSessions: Int64 + let errorMessage: String? + + enum CodingKeys: String, CodingKey { + case status + case syncID = "sync_id" + case processedSessions = "processed_sessions" + case totalSessions = "total_sessions" + case errorMessage = "error_message" + } +} + +struct RuntimeStreamEvent: Sendable { + let id: Int64 + let type: String + let fields: [String: JSONValue] + + var runID: String? { fields["run_id"]?.stringValue ?? fields["runId"]?.stringValue } +} + +enum RuntimeAuthenticationState: Equatable, Sendable { + case checking + case pairing + case authenticated(clientName: String) + case unauthenticated(message: String?) + case unavailable(message: String) + + var isAuthenticated: Bool { + if case .authenticated = self { return true } + return false + } +} + +struct RuntimeAuthClient: Codable, Sendable { + let clientID: String + let name: String + let scopes: [String] + + enum CodingKeys: String, CodingKey { + case name, scopes + case clientID = "client_id" + } +} + +struct RuntimeNativeTokenPair: Decodable, Sendable { + let accessToken: String + let accessExpiresAt: Date + let refreshToken: String + let refreshExpiresAt: Date + let client: RuntimeAuthClient + + enum CodingKeys: String, CodingKey { + case client + case accessToken = "access_token" + case accessExpiresAt = "access_expires_at" + case refreshToken = "refresh_token" + case refreshExpiresAt = "refresh_expires_at" + } +} + +extension RuntimeSession { + func threadSummary() -> ThreadSummary { + ThreadSummary( + id: sessionID, + projectID: projectID, + title: title, + preview: "", + status: normalizedRuntimeStatus(latestRunStatus), + source: "runtime", + updatedAt: updatedAt + ) + } +} + +extension RuntimeSessionSnapshot { + func threadDetail(excludingRunID: String? = nil) -> ThreadDetail { + ThreadDetail( + id: session.sessionID, + projectID: session.projectID, + title: session.title, + preview: "", + status: normalizedRuntimeStatus(session.latestRunStatus), + source: "runtime", + createdAt: session.createdAt, + updatedAt: session.updatedAt, + turns: runs.filter { $0.runID != excludingRunID }.map { $0.threadHistoryTurn() } + ) + } +} + +extension RuntimeRun { + func threadHistoryTurn() -> ThreadHistoryTurn { + var items: [ThreadHistoryItem] = [] + if let prompt, !prompt.isEmpty { + items.append(runtimeItem(id: "user-\(runID)", type: "userMessage", role: "user", text: prompt)) + } + var assistantID = "assistant-\(runID)" + var assistantText = "" + for event in events ?? [] { + switch event.type { + case "assistant.delta": + assistantText += event.payload["text"]?.stringValue ?? event.payload["delta"]?.stringValue ?? "" + case "item.started", "item.completed": + guard let value = event.payload["item"] else { continue } + if let item = decodeHistoryItem(value) { + if item.type == "agentMessage", item.phase == nil || item.phase == "final_answer" { + assistantID = item.id + if event.type == "item.completed", let text = item.text { assistantText = text } + } else { + upsert(item, in: &items) + } + } + case "item.delta": + let itemID = event.payload["item_id"]?.stringValue ?? "" + if itemID == assistantID, event.payload["field"]?.stringValue == "text" { + assistantText += event.payload["delta"]?.stringValue ?? "" + } + default: + break + } + } + if !assistantText.isEmpty || status == "failed" { + items.append(runtimeItem( + id: assistantID, + type: "agentMessage", + role: "assistant", + phase: "final_answer", + text: assistantText.isEmpty ? errorMessage : assistantText + )) + } + return ThreadHistoryTurn( + id: runID, + status: normalizedRuntimeStatus(status), + startedAt: startedAt ?? createdAt, + completedAt: finishedAt, + durationMS: durationMilliseconds, + error: errorMessage, + items: items, + truncated: false + ) + } + + private var durationMilliseconds: Int64? { + guard let end = finishedAt, let start = startedAt else { return nil } + return Int64(max(0, end.timeIntervalSince(start) * 1_000)) + } +} + +func normalizedRuntimeStatus(_ status: String?) -> String { + switch status { + case "queued", "dispatching", "accepted", "running", "waiting_agent", "recovering", "finalizing": "running" + case "canceled": "interrupted" + case "failed": "failed" + default: "completed" + } +} + +func decodeHistoryItem(_ value: JSONValue) -> ThreadHistoryItem? { + guard let data = try? JSONEncoder().encode(value) else { return nil } + return try? JSONDecoder().decode(ThreadHistoryItem.self, from: data) +} + +private func runtimeItem( + id: String, + type: String, + role: String? = nil, + phase: String? = nil, + text: String? = nil +) -> ThreadHistoryItem { + ThreadHistoryItem( + id: id, type: type, role: role, phase: phase, status: nil, text: text, + name: nil, command: nil, cwd: nil, output: nil, path: nil, query: nil, + exitCode: nil, durationMS: nil, changes: nil, truncated: false + ) +} + +private func upsert(_ item: ThreadHistoryItem, in items: inout [ThreadHistoryItem]) { + if let index = items.firstIndex(where: { $0.id == item.id }) { items[index] = item } + else { items.append(item) } +} diff --git a/CodexRemote/Services/MockRelayService.swift b/CodexRemote/Services/MockRelayService.swift index 4469473..44adbf8 100644 --- a/CodexRemote/Services/MockRelayService.swift +++ b/CodexRemote/Services/MockRelayService.swift @@ -37,6 +37,17 @@ final class MockRelayService: RelayServiceProtocol { ] var bootstrapState: RelayBootstrapState? { + if ProcessInfo.processInfo.arguments.contains("--demo-unpaired") { + return RelayBootstrapState( + connectionState: .disconnected, + agentName: "Lee's Mac Studio", + agentState: .offline, + capabilities: nil, + projects: [], + threads: [], + selectedProjectID: nil + ) + } if ProcessInfo.processInfo.arguments.contains("--demo-offline") { return RelayBootstrapState( connectionState: .disconnected, @@ -76,6 +87,12 @@ final class MockRelayService: RelayServiceProtocol { } func connect() async { + if ProcessInfo.processInfo.arguments.contains("--demo-unpaired") { + emit(.authentication(.unauthenticated(message: nil))) + emit(.connection(.disconnected)) + emit(.agent(name: "Lee's Mac Studio", state: .offline)) + return + } if ProcessInfo.processInfo.arguments.contains("--demo-offline") { emit(.connection(.disconnected)) emit(.agent(name: "Lee's Mac Studio", state: .offline)) diff --git a/CodexRemote/Services/RelayClient.swift b/CodexRemote/Services/RelayClient.swift deleted file mode 100644 index ba11127..0000000 --- a/CodexRemote/Services/RelayClient.swift +++ /dev/null @@ -1,455 +0,0 @@ -import Foundation - -@MainActor -final class RelayClient: RelayServiceProtocol { - private static let specVersion = "2.0" - private static let maximumIncomingMessageBytes = 1_048_576 - private let eventBuffer = RelayEventBuffer() - private var socket: URLSessionWebSocketTask? - private var receiveTask: Task? - private var reconnectTask: Task? - private var relayURL: String - private var activeTraceID: String? - private var activeThreadID: String? - private var activeTurnID: String? - private var projectListTraceIDs: Set = [] - private var executionProfileProjectIDByTraceID: [String: String] = [:] - private var threadListProjectIDByTraceID: [String: String] = [:] - private var threadReadTargetByTraceID: [String: (projectID: String, threadID: String)] = [:] - private var agentName = "Mac Agent" - - private let encoder = JSONEncoder.relay - private let decoder = JSONDecoder.relay - - init() { - let defaults = UserDefaults.standard - let profile = defaults.string(forKey: "relayConnectionProfile") - .flatMap(RelayConnectionProfile.init(rawValue:)) - ?? RelayConnectionProfile.current - relayURL = defaults.string(forKey: profile.urlDefaultsKey) ?? profile.defaultURL - } - - func events() -> AsyncStream { - eventBuffer.stream() - } - - func connect() async { - stopCurrentConnection(emitDisconnectedState: false) - emit(.capabilities(nil)) - emit(.connection(.connecting)) - Diagnostics.shared.record( - .relayConnectStarted, - category: .relay, - fields: [.transport: .string(URL(string: relayURL)?.scheme?.lowercased() ?? "invalid")] - ) - guard let url = URL(string: relayURL), ["ws", "wss"].contains(url.scheme?.lowercased() ?? "") else { - emit(.connection(.disconnected)) - emit(.output(LogEntry(stream: .system, text: "Relay URL is invalid."))) - Diagnostics.shared.record(.relayDisconnected, level: .error, category: .relay, fields: [.reason: .string("invalid_url")]) - return - } - let task = URLSession.shared.webSocketTask(with: url) - task.maximumMessageSize = Self.maximumIncomingMessageBytes - socket = task - task.resume() - emit(.connection(.connected)) - Diagnostics.shared.record(.relayConnected, level: .notice, category: .relay, fields: [.transport: .string(url.scheme ?? "unknown")]) - receiveTask = Task { @MainActor [weak self, weak task] in - guard let self, let task else { return } - await receiveLoop(task) - } - await requestProjects() - } - - func disconnect() async { - stopCurrentConnection(emitDisconnectedState: true) - Diagnostics.shared.record(.relayDisconnected, level: .notice, category: .relay, fields: [.reason: .string("requested")]) - } - - func testConnection(url: String) async throws -> RelayConnectionTestResult { - let normalized = url.trimmingCharacters(in: .whitespacesAndNewlines) - Diagnostics.shared.record(.relayConnectionTestStarted, category: .relay) - guard let webSocketURL = Self.validWebSocketURL(from: normalized), - let statusURL = Self.statusURL(from: webSocketURL) else { - throw RelayConnectionError.invalidURL - } - - stopCurrentConnection(emitDisconnectedState: true) - - var request = URLRequest(url: statusURL) - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = 5 - request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 5 - configuration.timeoutIntervalForResource = 5 - let session = URLSession(configuration: configuration) - defer { session.invalidateAndCancel() } - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw RelayConnectionError.invalidResponse - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw RelayConnectionError.httpStatus(httpResponse.statusCode) - } - guard let status = try? decoder.decode(RelayHTTPStatus.self, from: data) else { - throw RelayConnectionError.invalidResponse - } - - relayURL = normalized - await connect() - Diagnostics.shared.record( - .relayConnectionTestCompleted, - level: .notice, - category: .relay, - fields: [ - .statusCode: .integer(httpResponse.statusCode), - .agentConnected: .boolean(status.agentConnected) - ] - ) - return RelayConnectionTestResult(agentConnected: status.agentConnected) - } - - func requestProjects() async { - let traceID = newID() - projectListTraceIDs.insert(traceID) - await send(type: "project.list", traceID: traceID, payload: EmptyPayload()) - } - - func requestExecutionProfiles(projectID: String) async { - let traceID = newID() - executionProfileProjectIDByTraceID[traceID] = projectID - await send(type: "execution.profile.list", traceID: traceID, payload: ExecutionProfileListPayload(projectID: projectID)) - } - - func requestThreads(projectID: String) async { - let traceID = newID() - threadListProjectIDByTraceID[traceID] = projectID - await send(type: "thread.list", traceID: traceID, payload: ThreadListPayload(projectID: projectID)) - } - - func requestThread(projectID: String, threadID: String) async { - let traceID = newID() - threadReadTargetByTraceID[traceID] = (projectID, threadID) - await send(type: "thread.read", traceID: traceID, payload: ThreadReadPayload(projectID: projectID, threadID: threadID)) - } - - func startTurn(projectID: String, threadID: String?, prompt: String, permissionProfileID: String?) async { - let traceID = newID() - activeTraceID = traceID - activeThreadID = threadID - activeTurnID = nil - Diagnostics.shared.record( - .turnStarted, - level: .notice, - category: .turn, - traceID: traceID, - fields: [.promptCharacters: .integer(prompt.count)] - ) - await send(type: "turn.start", traceID: traceID, payload: TurnStartPayload(projectID: projectID, threadID: threadID, prompt: prompt, permissionProfileID: permissionProfileID)) - } - - func interruptTurn() async { - guard let traceID = activeTraceID, let threadID = activeThreadID, let turnID = activeTurnID else { - emit(.output(LogEntry(stream: .system, text: "Codex has not started the turn yet."))) - return - } - await send(type: "turn.interrupt", traceID: traceID, payload: TurnInterruptPayload(threadID: threadID, turnID: turnID)) - } - - func acknowledgeTurn(traceID: String, turnID: String, phase: TurnPhase) async { - guard phase.isTerminal else { return } - await send( - type: "turn.acknowledged", - traceID: traceID, - payload: TurnAcknowledgedPayload(turnID: turnID, status: phase.rawValue) - ) - } - - private func receiveLoop(_ task: URLSessionWebSocketTask) async { - do { - while !Task.isCancelled { - let message = try await task.receive() - let data: Data - switch message { - case .data(let value): data = value - case .string(let value): data = Data(value.utf8) - @unknown default: continue - } - handle(data) - } - } catch { - guard !Task.isCancelled else { return } - emit(.connection(.disconnected)) - emit(.agent(name: agentName, state: .offline)) - emit(.capabilities(nil)) - emit(.output(LogEntry(stream: .system, text: "Relay connection ended: \(error.localizedDescription)"))) - let nsError = error as NSError - Diagnostics.shared.record( - .relayDisconnected, - level: .error, - category: .relay, - fields: [ - .reason: .string("receive_failed"), - .errorDomain: .string(nsError.domain), - .errorCode: .integer(nsError.code) - ] - ) - scheduleReconnect() - } - } - - private func handle(_ data: Data) { - MemoryDiagnostics.shared.recordRelayMessage(byteCount: data.count) - guard let header = try? decoder.decode(RelayEnvelopeHeader.self, from: data), header.specVersion == Self.specVersion else { - emit(.output(LogEntry(stream: .system, text: "Relay sent an unsupported message."))) - Diagnostics.shared.record( - .relayDecodeFailed, - level: .error, - category: .relay, - fields: [.bytes: .integer(data.count)] - ) - return - } - if Self.lowVolumeMessageTypes.contains(header.type) { - Diagnostics.shared.record( - .relayResponseReceived, - category: .relay, - traceID: header.traceID, - turnID: activeTurnID, - fields: [ - .messageType: .string(header.type), - .bytes: .integer(data.count) - ] - ) - } - switch header.type { - case "agent.hello": - if let payload: AgentHelloPayload = payload(data) { - agentName = payload.name - emit(.agent(name: payload.name, state: AgentState(rawValue: payload.status) ?? .idle)) - } - case "agent.status": - if let payload: AgentStatusPayload = payload(data) { - emit(.agent(name: agentName, state: AgentState(rawValue: payload.status) ?? .offline)) - } - case "agent.capabilities": - if let payload: ExecutionCapabilities = payload(data) { - emit(.capabilities(payload)) - } - case "execution.profile.snapshot": - if let payload: ExecutionProfileSnapshot = payload(data) { - executionProfileProjectIDByTraceID.removeValue(forKey: header.traceID) - emit(.executionProfiles(payload)) - } - case "project.snapshot": - if let payload: ProjectSnapshotPayload = payload(data) { - projectListTraceIDs.remove(header.traceID) - emit(.projects(payload.projects)) - } - case "thread.snapshot": - if let payload: ThreadSnapshotPayload = payload(data) { - threadListProjectIDByTraceID.removeValue(forKey: header.traceID) - emit(.threads(projectID: payload.projectID, values: payload.threads)) - } - case "thread.detail": - if let payload: ThreadDetailEventPayload = payload(data) { - threadReadTargetByTraceID.removeValue(forKey: header.traceID) - emit(.threadDetail(projectID: payload.projectID, value: payload.thread, truncated: payload.truncated)) - } - case "turn.started": - if let payload: TurnStartedEventPayload = payload(data) { - activeThreadID = payload.threadID - activeTurnID = payload.turnID - emit(.turnStarted(threadID: payload.threadID, turnID: payload.turnID, startedAt: payload.startedAt)) - } - case "turn.item.started": - if let payload: TurnItemStartedEventPayload = payload(data) { - emit(.turnItemStarted(turnID: payload.turnID, sequence: payload.sequence, item: payload.item)) - } - case "turn.item.delta": - if let payload: TurnItemDeltaEventPayload = payload(data) { - emit(.turnItemDelta(turnID: payload.turnID, sequence: payload.sequence, itemID: payload.itemID, field: payload.field, delta: payload.delta)) - } - case "turn.item.completed": - if let payload: TurnItemCompletedEventPayload = payload(data) { - emit(.turnItemCompleted(turnID: payload.turnID, sequence: payload.sequence, item: payload.item)) - } - case "turn.output": - if let payload: TurnOutputEventPayload = payload(data) { - let stream = LogStream(rawValue: payload.stream) ?? .stdout - emit(.output(LogEntry(stream: stream, text: payload.text))) - } - case "turn.snapshot": - if let payload: TurnSnapshotEventPayload = payload(data) { - activeThreadID = payload.threadID - activeTurnID = payload.turnID - emit(.turnSnapshot( - threadID: payload.threadID, - turnID: payload.turnID, - startedAt: payload.startedAt, - items: payload.liveItems ?? [], - lastSequence: payload.lastSequence ?? 0, - itemsTruncated: payload.liveItemsTruncated ?? false, - recentOutput: payload.recentOutput - )) - } - case "turn.completed": - if let payload: TurnCompletedEventPayload = payload(data) { - Diagnostics.shared.record(.turnEnded, level: .notice, category: .turn, traceID: header.traceID, turnID: payload.turnID, fields: [.phase: .string("completed")]) - emit(.turnEnded(traceID: header.traceID, turnID: payload.turnID, phase: .completed, result: nil)) - clearActiveTurn() - } - case "turn.failed": - if let payload: TurnFailedEventPayload = payload(data) { - Diagnostics.shared.record(.turnEnded, level: .error, category: .turn, traceID: header.traceID, turnID: payload.turnID ?? activeTurnID, fields: [.phase: .string("failed"), .durationMS: .integer(Int(payload.durationMS ?? 0))]) - emit(.turnEnded(traceID: header.traceID, turnID: payload.turnID ?? activeTurnID, phase: .failed, result: TurnResult(title: String(localized: "Turn failed"), detail: payload.message, changedFiles: 0, duration: Double(payload.durationMS ?? 0) / 1_000))) - clearActiveTurn() - } - case "turn.interrupted": - if let payload: TurnInterruptedEventPayload = payload(data) { - Diagnostics.shared.record(.turnEnded, level: .notice, category: .turn, traceID: header.traceID, turnID: payload.turnID ?? activeTurnID, fields: [.phase: .string("interrupted"), .durationMS: .integer(Int(payload.durationMS))]) - emit(.turnEnded(traceID: header.traceID, turnID: payload.turnID ?? activeTurnID, phase: .interrupted, result: TurnResult(title: String(localized: "Turn interrupted"), detail: String(localized: "Codex stopped cleanly."), changedFiles: 0, duration: Double(payload.durationMS) / 1_000))) - clearActiveTurn() - } - case "turn.rejected": - if let payload: TurnRejectedEventPayload = payload(data) { - if let context = payload.executionContext { - emit(.capabilities(context)) - } - emit(.output(LogEntry(stream: .stderr, text: "\(payload.code): \(payload.message)"))) - let rejectionDetail = [payload.message, payload.recoveryAction].compactMap { $0 }.joined(separator: "\n\n") - if projectListTraceIDs.remove(header.traceID) != nil { - emit(.projectsFailed(message: payload.message)) - } else if let projectID = executionProfileProjectIDByTraceID.removeValue(forKey: header.traceID) { - emit(.executionProfilesFailed(projectID: projectID, message: payload.message)) - } else if let projectID = threadListProjectIDByTraceID.removeValue(forKey: header.traceID) { - emit(.threadsFailed(projectID: projectID, message: payload.message)) - } else if let target = threadReadTargetByTraceID.removeValue(forKey: header.traceID) { - emit(.threadDetailFailed(projectID: target.projectID, threadID: target.threadID, message: payload.message)) - } else if header.traceID == activeTraceID { - emit(.turnEnded(traceID: nil, turnID: nil, phase: .failed, result: TurnResult(title: String(localized: "Request rejected"), detail: rejectionDetail, changedFiles: 0, duration: 0))) - clearActiveTurn() - } - } - default: - break - } - } - - private func payload(_ data: Data) -> Payload? { - try? decoder.decode(RelayEnvelope.self, from: data).payload - } - - private func send(type: String, traceID: String, payload: Payload) async { - guard let socket else { - emit(.connection(.disconnected)) - return - } - let envelope = RelayEnvelope(specVersion: Self.specVersion, messageID: newID(), type: type, occurredAt: Date(), traceID: traceID, sender: RelaySender(kind: "user", id: "iphone"), payload: payload) - do { - let data = try encoder.encode(envelope) - guard let text = String(data: data, encoding: .utf8) else { - throw RelayClientError.invalidUTF8 - } - try await socket.send(.string(text)) - Diagnostics.shared.record( - .relayRequestSent, - category: .relay, - traceID: traceID, - turnID: activeTurnID, - fields: [ - .messageType: .string(type), - .bytes: .integer(data.count) - ] - ) - } catch { - emit(.output(LogEntry(stream: .system, text: "Relay send failed: \(error.localizedDescription)"))) - let nsError = error as NSError - Diagnostics.shared.record( - .relaySendFailed, - level: .error, - category: .relay, - traceID: traceID, - turnID: activeTurnID, - fields: [ - .messageType: .string(type), - .errorDomain: .string(nsError.domain), - .errorCode: .integer(nsError.code) - ] - ) - } - } - - private func scheduleReconnect() { - reconnectTask?.cancel() - Diagnostics.shared.record(.relayReconnectScheduled, category: .relay, fields: [.delayMS: .integer(2_000)]) - reconnectTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - await self?.connect() - } - } - - private func stopCurrentConnection(emitDisconnectedState: Bool) { - receiveTask?.cancel() - receiveTask = nil - reconnectTask?.cancel() - reconnectTask = nil - socket?.cancel(with: .goingAway, reason: nil) - socket = nil - projectListTraceIDs.removeAll() - executionProfileProjectIDByTraceID.removeAll() - threadListProjectIDByTraceID.removeAll() - threadReadTargetByTraceID.removeAll() - clearActiveTurn() - if emitDisconnectedState { - emit(.connection(.disconnected)) - emit(.agent(name: agentName, state: .offline)) - emit(.capabilities(nil)) - } - } - - private static func validWebSocketURL(from value: String) -> URL? { - guard let url = URL(string: value), - ["ws", "wss"].contains(url.scheme?.lowercased() ?? ""), - url.host != nil else { return nil } - return url - } - - private static func statusURL(from webSocketURL: URL) -> URL? { - guard var components = URLComponents(url: webSocketURL, resolvingAgainstBaseURL: false) else { return nil } - components.scheme = webSocketURL.scheme?.lowercased() == "wss" ? "https" : "http" - components.path = "/status" - components.query = nil - components.fragment = nil - return components.url - } - - private func clearActiveTurn() { - activeTraceID = nil - activeThreadID = nil - activeTurnID = nil - } - - private func emit(_ event: RelayEvent) { eventBuffer.yield(event) } - private func newID() -> String { UUID().uuidString.lowercased() } - - private static let lowVolumeMessageTypes: Set = [ - "agent.hello", - "agent.status", - "agent.capabilities", - "execution.profile.snapshot", - "project.snapshot", - "thread.snapshot", - "thread.detail", - "turn.started", - "turn.snapshot", - "turn.completed", - "turn.failed", - "turn.interrupted", - "turn.rejected" - ] -} diff --git a/CodexRemote/Services/RelayService.swift b/CodexRemote/Services/RelayService.swift index 0f7789e..7285da0 100644 --- a/CodexRemote/Services/RelayService.swift +++ b/CodexRemote/Services/RelayService.swift @@ -25,13 +25,12 @@ enum RelayConnectionProfile: String, CaseIterable, Identifiable, Sendable { var defaultPort: Int { switch self { - case .simulator: 18_767 - case .iPhone: 18_768 + case .simulator, .iPhone: 18_874 } } - var defaultURL: String { "ws://127.0.0.1:\(defaultPort)/ws/app" } - var urlDefaultsKey: String { "relayURL.\(rawValue)" } + var defaultURL: String { "http://127.0.0.1:\(defaultPort)" } + var urlDefaultsKey: String { "runtimeURL.\(rawValue)" } static var current: RelayConnectionProfile { #if targetEnvironment(simulator) @@ -67,11 +66,21 @@ protocol RelayServiceProtocol: AnyObject { func requestExecutionProfiles(projectID: String) async func requestThreads(projectID: String) async func requestThread(projectID: String, threadID: String) async + func selectContext(projectID: String, threadID: String?) func startTurn(projectID: String, threadID: String?, prompt: String, permissionProfileID: String?) async func interruptTurn() async func acknowledgeTurn(traceID: String, turnID: String, phase: TurnPhase) async + func pair(codeOrLink: String) async + func signOut() async + func suspend() async + func resume() async } extension RelayServiceProtocol { var bootstrapState: RelayBootstrapState? { nil } + func pair(codeOrLink: String) async {} + func signOut() async {} + func selectContext(projectID: String, threadID: String?) {} + func suspend() async {} + func resume() async { await connect() } } diff --git a/CodexRemote/Services/RelayWireTypes.swift b/CodexRemote/Services/RelayWireTypes.swift deleted file mode 100644 index b79c6a1..0000000 --- a/CodexRemote/Services/RelayWireTypes.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation - -enum RelayClientError: Error { - case invalidUTF8 -} - -enum RelayConnectionError: LocalizedError { - case invalidURL - case invalidResponse - case httpStatus(Int) - - var errorDescription: String? { - switch self { - case .invalidURL: - "Enter a valid ws:// or wss:// Relay URL." - case .invalidResponse: - "Relay returned an invalid status response." - case .httpStatus(let statusCode): - "Relay status check returned HTTP \(statusCode)." - } - } -} - -struct RelayHTTPStatus: Decodable { - let agentConnected: Bool - - enum CodingKeys: String, CodingKey { - case agentConnected = "agent_connected" - } -} - -struct AgentHelloPayload: Codable, Sendable { let name: String; let version: String; let status: String } -struct AgentStatusPayload: Codable, Sendable { let status: String } -struct ProjectSnapshotPayload: Codable, Sendable { let projects: [ProjectSummary] } - -struct ThreadSnapshotPayload: Codable, Sendable { - let projectID: String - let threads: [ThreadSummary] - enum CodingKeys: String, CodingKey { case threads; case projectID = "project_id" } -} - -struct ThreadDetailEventPayload: Codable, Sendable { - let projectID: String - let thread: ThreadDetail - let truncated: Bool - enum CodingKeys: String, CodingKey { case thread, truncated; case projectID = "project_id" } -} - -struct TurnStartedEventPayload: Codable, Sendable { - let threadID: String; let turnID: String; let startedAt: Date - enum CodingKeys: String, CodingKey { case threadID = "thread_id"; case turnID = "turn_id"; case startedAt = "started_at" } -} - -struct TurnOutputEventPayload: Codable, Sendable { let stream: String; let text: String } - -struct TurnItemStartedEventPayload: Codable, Sendable { - let turnID: String - let sequence: Int64 - let item: ThreadHistoryItem - enum CodingKeys: String, CodingKey { case sequence, item; case turnID = "turn_id" } -} - -struct TurnItemDeltaEventPayload: Codable, Sendable { - let turnID: String - let sequence: Int64 - let itemID: String - let field: String - let delta: String - enum CodingKeys: String, CodingKey { case sequence, field, delta; case turnID = "turn_id"; case itemID = "item_id" } -} - -struct TurnItemCompletedEventPayload: Codable, Sendable { - let turnID: String - let sequence: Int64 - let item: ThreadHistoryItem - enum CodingKeys: String, CodingKey { case sequence, item; case turnID = "turn_id" } -} - -struct TurnSnapshotEventPayload: Codable, Sendable { - let threadID: String - let turnID: String - let startedAt: Date - let recentOutput: [String] - let liveItems: [ThreadHistoryItem]? - let lastSequence: Int64? - let liveItemsTruncated: Bool? - - enum CodingKeys: String, CodingKey { - case threadID = "thread_id" - case turnID = "turn_id" - case startedAt = "started_at" - case recentOutput = "recent_output" - case liveItems = "live_items" - case lastSequence = "last_sequence" - case liveItemsTruncated = "live_items_truncated" - } -} - -struct TurnCompletedEventPayload: Codable, Sendable { - let turnID: String; let durationMS: Int64; let summary: String; let changedFiles: [String] - enum CodingKeys: String, CodingKey { case summary; case turnID = "turn_id"; case durationMS = "duration_ms"; case changedFiles = "changed_files" } -} - -struct TurnFailedEventPayload: Codable, Sendable { - let turnID: String?; let code: String; let message: String; let durationMS: Int64? - enum CodingKeys: String, CodingKey { case code, message; case turnID = "turn_id"; case durationMS = "duration_ms" } -} - -struct TurnInterruptedEventPayload: Codable, Sendable { - let turnID: String?; let durationMS: Int64 - enum CodingKeys: String, CodingKey { case turnID = "turn_id"; case durationMS = "duration_ms" } -} - -struct TurnRejectedEventPayload: Codable, Sendable { - let code: String - let message: String - let requiredCapabilities: [String]? - let recoveryAction: String? - let executionContext: ExecutionCapabilities? - - enum CodingKeys: String, CodingKey { - case code, message - case requiredCapabilities = "required_capabilities" - case recoveryAction = "recovery_action" - case executionContext = "execution_context" - } -} - -extension JSONEncoder { - static var relay: JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return encoder - } -} - -extension JSONDecoder { - static var relay: JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { value in - let container = try value.singleValueContainer() - let rawValue = try container.decode(String.self) - if let date = RelayDateParser.date(from: rawValue) { - return date - } - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Invalid RFC3339 timestamp: \(rawValue)" - ) - } - return decoder - } -} - -private enum RelayDateParser { - private static let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true) - private static let withoutFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: false) - - static func date(from value: String) -> Date? { - (try? withFraction.parse(value)) ?? (try? withoutFraction.parse(value)) - } -} diff --git a/CodexRemote/Services/Runtime/KeychainCredentialStore.swift b/CodexRemote/Services/Runtime/KeychainCredentialStore.swift new file mode 100644 index 0000000..2fe24ae --- /dev/null +++ b/CodexRemote/Services/Runtime/KeychainCredentialStore.swift @@ -0,0 +1,87 @@ +import Foundation +import Security + +struct RuntimeRefreshCredential: Codable, Equatable, Sendable { + let baseURL: String + let refreshToken: String + let refreshExpiresAt: Date + let clientName: String +} + +protocol RuntimeCredentialStore: Sendable { + func load() throws -> RuntimeRefreshCredential? + func save(_ credential: RuntimeRefreshCredential) throws + func remove() throws +} + +struct KeychainCredentialStore: RuntimeCredentialStore { + private let service = "org.codexremote.app.runtime-auth" + private let account = "refresh-session" + + func load() throws -> RuntimeRefreshCredential? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainError(status: status) + } + return try JSONDecoder().decode(RuntimeRefreshCredential.self, from: data) + } + + func save(_ credential: RuntimeRefreshCredential) throws { + let data = try JSONEncoder().encode(credential) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var item = query + attributes.forEach { item[$0.key] = $0.value } + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw KeychainError(status: addStatus) } + } else if status != errSecSuccess { + throw KeychainError(status: status) + } + } + + func remove() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError(status: status) + } + } +} + +struct KeychainError: LocalizedError { + let status: OSStatus + var errorDescription: String? { "Keychain error \(status)" } +} + +final class MemoryCredentialStore: RuntimeCredentialStore, @unchecked Sendable { + private let lock = NSLock() + private var value: RuntimeRefreshCredential? + + init(value: RuntimeRefreshCredential? = nil) { self.value = value } + + func load() -> RuntimeRefreshCredential? { lock.withLock { value } } + func save(_ credential: RuntimeRefreshCredential) { lock.withLock { value = credential } } + func remove() { lock.withLock { value = nil } } +} diff --git a/CodexRemote/Services/Runtime/RuntimeAuthSession.swift b/CodexRemote/Services/Runtime/RuntimeAuthSession.swift new file mode 100644 index 0000000..70f22ae --- /dev/null +++ b/CodexRemote/Services/Runtime/RuntimeAuthSession.swift @@ -0,0 +1,197 @@ +import Foundation + +enum RuntimeAuthError: LocalizedError, Sendable { + case invalidPairingInput + case missingCredential + case rejected(code: String, status: Int) + case invalidResponse + + var errorDescription: String? { + switch self { + case .invalidPairingInput: "Invalid pairing code or link." + case .missingCredential: "This device is not paired." + case .rejected(let code, _): code + case .invalidResponse: "The authentication service returned an invalid response." + } + } +} + +actor RuntimeAuthSession { + private var baseURL: URL + private let session: URLSession + private let credentialStore: RuntimeCredentialStore + private var accessToken = "" + private var accessExpiresAt = Date.distantPast + private var clientName = "" + private var refreshTask: Task? + + init(baseURL: URL, session: URLSession, credentialStore: RuntimeCredentialStore = KeychainCredentialStore()) { + self.baseURL = baseURL + self.session = session + self.credentialStore = credentialStore + } + + func updateBaseURL(_ url: URL) { + guard baseURL != url else { return } + baseURL = url + accessToken = "" + accessExpiresAt = .distantPast + clientName = "" + } + + func bootstrap() async -> RuntimeAuthenticationState { + do { + guard let credential = try credentialStore.load(), credential.baseURL == normalizedBaseURL else { + return .unauthenticated(message: nil) + } + clientName = credential.clientName + _ = try await refresh(using: credential.refreshToken) + return .authenticated(clientName: clientName) + } catch let error as RuntimeAuthError { + if case .rejected(_, let status) = error, status == 401 { + try? credentialStore.remove() + return .unauthenticated(message: "This device's access has expired or was revoked.") + } + return .unavailable(message: "Authentication service is unavailable.") + } catch { + return .unavailable(message: "Authentication service is unavailable.") + } + } + + func exchange(_ input: String) async throws -> RuntimeAuthenticationState { + guard let code = Self.pairingCode(from: input) else { throw RuntimeAuthError.invalidPairingInput } + let pair: RuntimeNativeTokenPair = try await tokenRequest( + path: "/v1/auth/native/pairing-grants:exchange", + body: ["code": code] + ) + try accept(pair) + return .authenticated(clientName: pair.client.name) + } + + func validAccessToken(rejectedToken: String? = nil) async throws -> String { + if let rejectedToken, !accessToken.isEmpty, rejectedToken != accessToken { return accessToken } + if !accessToken.isEmpty, accessExpiresAt.timeIntervalSinceNow > 30 { return accessToken } + if let refreshTask { return try await refreshTask.value } + guard let credential = try credentialStore.load(), credential.baseURL == normalizedBaseURL else { + throw RuntimeAuthError.missingCredential + } + let task = Task { try await self.refresh(using: credential.refreshToken) } + refreshTask = task + defer { refreshTask = nil } + do { + return try await task.value + } catch let error as RuntimeAuthError { + if case .rejected(_, let status) = error, status == 401 { + accessToken = "" + accessExpiresAt = .distantPast + clientName = "" + try? credentialStore.remove() + } + throw error + } + } + + func signOut() async { + let credential = try? credentialStore.load() + if let credential, credential.baseURL == normalizedBaseURL { + let request = try? makeRequest( + path: "/v1/auth/native/sessions/current:revoke", + body: ["refresh_token": credential.refreshToken] + ) + if let request { _ = try? await session.data(for: request) } + } + try? credentialStore.remove() + accessToken = "" + accessExpiresAt = .distantPast + clientName = "" + } + + static func pairingCode(from input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let components = URLComponents(string: trimmed), let fragment = components.fragment { + return URLComponents(string: "?\(fragment)")?.queryItems? + .first(where: { $0.name == "code" })?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + } + if trimmed.contains("://") { return nil } + return trimmed + } + + static func pairingOrigin(from input: String) -> URL? { + guard let inputURL = URL(string: input.trimmingCharacters(in: .whitespacesAndNewlines)), + ["http", "https"].contains(inputURL.scheme?.lowercased() ?? ""), + inputURL.host != nil, + var components = URLComponents(url: inputURL, resolvingAgainstBaseURL: false) else { return nil } + components.path = "" + components.query = nil + components.fragment = nil + return components.url + } + + private func refresh(using refreshToken: String) async throws -> String { + let pair: RuntimeNativeTokenPair = try await tokenRequest( + path: "/v1/auth/native/tokens:refresh", + body: ["refresh_token": refreshToken] + ) + try accept(pair) + return pair.accessToken + } + + private func accept(_ pair: RuntimeNativeTokenPair) throws { + accessToken = pair.accessToken + accessExpiresAt = pair.accessExpiresAt + clientName = pair.client.name + try credentialStore.save(RuntimeRefreshCredential( + baseURL: normalizedBaseURL, + refreshToken: pair.refreshToken, + refreshExpiresAt: pair.refreshExpiresAt, + clientName: pair.client.name + )) + } + + private func tokenRequest(path: String, body: [String: String]) async throws -> Value { + let request = try makeRequest(path: path, body: body) + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { throw RuntimeAuthError.invalidResponse } + if (200..<300).contains(response.statusCode), let envelope = try? RuntimeCoding.decoder.decode(RuntimeEnvelope.self, from: data) { + return envelope.data + } + let code = (try? RuntimeCoding.decoder.decode(RuntimeErrorEnvelope.self, from: data).error.code) ?? "HTTP_\(response.statusCode)" + throw RuntimeAuthError.rejected(code: code, status: response.statusCode) + } + + private func makeRequest(path: String, body: [String: String]) throws -> URLRequest { + guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { throw RuntimeAuthError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 10 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "X-CodexRemote-Request") + request.httpBody = try JSONEncoder().encode(body) + return request + } + + private var normalizedBaseURL: String { baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} + +enum RuntimeCoding { + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let standard = ISO8601DateFormatter() + if let date = fractional.date(from: value) ?? standard.date(from: value) { return date } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid RFC3339 date") + } + return decoder + }() +} diff --git a/CodexRemote/Services/Runtime/RuntimeClient.swift b/CodexRemote/Services/Runtime/RuntimeClient.swift new file mode 100644 index 0000000..d5cf0ea --- /dev/null +++ b/CodexRemote/Services/Runtime/RuntimeClient.swift @@ -0,0 +1,236 @@ +import Foundation + +enum RuntimeAPIError: LocalizedError, Sendable { + case invalidURL + case invalidResponse + case request(code: String, message: String, status: Int, retryable: Bool) + + var errorDescription: String? { + switch self { + case .invalidURL: "Invalid Runtime URL." + case .invalidResponse: "Runtime returned an invalid response." + case .request(_, let message, _, _): message + } + } +} + +actor RuntimeCursorStore { + private let defaults: UserDefaults + private let prefix = "runtime.cursor." + + init(defaults: UserDefaults = .standard) { self.defaults = defaults } + + func value(origin: String, kind: String, id: String) -> Int64 { + Int64(defaults.integer(forKey: key(origin: origin, kind: kind, id: id))) + } + + func advance(_ value: Int64, origin: String, kind: String, id: String) { + guard value > self.value(origin: origin, kind: kind, id: id) else { return } + defaults.set(value, forKey: key(origin: origin, kind: kind, id: id)) + } + + private func key(origin: String, kind: String, id: String) -> String { + let raw = Data("\(origin)|\(kind)|\(id)".utf8).base64EncodedString() + return prefix + raw + } +} + +actor RuntimeClient { + private var baseURL: URL + private let session: URLSession + private let auth: RuntimeAuthSession + + init(baseURL: URL, session: URLSession? = nil, credentialStore: RuntimeCredentialStore = KeychainCredentialStore()) { + let configuration = URLSessionConfiguration.default + configuration.waitsForConnectivity = true + configuration.timeoutIntervalForRequest = 15 + let resolvedSession = session ?? URLSession(configuration: configuration) + self.baseURL = baseURL + self.session = resolvedSession + auth = RuntimeAuthSession(baseURL: baseURL, session: resolvedSession, credentialStore: credentialStore) + } + + func updateBaseURL(_ url: URL) async { + baseURL = url + await auth.updateBaseURL(url) + } + + func bootstrapAuthentication() async -> RuntimeAuthenticationState { await auth.bootstrap() } + func pair(_ input: String) async throws -> RuntimeAuthenticationState { try await auth.exchange(input) } + func signOut() async { await auth.signOut() } + + func health() async throws { + let _: [String: String] = try await get("/v1/runtime/healthz") + } + + func projects() async throws -> RuntimeProjectList { try await get("/v1/runtime/projects") } + + func sessions(projectID: String? = nil) async throws -> [RuntimeSession] { + var path = "/v1/runtime/sessions" + if let projectID { path += "?project_id=\(projectID.urlPathEncoded)" } + let result: RuntimeSessionList = try await get(path) + return result.items + } + + func createSession(projectID: String, title: String, idempotencyKey: String) async throws -> RuntimeSession { + try await post("/v1/runtime/sessions", body: ["project_id": projectID, "title": title], idempotencyKey: idempotencyKey) + } + + func sessionSnapshot(_ sessionID: String) async throws -> RuntimeSessionSnapshot { + try await get("/v1/runtime/sessions/\(sessionID.urlPathEncoded)") + } + + func sessionDetail(_ sessionID: String) async throws -> RuntimeSessionSnapshot { + let snapshot = try await sessionSnapshot(sessionID) + let runsByID = try await withThrowingTaskGroup(of: RuntimeRun.self, returning: [String: RuntimeRun].self) { group in + for summary in snapshot.runs { + group.addTask { try await self.run(summary.runID) } + } + var values: [String: RuntimeRun] = [:] + for try await run in group { values[run.runID] = run } + return values + } + return RuntimeSessionSnapshot( + session: snapshot.session, + runs: snapshot.runs.compactMap { runsByID[$0.runID] }, + snapshotSessionSequence: snapshot.snapshotSessionSequence, + agentPresence: snapshot.agentPresence + ) + } + + func run(_ runID: String) async throws -> RuntimeRun { + try await get("/v1/runtime/runs/\(runID.urlPathEncoded)") + } + + func createRun(sessionID: String, prompt: String, permissionProfileID: String?, idempotencyKey: String) async throws -> RuntimeCreatedRun { + var body = ["prompt": prompt] + if let permissionProfileID { body["permission_profile_id"] = permissionProfileID } + return try await post("/v1/runtime/sessions/\(sessionID.urlPathEncoded)/runs", body: body, idempotencyKey: idempotencyKey) + } + + func cancelRun(_ runID: String) async throws { + let _: [String: JSONValue] = try await post("/v1/runtime/runs/\(runID.urlPathEncoded)/cancel", body: [String: String]()) + } + + func executionProfiles(projectID: String) async throws -> ExecutionProfileSnapshot { + try await get("/v1/runtime/projects/\(projectID.urlPathEncoded)/execution-profiles") + } + + func startBootstrap(idempotencyKey: String) async throws -> RuntimeSyncJob { + try await post("/v1/runtime/bootstrap-syncs", body: [String: String](), idempotencyKey: idempotencyKey) + } + + func bootstrap(_ syncID: String) async throws -> RuntimeSyncJob { + try await get("/v1/runtime/bootstrap-syncs/\(syncID.urlPathEncoded)") + } + + func eventStream(path: String, after: Int64) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + var request = try await self.authorizedRequest(path: "\(path)?after=\(max(0, after))", method: "GET") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + var attemptedToken = request.value(forHTTPHeaderField: "Authorization")?.replacingOccurrences(of: "Bearer ", with: "") ?? "" + var (bytes, response) = try await self.session.bytes(for: request) + if let http = response as? HTTPURLResponse, http.statusCode == 401 { + let token = try await self.auth.validAccessToken(rejectedToken: attemptedToken) + attemptedToken = token + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + (bytes, response) = try await self.session.bytes(for: request) + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw RuntimeAPIError.request(code: "SSE_HTTP_ERROR", message: "Runtime event stream is unavailable.", status: (response as? HTTPURLResponse)?.statusCode ?? 0, retryable: true) + } + var decoder = SSEDecoder() + for try await line in bytes.lines { + if Task.isCancelled { break } + for packet in decoder.push(line + "\n") { + if let event = try Self.runtimeEvent(packet) { continuation.yield(event) } + } + } + for packet in decoder.finish() { + if let event = try Self.runtimeEvent(packet) { continuation.yield(event) } + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + private func get(_ path: String) async throws -> Value { + try await request(path: path, method: "GET", body: Optional<[String: String]>.none, idempotencyKey: nil) + } + + private func post( + _ path: String, + body: Body, + idempotencyKey: String? = nil + ) async throws -> Value { + try await request(path: path, method: "POST", body: body, idempotencyKey: idempotencyKey) + } + + private func request( + path: String, + method: String, + body: Body?, + idempotencyKey: String? + ) async throws -> Value { + var request = try await authorizedRequest(path: path, method: method) + if let body { + request.httpBody = try JSONEncoder().encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + if let idempotencyKey { request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key") } + let attemptedToken = request.value(forHTTPHeaderField: "Authorization")?.replacingOccurrences(of: "Bearer ", with: "") ?? "" + var (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, http.statusCode == 401 { + let token = try await auth.validAccessToken(rejectedToken: attemptedToken) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + (data, response) = try await session.data(for: request) + } + guard let http = response as? HTTPURLResponse else { throw RuntimeAPIError.invalidResponse } + if (200..<300).contains(http.statusCode), let envelope = try? RuntimeCoding.decoder.decode(RuntimeEnvelope.self, from: data) { + return envelope.data + } + let failure = try? RuntimeCoding.decoder.decode(RuntimeErrorEnvelope.self, from: data).error + throw RuntimeAPIError.request( + code: failure?.code ?? "HTTP_\(http.statusCode)", + message: failure?.message ?? "Runtime request failed.", + status: http.statusCode, + retryable: failure?.retryable ?? (http.statusCode >= 500) + ) + } + + private func authorizedRequest(path: String, method: String) async throws -> URLRequest { + guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { throw RuntimeAPIError.invalidURL } + let token = try await auth.validAccessToken() + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = method == "GET" && path.contains("/events") ? 60 : 15 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("1", forHTTPHeaderField: "X-CodexRemote-Request") + return request + } + + private static func runtimeEvent(_ packet: ServerSentEvent) throws -> RuntimeStreamEvent? { + guard !packet.data.isEmpty else { return nil } + guard let data = packet.data.data(using: .utf8), + case .object(let fields) = try RuntimeCoding.decoder.decode(JSONValue.self, from: data) else { + throw RuntimeAPIError.invalidResponse + } + let id = Int64(packet.id ?? "") ?? fields["id"]?.int64Value ?? 0 + let type = packet.event ?? fields["type"]?.stringValue ?? "message" + return RuntimeStreamEvent(id: id, type: type, fields: fields) + } +} + +private extension String { + var urlPathEncoded: String { + addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#"))) ?? self + } +} diff --git a/CodexRemote/Services/Runtime/RuntimeRelayService.swift b/CodexRemote/Services/Runtime/RuntimeRelayService.swift new file mode 100644 index 0000000..514ad49 --- /dev/null +++ b/CodexRemote/Services/Runtime/RuntimeRelayService.swift @@ -0,0 +1,511 @@ +import Foundation + +@MainActor +final class RuntimeRelayService: RelayServiceProtocol { + private let eventBuffer = RelayEventBuffer() + private let cursorStore = RuntimeCursorStore() + private var client: RuntimeClient + private var baseURL: URL + private var runWatchTask: Task? + private var sessionWatchTask: Task? + private var bootstrapTask: Task? + private var sessionsByProjectID: [String: [RuntimeSession]] = [:] + private var selectedProjectID: String? + private var selectedSessionID: String? + private var activeRunID: String? + private var legacyAssistantStartedRunIDs: Set = [] + + init() { + let defaults = UserDefaults.standard + let profile = defaults.string(forKey: "relayConnectionProfile") + .flatMap(RelayConnectionProfile.init(rawValue:)) ?? .current + let rawURL = defaults.string(forKey: profile.urlDefaultsKey) ?? profile.defaultURL + baseURL = Self.validRuntimeURL(rawURL) ?? URL(string: "http://127.0.0.1:18874")! + client = RuntimeClient(baseURL: baseURL) + } + + func events() -> AsyncStream { eventBuffer.stream() } + + func connect() async { + stopWatchers() + emit(.authentication(.checking)) + emit(.connection(.connecting)) + let state = await client.bootstrapAuthentication() + emit(.authentication(state)) + guard state.isAuthenticated else { + emit(.connection(.disconnected)) + emit(.agent(name: "Mac Agent", state: .offline)) + return + } + do { + try await client.health() + emit(.connection(.connected)) + await requestProjects() + } catch { + publishConnectionFailure(error) + } + } + + func disconnect() async { + stopWatchers() + emit(.connection(.disconnected)) + emit(.agent(name: "Mac Agent", state: .offline)) + } + + func testConnection(url: String) async throws -> RelayConnectionTestResult { + guard let resolved = Self.validRuntimeURL(url) else { throw RuntimeAPIError.invalidURL } + var request = URLRequest(url: URL(string: "/gateway/healthz", relativeTo: resolved)!.absoluteURL) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 5 + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 5 + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let (_, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { + throw RuntimeAPIError.invalidResponse + } + baseURL = resolved + await client.updateBaseURL(resolved) + await connect() + return RelayConnectionTestResult(agentConnected: true) + } + + func pair(codeOrLink: String) async { + emit(.authentication(.pairing)) + do { + if let origin = RuntimeAuthSession.pairingOrigin(from: codeOrLink), origin != baseURL { + baseURL = origin + UserDefaults.standard.set(origin.absoluteString, forKey: RelayConnectionProfile.current.urlDefaultsKey) + await client.updateBaseURL(origin) + emit(.runtimeOrigin(origin.absoluteString)) + } + let state = try await client.pair(codeOrLink) + emit(.authentication(state)) + await connect() + } catch { + let message = pairingMessage(error) + emit(.authentication(.unauthenticated(message: message))) + } + } + + func signOut() async { + stopWatchers() + await client.signOut() + sessionsByProjectID.removeAll() + selectedProjectID = nil + selectedSessionID = nil + activeRunID = nil + emit(.reset) + emit(.projects([])) + emit(.connection(.disconnected)) + emit(.agent(name: "Mac Agent", state: .offline)) + emit(.authentication(.unauthenticated(message: "This device has signed out."))) + } + + func suspend() async { + stopWatchers() + } + + func resume() async { + let projectID = selectedProjectID + let sessionID = selectedSessionID + await connect() + guard case .connected = currentConnectionState else { return } + if let projectID { await requestThreads(projectID: projectID) } + if let projectID, let sessionID { await requestThread(projectID: projectID, threadID: sessionID) } + } + + func requestProjects() async { + do { + let projectList = try await client.projects() + let sessions = try await client.sessions() + sessionsByProjectID = Dictionary(grouping: sessions, by: \RuntimeSession.projectID) + let projects = projectList.items.map { item in + let values = sessionsByProjectID[item.projectID] ?? [] + return ProjectSummary( + id: item.projectID, + name: item.displayName, + path: "", + threadCount: values.count, + updatedAt: values.first?.updatedAt + ) + } + let agentState: AgentState = projectList.agentPresence == "online" ? (activeRunID == nil ? .idle : .running) : .offline + emit(.agent(name: "Mac Agent", state: agentState)) + emit(.capabilities(runtimeCapabilities)) + emit(.projects(projects)) + if projects.isEmpty, projectList.agentPresence == "online" { startBootstrapIfNeeded() } + } catch { + emit(.projectsFailed(message: displayMessage(error))) + publishConnectionFailure(error) + } + } + + func requestExecutionProfiles(projectID: String) async { + do { + emit(.executionProfiles(try await client.executionProfiles(projectID: projectID))) + } catch { + emit(.executionProfilesFailed(projectID: projectID, message: displayMessage(error))) + } + } + + func requestThreads(projectID: String) async { + selectedProjectID = projectID + do { + let sessions = try await client.sessions(projectID: projectID) + sessionsByProjectID[projectID] = sessions + emit(.threads(projectID: projectID, values: sessions.map { $0.threadSummary() })) + } catch { + emit(.threadsFailed(projectID: projectID, message: displayMessage(error))) + } + } + + func requestThread(projectID: String, threadID: String) async { + switchContext(projectID: projectID, sessionID: threadID) + do { + let snapshot = try await client.sessionDetail(threadID) + await cursorStore.advance(snapshot.snapshotSessionSequence, origin: origin, kind: "session", id: threadID) + for run in snapshot.runs { + await cursorStore.advance(run.persistedThroughSequence, origin: origin, kind: "run", id: run.runID) + } + let active = snapshot.runs.last(where: { runtimeStatusIsActive($0.status) }) + emit(.threadDetail(projectID: projectID, value: snapshot.threadDetail(excludingRunID: activeRunID ?? active?.runID), truncated: false)) + startSessionWatcher(projectID: projectID, sessionID: threadID) + if let active { + restoreActiveRun(active) + startRunWatcher(projectID: projectID, sessionID: threadID, runID: active.runID) + } + } catch { + emit(.threadDetailFailed(projectID: projectID, threadID: threadID, message: displayMessage(error))) + } + } + + func selectContext(projectID: String, threadID: String?) { + switchContext(projectID: projectID, sessionID: threadID) + } + + func startTurn(projectID: String, threadID: String?, prompt: String, permissionProfileID: String?) async { + do { + let session: RuntimeSession + if let threadID { + let snapshot = try await client.sessionSnapshot(threadID) + session = snapshot.session + } else { + session = try await client.createSession( + projectID: projectID, + title: Self.sessionTitle(from: prompt), + idempotencyKey: "ios-session-\(UUID().uuidString.lowercased())" + ) + } + selectedProjectID = projectID + selectedSessionID = session.sessionID + let run = try await client.createRun( + sessionID: session.sessionID, + prompt: prompt, + permissionProfileID: permissionProfileID, + idempotencyKey: "ios-run-\(UUID().uuidString.lowercased())" + ) + activeRunID = run.runID + emit(.turnStarted(threadID: session.sessionID, turnID: run.runID, startedAt: run.createdAt)) + emit(.agent(name: "Mac Agent", state: .running)) + await requestThreads(projectID: projectID) + startSessionWatcher(projectID: projectID, sessionID: session.sessionID) + startRunWatcher(projectID: projectID, sessionID: session.sessionID, runID: run.runID) + } catch { + emit(.turnEnded( + traceID: nil, + turnID: activeRunID, + phase: .failed, + result: TurnResult(title: String(localized: "Request rejected"), detail: displayMessage(error), changedFiles: 0, duration: 0) + )) + activeRunID = nil + } + } + + func interruptTurn() async { + guard let activeRunID else { return } + do { try await client.cancelRun(activeRunID) } + catch { emit(.output(LogEntry(stream: .stderr, text: displayMessage(error)))) } + } + + func acknowledgeTurn(traceID: String, turnID: String, phase: TurnPhase) async {} + + private func startBootstrapIfNeeded() { + guard bootstrapTask == nil else { return } + bootstrapTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { bootstrapTask = nil } + do { + var job = try await client.startBootstrap(idempotencyKey: "ios-bootstrap-\(UUID().uuidString.lowercased())") + while job.status == "queued" || job.status == "running" { + try await Task.sleep(for: .milliseconds(700)) + job = try await client.bootstrap(job.syncID) + } + if job.status == "completed" { await requestProjects() } + else { emit(.projectsFailed(message: job.errorMessage ?? "History sync failed.")) } + } catch { + emit(.projectsFailed(message: displayMessage(error))) + } + } + } + + private func startSessionWatcher(projectID: String, sessionID: String) { + sessionWatchTask?.cancel() + sessionWatchTask = Task { @MainActor [weak self] in + guard let self else { return } + var cursor = await cursorStore.value(origin: origin, kind: "session", id: sessionID) + while !Task.isCancelled, selectedSessionID == sessionID { + do { + let stream = await client.eventStream(path: "/v1/runtime/sessions/\(sessionID)/events", after: cursor) + for try await event in stream { + guard !Task.isCancelled, selectedSessionID == sessionID else { return } + cursor = max(cursor, event.id) + await cursorStore.advance(cursor, origin: origin, kind: "session", id: sessionID) + await refreshSelectedSession(projectID: projectID, sessionID: sessionID, discoveredRunID: event.runID) + } + } catch { + if Task.isCancelled { return } + if handleAuthenticationFailure(error) { return } + try? await Task.sleep(for: .seconds(2)) + } + } + } + } + + private func startRunWatcher(projectID: String, sessionID: String, runID: String) { + runWatchTask?.cancel() + activeRunID = runID + runWatchTask = Task { @MainActor [weak self] in + guard let self else { return } + var cursor = await cursorStore.value(origin: origin, kind: "run", id: runID) + while !Task.isCancelled, activeRunID == runID { + do { + let stream = await client.eventStream(path: "/v1/runtime/runs/\(runID)/events", after: cursor) + for try await event in stream { + guard !Task.isCancelled, activeRunID == runID else { return } + cursor = max(cursor, event.id) + await cursorStore.advance(cursor, origin: origin, kind: "run", id: runID) + if applyRunEvent(event, runID: runID) { + await finishRun(projectID: projectID, sessionID: sessionID, runID: runID) + return + } + } + } catch { + if Task.isCancelled { return } + if handleAuthenticationFailure(error) { return } + try? await Task.sleep(for: .seconds(2)) + } + } + } + } + + private func refreshSelectedSession(projectID: String, sessionID: String, discoveredRunID: String?) async { + guard selectedSessionID == sessionID else { return } + do { + let snapshot = try await client.sessionDetail(sessionID) + let active = snapshot.runs.last(where: { runtimeStatusIsActive($0.status) }) + emit(.threadDetail(projectID: projectID, value: snapshot.threadDetail(excludingRunID: active?.runID), truncated: false)) + await requestThreads(projectID: projectID) + guard let runID = discoveredRunID, + runID != activeRunID, + let run = snapshot.runs.first(where: { $0.runID == runID }), + runtimeStatusIsActive(run.status) else { return } + restoreActiveRun(run) + startRunWatcher(projectID: projectID, sessionID: sessionID, runID: runID) + } catch { + emit(.output(LogEntry(stream: .system, text: displayMessage(error)))) + } + } + + private func restoreActiveRun(_ run: RuntimeRun) { + activeRunID = run.runID + let turn = run.threadHistoryTurn() + emit(.turnSnapshot( + threadID: run.sessionID, + turnID: run.runID, + startedAt: run.startedAt ?? run.createdAt, + items: turn.items, + lastSequence: run.persistedThroughSequence, + itemsTruncated: turn.truncated, + recentOutput: [] + )) + emit(.agent(name: "Mac Agent", state: .running)) + } + + private func applyRunEvent(_ event: RuntimeStreamEvent, runID: String) -> Bool { + switch event.type { + case "turn.started": + emit(.turnStarted( + threadID: selectedSessionID ?? "", + turnID: runID, + startedAt: event.fields["started_at"]?.stringValue.flatMap(Self.date) ?? Date() + )) + case "assistant.delta": + let delta = event.fields["text"]?.stringValue ?? event.fields["delta"]?.stringValue ?? "" + if legacyAssistantStartedRunIDs.insert(runID).inserted { + emit(.turnItemStarted( + turnID: runID, + sequence: event.id, + item: ThreadHistoryItem( + id: "assistant-\(runID)", type: "agentMessage", role: "assistant", phase: "final_answer", + status: "inProgress", text: delta, name: nil, command: nil, cwd: nil, output: nil, + path: nil, query: nil, exitCode: nil, durationMS: nil, changes: nil, truncated: false + ) + )) + break + } + emit(.turnItemDelta(turnID: runID, sequence: event.id, itemID: "assistant-\(runID)", field: "text", delta: delta)) + case "item.started": + if let value = event.fields["item"], let item = decodeHistoryItem(value) { + emit(.turnItemStarted(turnID: runID, sequence: event.id, item: item)) + } + case "item.delta": + emit(.turnItemDelta( + turnID: runID, + sequence: event.id, + itemID: event.fields["item_id"]?.stringValue ?? "", + field: event.fields["field"]?.stringValue ?? "text", + delta: event.fields["delta"]?.stringValue ?? "" + )) + case "item.completed": + if let value = event.fields["item"], let item = decodeHistoryItem(value) { + emit(.turnItemCompleted(turnID: runID, sequence: event.id, item: item)) + } + case "turn.completed": + emit(.turnEnded(traceID: nil, turnID: runID, phase: .completed, result: nil)) + return true + case "turn.failed": + let message = event.fields["message"]?.stringValue ?? "Runtime execution failed." + emit(.turnEnded(traceID: nil, turnID: runID, phase: .failed, result: TurnResult(title: String(localized: "Turn failed"), detail: message, changedFiles: 0, duration: 0))) + return true + case "turn.interrupted": + emit(.turnEnded(traceID: nil, turnID: runID, phase: .interrupted, result: TurnResult(title: String(localized: "Turn interrupted"), detail: String(localized: "Codex stopped cleanly."), changedFiles: 0, duration: 0))) + return true + default: + break + } + return false + } + + private func finishRun(projectID: String, sessionID: String, runID: String) async { + guard activeRunID == runID else { return } + activeRunID = nil + legacyAssistantStartedRunIDs.remove(runID) + emit(.agent(name: "Mac Agent", state: .idle)) + do { + let snapshot = try await client.sessionDetail(sessionID) + emit(.threadDetail(projectID: projectID, value: snapshot.threadDetail(), truncated: false)) + emit(.reset) + await requestThreads(projectID: projectID) + } catch { + emit(.output(LogEntry(stream: .system, text: displayMessage(error)))) + } + } + + private func publishConnectionFailure(_ error: Error) { + if handleAuthenticationFailure(error) { return } + emit(.connection(.disconnected)) + emit(.agent(name: "Mac Agent", state: .offline)) + emit(.output(LogEntry(stream: .system, text: displayMessage(error)))) + } + + private func handleAuthenticationFailure(_ error: Error) -> Bool { + guard let authError = error as? RuntimeAuthError else { return false } + switch authError { + case .missingCredential: + emit(.authentication(.unauthenticated(message: "This device is not paired."))) + case .rejected(_, let status) where status == 401: + emit(.authentication(.unauthenticated(message: "This device's access has expired or was revoked."))) + default: + emit(.authentication(.unavailable(message: "Authentication service is unavailable."))) + } + emit(.connection(.disconnected)) + emit(.agent(name: "Mac Agent", state: .offline)) + return true + } + + private func stopWatchers() { + runWatchTask?.cancel() + sessionWatchTask?.cancel() + bootstrapTask?.cancel() + runWatchTask = nil + sessionWatchTask = nil + bootstrapTask = nil + } + + private func switchContext(projectID: String, sessionID: String?) { + if selectedSessionID != sessionID { + runWatchTask?.cancel() + sessionWatchTask?.cancel() + runWatchTask = nil + sessionWatchTask = nil + activeRunID = nil + legacyAssistantStartedRunIDs.removeAll() + } + selectedProjectID = projectID + selectedSessionID = sessionID + } + + private var currentConnectionState: ConnectionState = .disconnected + private func emit(_ event: RelayEvent) { + if case .connection(let state) = event { currentConnectionState = state } + eventBuffer.yield(event) + } + private var origin: String { baseURL.absoluteString } + + private var runtimeCapabilities: ExecutionCapabilities { + ExecutionCapabilities( + restricted: false, + sandboxMode: "runtime-profile", + approvalPolicy: "runtime-profile", + writableScope: "selected-project", + networkAccess: false, + canRequestApproval: false, + hostProcessControl: false, + userLibraryWrite: false, + xcodeDeviceControl: false, + supportsPermissionProfiles: true + ) + } + + private func displayMessage(_ error: Error) -> String { + if let value = error as? RuntimeAPIError { return value.localizedDescription } + if let value = error as? RuntimeAuthError { return value.localizedDescription } + return error.localizedDescription + } + + private func pairingMessage(_ error: Error) -> String { + guard case .rejected(let code, _) = error as? RuntimeAuthError else { return displayMessage(error) } + switch code { + case "AUTH_PAIRING_ALREADY_USED": return "This pairing link has already been used." + case "AUTH_CREDENTIAL_EXPIRED": return "This pairing link has expired." + default: return "This pairing code is invalid." + } + } + + private static func sessionTitle(from prompt: String) -> String { + let compact = prompt.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) + return String(compact.prefix(80)) + } + + private static func date(_ value: String) -> Date? { + guard let data = "\"\(value)\"".data(using: .utf8) else { return nil } + return try? RuntimeCoding.decoder.decode(Date.self, from: data) + } + + private static func validRuntimeURL(_ value: String) -> URL? { + guard let url = URL(string: value.trimmingCharacters(in: .whitespacesAndNewlines)), + ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + url.host != nil, + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil else { return nil } + return url + } +} + +private func runtimeStatusIsActive(_ status: String) -> Bool { + ["queued", "dispatching", "accepted", "running", "waiting_agent", "recovering", "finalizing"].contains(status) +} diff --git a/CodexRemote/Services/Runtime/SSEDecoder.swift b/CodexRemote/Services/Runtime/SSEDecoder.swift new file mode 100644 index 0000000..e76acfb --- /dev/null +++ b/CodexRemote/Services/Runtime/SSEDecoder.swift @@ -0,0 +1,54 @@ +import Foundation + +struct ServerSentEvent: Equatable, Sendable { + let id: String? + let event: String? + let data: String +} + +struct SSEDecoder: Sendable { + private var buffer = "" + private var eventID: String? + private var eventType: String? + private var dataLines: [String] = [] + + mutating func push(_ chunk: String) -> [ServerSentEvent] { + buffer += chunk + let normalized = buffer.replacingOccurrences(of: "\r\n", with: "\n") + var lines = normalized.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + buffer = lines.popLast() ?? "" + return consume(lines) + } + + mutating func finish() -> [ServerSentEvent] { + let lines = buffer.isEmpty ? [""] : [buffer, ""] + buffer = "" + return consume(lines) + } + + private mutating func consume(_ lines: [String]) -> [ServerSentEvent] { + var result: [ServerSentEvent] = [] + for line in lines { + if line.isEmpty { + if !dataLines.isEmpty { + result.append(ServerSentEvent(id: eventID, event: eventType, data: dataLines.joined(separator: "\n"))) + } + eventType = nil + dataLines.removeAll(keepingCapacity: true) + continue + } + if line.hasPrefix(":") { continue } + let components = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let field = String(components[0]) + var value = components.count == 2 ? String(components[1]) : "" + if value.hasPrefix(" ") { value.removeFirst() } + switch field { + case "id": eventID = value + case "event": eventType = value + case "data": dataLines.append(value) + default: break + } + } + return result + } +} diff --git a/CodexRemote/en.lproj/Localizable.strings b/CodexRemote/en.lproj/Localizable.strings index 49a9bf0..d18537d 100644 --- a/CodexRemote/en.lproj/Localizable.strings +++ b/CodexRemote/en.lproj/Localizable.strings @@ -53,7 +53,6 @@ "Sessions" = "Sessions"; "Settings" = "Settings"; "Simulator" = "Simulator"; -"Simulator uses the Mac loopback address and port 18767." = "Simulator uses the Mac loopback address and port 18767."; "Start a task" = "Start a task"; "Start turn" = "Start turn"; "Status" = "Status"; @@ -63,8 +62,6 @@ "Updated" = "Updated"; "Version" = "Version"; "Waiting for Codex to start" = "Waiting for Codex to start"; -"WebSocket URL" = "WebSocket URL"; -"Physical iPhone uses port 18768. Replace 127.0.0.1 with this Mac's LAN IP or .local hostname." = "Physical iPhone uses port 18768. Replace 127.0.0.1 with this Mac's LAN IP or .local hostname."; "Working" = "Working"; "Workspace" = "Workspace"; "sessions" = "sessions"; @@ -192,3 +189,16 @@ "Export diagnostics" = "Export diagnostics"; "CodexRemote diagnostics" = "CodexRemote diagnostics"; "Includes bounded structured events and Apple system diagnostics. Conversation content and credentials are never included." = "Includes bounded structured events and Apple system diagnostics. Conversation content and credentials are never included."; +"Pair this iPhone" = "Pair this iPhone"; +"Pairing link or code" = "Pairing link or code"; +"Pair device" = "Pair device"; +"Checking this device's access…" = "Checking this device's access…"; +"Exchanging the one-time pairing code…" = "Exchanging the one-time pairing code…"; +"Paste the one-time link printed by codex-remote pair." = "Paste the one-time link printed by codex-remote pair."; +"This iPhone is paired." = "This iPhone is paired."; +"Authentication" = "Authentication"; +"Device access" = "Device access"; +"Sign out" = "Sign out"; +"Refresh credentials are stored in this iPhone's Keychain and are never written to diagnostics." = "Refresh credentials are stored in this iPhone's Keychain and are never written to diagnostics."; +"Runtime URL" = "Runtime URL"; +"Checking Runtime status…" = "Checking Runtime status…"; diff --git a/CodexRemote/zh-Hans.lproj/Localizable.strings b/CodexRemote/zh-Hans.lproj/Localizable.strings index 3e07c1a..a466531 100644 --- a/CodexRemote/zh-Hans.lproj/Localizable.strings +++ b/CodexRemote/zh-Hans.lproj/Localizable.strings @@ -53,7 +53,6 @@ "Sessions" = "会话"; "Settings" = "设置"; "Simulator" = "模拟器"; -"Simulator uses the Mac loopback address and port 18767." = "模拟器使用 Mac 本机回环地址和 18767 端口。"; "Start a task" = "开始任务"; "Start turn" = "开始任务"; "Status" = "状态"; @@ -63,8 +62,6 @@ "Updated" = "更新"; "Version" = "版本"; "Waiting for Codex to start" = "等待 Codex 开始工作"; -"WebSocket URL" = "WebSocket 地址"; -"Physical iPhone uses port 18768. Replace 127.0.0.1 with this Mac's LAN IP or .local hostname." = "iPhone 真机使用 18768 端口。请将 127.0.0.1 替换为这台 Mac 的局域网 IP 或 .local 主机名。"; "Working" = "工作中"; "Workspace" = "工作区"; "sessions" = "个会话"; @@ -192,3 +189,16 @@ "Export diagnostics" = "导出诊断包"; "CodexRemote diagnostics" = "CodexRemote 诊断包"; "Includes bounded structured events and Apple system diagnostics. Conversation content and credentials are never included." = "包含有容量上限的结构化事件和 Apple 系统诊断;不会包含对话正文或凭据。"; +"Pair this iPhone" = "配对此 iPhone"; +"Pairing link or code" = "配对链接或配对码"; +"Pair device" = "配对设备"; +"Checking this device's access…" = "正在检查此设备的访问权限…"; +"Exchanging the one-time pairing code…" = "正在兑换一次性配对码…"; +"Paste the one-time link printed by codex-remote pair." = "粘贴 codex-remote pair 输出的一次性链接。"; +"This iPhone is paired." = "此 iPhone 已配对。"; +"Authentication" = "身份认证"; +"Device access" = "设备访问"; +"Sign out" = "退出设备"; +"Refresh credentials are stored in this iPhone's Keychain and are never written to diagnostics." = "刷新凭据保存在此 iPhone 的钥匙串中,绝不会写入诊断。"; +"Runtime URL" = "Runtime 地址"; +"Checking Runtime status…" = "正在检查 Runtime 状态…"; diff --git a/CodexRemoteTests/RuntimeClientTests.swift b/CodexRemoteTests/RuntimeClientTests.swift new file mode 100644 index 0000000..e3d7d43 --- /dev/null +++ b/CodexRemoteTests/RuntimeClientTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import CodexRemote + +final class RuntimeClientTests: XCTestCase { + func testSSEDecoderHandlesChunkBoundariesCommentsAndMultilineData() { + var decoder = SSEDecoder() + XCTAssertTrue(decoder.push("id: 4\nevent: item.delta\ndata: {\"delta\":\"hel").isEmpty) + let events = decoder.push("lo\",\ndata: \"type\":\"item.delta\"}\n: heartbeat\n\n") + + XCTAssertEqual(events, [ServerSentEvent( + id: "4", + event: "item.delta", + data: "{\"delta\":\"hello\",\n\"type\":\"item.delta\"}" + )]) + } + + func testPairingCodeAcceptsRawCodeAndFragmentLink() { + XCTAssertEqual(RuntimeAuthSession.pairingCode(from: " opaque-code "), "opaque-code") + XCTAssertEqual( + RuntimeAuthSession.pairingCode(from: "http://192.0.2.1:18874/pair#code=one%20time"), + "one time" + ) + XCTAssertNil(RuntimeAuthSession.pairingCode(from: "http://192.0.2.1:18874/pair")) + XCTAssertEqual( + RuntimeAuthSession.pairingOrigin(from: "http://192.0.2.1:18874/pair#code=one")?.absoluteString, + "http://192.0.2.1:18874" + ) + XCTAssertNil(RuntimeAuthSession.pairingOrigin(from: "opaque-code")) + } + + func testRuntimeRunMapsFinalAnswerAndToolIntoThreadHistory() throws { + let data = Data(#""" + { + "run_id":"run-1","session_id":"session-1","status":"completed","state_version":4, + "prompt":"Review it","permission_profile_id":":workspace","persisted_through_sequence":4, + "final_sequence":4,"created_at":"2026-10-09T04:00:00Z","started_at":"2026-10-09T04:00:01Z", + "finished_at":"2026-10-09T04:00:03Z","updated_at":"2026-10-09T04:00:03Z", + "events":[ + {"agent_sequence":1,"type":"item.completed","occurred_at":"2026-10-09T04:00:01Z","payload":{"item":{"id":"cmd","type":"commandExecution","command":"git status","status":"completed","truncated":false}}}, + {"agent_sequence":2,"type":"item.started","occurred_at":"2026-10-09T04:00:02Z","payload":{"item":{"id":"answer","type":"agentMessage","role":"assistant","phase":"final_answer","text":"","truncated":false}}}, + {"agent_sequence":3,"type":"item.delta","occurred_at":"2026-10-09T04:00:02Z","payload":{"item_id":"answer","field":"text","delta":"Done"}}, + {"agent_sequence":4,"type":"turn.completed","occurred_at":"2026-10-09T04:00:03Z","payload":{}} + ] + } + """#.utf8) + let run = try RuntimeCoding.decoder.decode(RuntimeRun.self, from: data) + let turn = run.threadHistoryTurn() + + XCTAssertEqual(turn.status, "completed") + XCTAssertEqual(turn.items.first?.text, "Review it") + XCTAssertEqual(turn.items.last?.text, "Done") + XCTAssertEqual(turn.items.first(where: { $0.id == "cmd" })?.command, "git status") + XCTAssertEqual(run.permissionProfileID, ":workspace") + } + + func testCursorStoreOnlyAdvances() async { + let suite = "RuntimeClientTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = RuntimeCursorStore(defaults: defaults) + + await store.advance(9, origin: "http://runtime", kind: "run", id: "run-1") + await store.advance(3, origin: "http://runtime", kind: "run", id: "run-1") + + let value = await store.value(origin: "http://runtime", kind: "run", id: "run-1") + XCTAssertEqual(value, 9) + } + + func testMemoryCredentialStoreReplacesAndRemovesRefreshCredential() { + let store = MemoryCredentialStore() + let first = RuntimeRefreshCredential( + baseURL: "http://runtime", + refreshToken: "secret-1", + refreshExpiresAt: Date(timeIntervalSince1970: 100), + clientName: "iPhone" + ) + store.save(first) + XCTAssertEqual(store.load(), first) + store.remove() + XCTAssertNil(store.load()) + } +} diff --git a/CodexRemoteTests/TurnSessionStoreTests.swift b/CodexRemoteTests/TurnSessionStoreTests.swift index 6efd6fa..1e487d2 100644 --- a/CodexRemoteTests/TurnSessionStoreTests.swift +++ b/CodexRemoteTests/TurnSessionStoreTests.swift @@ -70,14 +70,14 @@ final class TurnSessionStoreTests: XCTestCase { XCTAssertEqual(store.retainedCharacterCount, 24_576 + 7) } - func testRelayDecoderAcceptsFractionalAndWholeSecondDates() throws { + func testRuntimeDecoderAcceptsFractionalAndWholeSecondDates() throws { struct Payload: Decodable { let date: Date } - let withFraction = try JSONDecoder.relay.decode( + let withFraction = try RuntimeCoding.decoder.decode( Payload.self, from: Data(#"{"date":"2026-08-12T08:34:43.123Z"}"#.utf8) ) - let withoutFraction = try JSONDecoder.relay.decode( + let withoutFraction = try RuntimeCoding.decoder.decode( Payload.self, from: Data(#"{"date":"2026-08-12T08:34:43Z"}"#.utf8) ) diff --git a/CodexRemoteUITests/WorkspaceSkeletonUITests.swift b/CodexRemoteUITests/WorkspaceSkeletonUITests.swift index 47786b3..71f5a07 100644 --- a/CodexRemoteUITests/WorkspaceSkeletonUITests.swift +++ b/CodexRemoteUITests/WorkspaceSkeletonUITests.swift @@ -18,6 +18,20 @@ final class WorkspaceSkeletonUITests: XCTestCase { XCTAssertFalse(element("projectManager.drawer", in: app).waitForExistence(timeout: 1)) } + func testUnpairedRuntimeShowsNativePairingControls() throws { + let app = launchApp(arguments: ["--demo-unpaired"]) + + XCTAssertTrue(app.staticTexts["Pair this iPhone"].waitForExistence(timeout: 4)) + let input = element("auth.pairingInput", in: app) + XCTAssertTrue(input.waitForExistence(timeout: 2)) + XCTAssertTrue(button("auth.pair", in: app).waitForExistence(timeout: 2)) + XCTAssertFalse(button("auth.pair", in: app).isEnabled) + + input.tap() + input.typeText("one-time-code") + XCTAssertTrue(button("auth.pair", in: app).isEnabled) + } + func testProjectManagerShowsLoadingUntilProjectsArrive() throws { let app = launchApp(arguments: ["--demo-drawer", "--demo-project-loading"]) let loading = element("projectManager.projects.loading", in: app) @@ -221,32 +235,32 @@ final class WorkspaceSkeletonUITests: XCTestCase { XCTAssertLessThan(lowerProject.frame.minY, app.frame.height * 0.55) } - func testConnectionProfilesUseDifferentDefaultPorts() throws { + func testConnectionProfilesUseRuntimeGatewayDefault() throws { let app = launchApp(arguments: ["--demo-settings", "--reset-connection-settings"]) let urlField = app.textFields["connection.url"] XCTAssertTrue(urlField.waitForExistence(timeout: 4)) - XCTAssertTrue(stringValue(of: urlField).contains(":18767/")) + XCTAssertTrue(stringValue(of: urlField).contains(":18874")) let profilePicker = app.segmentedControls["connection.profile"] XCTAssertTrue(profilePicker.waitForExistence(timeout: 2)) profilePicker.buttons.element(boundBy: 1).tap() - XCTAssertTrue(stringValue(of: urlField).contains(":18768/")) + XCTAssertTrue(stringValue(of: urlField).contains(":18874")) profilePicker.buttons.element(boundBy: 0).tap() - XCTAssertTrue(stringValue(of: urlField).contains(":18767/")) + XCTAssertTrue(stringValue(of: urlField).contains(":18874")) } - func testRelayURLLaunchArgumentConfiguresIPhoneConnection() throws { - let relayURL = "ws://192.0.2.10:18768/ws/app" + func testRuntimeURLLaunchArgumentConfiguresIPhoneConnection() throws { + let runtimeURL = "http://192.0.2.10:18874" let app = launchApp(arguments: [ "--demo-settings", "--reset-connection-settings", - "--relay-url", relayURL + "--runtime-url", runtimeURL ]) let urlField = app.textFields["connection.url"] XCTAssertTrue(urlField.waitForExistence(timeout: 4)) - XCTAssertEqual(stringValue(of: urlField), relayURL) + XCTAssertEqual(stringValue(of: urlField), runtimeURL) let connectionToggle = element("connection.toggle", in: app) XCTAssertTrue(connectionToggle.waitForExistence(timeout: 2)) diff --git a/README.md b/README.md index 45936a1..6c22792 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Codex Remote - iPhone App -SwiftUI 移动控制台。App 通过 WebSocket 连接 Relay,列出 Mac 上的多个 Git 项目与 Codex 历史会话,在新会话或已有会话中启动 Turn,并实时展示输出和结果。 +SwiftUI 移动控制台。App 通过 Runtime v1 HTTP 与 Session/Run SSE 连接 Mac Runtime,列出项目与持久会话,在新会话或已有会话中启动 Run,并实时展示输出和结果。 > Codex Remote 是独立开源项目,与 OpenAI 没有关联或背书关系。 @@ -8,20 +8,20 @@ SwiftUI 移动控制台。App 通过 WebSocket 连接 Relay,列出 Mac 上的 ## 当前能力 -- 真实 `URLSessionWebSocketTask` Relay Client。 -- Relay URL 本地设置和自动重连。 -- Simulator/iPhone 独立连接预设,以及 Relay `/status` 测试和全新重连。 +- 原生 `URLSession` Runtime v1 HTTP/SSE Client,按持久 Cursor 自动恢复。 +- 一次性配对、内存 Access Token、Keychain Refresh Token Rotation、退出和撤销恢复。 +- Simulator/iPhone 独立 Runtime Gateway 连接预设和健康检查。 - 多 Project 选择与刷新。 -- Project 下的 Codex Thread 列表、新会话和继续会话。 -- 选择历史会话时通过 `thread.read` 加载持久化 Turns,并在 Console 展示消息、命令输出和文件变更。 +- Project 下的持久 Session 列表、新会话和继续会话。 +- 通过 Session/Run 快照加载历史,并在 Console 展示消息、命令输出和文件变更。 - Prompt、执行、中断、assistant/stdout/stderr Console。 -- 完成、失败、中断结果和最近日志恢复。 +- 完成、失败、中断结果、Bootstrap 历史同步和前后台 Cursor 恢复。 - 在任务提交前显示 Mac Agent 的受限执行状态,并提供沙箱、授权策略和不可用宿主能力详情。 - 按项目读取 App Server 允许的执行档位,在原生菜单中选择只读、工作区或完整权限,并把选择同时应用于 Codex Thread 和 Turn。 - 使用 Apple Unified Logging、有限容量 JSONL、MetricKit 和诊断导出保存可脱敏的本地证据。 - 本地 Mock Relay 用于 UI 自动化和离线开发验证。 -当前运行模型是单用户、单 Mac、全局单 Turn。 +当前运行模型是单 Owner、单 Mac;iPhone 与 Mobile Web 可同时打开同一个 Session,并通过 Session SSE 发现彼此创建的 Run。 当前版本为 **0.0.1**,是首个最小可用快照。`0.x` 阶段继续快速迭代,后续变更按实际影响决定是否兼容,并在 [CHANGELOG.md](CHANGELOG.md) 中记录;此版本不代表兼容性冻结。 @@ -35,12 +35,12 @@ OOM/Jetsam 发生时不能依赖 App 终止回调完成上传。定位需要组 ## 运行 -1. 在 `mac-agent` 仓库执行 `./dev simulator`,启动 Simulator 专用的 Relay 与 Mac Agent。 +1. 从外部 Terminal 或 Codex Desktop 执行 `devrun crweb`,启动 source Runtime Gateway、Run Server 与 Mac Agent。 2. 使用 Xcode 打开 `CodexRemote.xcodeproj`。 3. 选择 iPhone Simulator 或真机,运行 `CodexRemote` Scheme。 -4. iPhone Simulator 默认连接 `ws://127.0.0.1:18767/ws/app`。 +4. iPhone Simulator 默认连接 `http://127.0.0.1:18874`。 -真机无法使用 Mac 的回环地址,并且必须与 Mac 位于可互通的可信局域网。真机运行脚本默认复用已经连接的 iPhone profile Relay 与 Mac Agent,提取 Mac 当前局域网地址,并写入 App 的 iPhone 连接配置。 +真机无法使用 Mac 的回环地址,并且必须与 Mac 位于可互通的可信局域网。真机运行脚本默认复用 source Runtime,提取 Mac 当前局域网地址,并写入 App 的 Runtime Gateway 配置。首次打开后,粘贴 `codex-remote pair` 或 `devrun crpair` 输出的一次性链接完成配对。 首次真机运行时,请在 Xcode 的 Signing & Capabilities 中选择自己的开发团队,并将 `org.codexremote.app` 改为自己拥有的唯一 Bundle ID;仓库不会提交个人 Team ID。诊断脚本可通过 `CODEX_REMOTE_BUNDLE_ID` 使用相同 Bundle ID。完成签名配置、真机配对并启动 iPhone profile 后,可以一条命令完成 Debug 构建、覆盖安装、连接配置和启动: @@ -56,25 +56,25 @@ OOM/Jetsam 发生时不能依赖 App 终止回调完成上传。定位需要组 脚本使用 Apple 官方的 `xcodebuild` 和 `devicectl`,效果相当于不附加 LLDB 调试器的 Xcode `Cmd + R`。App 启动后脚本还会通过 Relay `/status` 确认真机建立了新的连接。仅构建并安装、不自动打开 App 时传入 `--no-launch`。 -只有需要更新 Relay 或 Mac Agent 二进制、配置或运行时时,才从 Mac 桌面 Codex 或终端显式重启完整栈: +只有需要更新 Gateway、Run Server 或 Mac Agent 二进制、配置或运行时时,才从 Mac 桌面 Codex 或终端显式重启完整栈: ```bash ./scripts/run-device.sh --restart-stack ``` -脚本检测到自己运行在 iPhone Mac Agent 承载的 Turn 中时,会拒绝 `--restart-stack` 并自动采用延迟刷新:先完成设备预检和构建,等待 iPhone 应用 `turn.completed` 并发送 `turn.acknowledged`,再由无 `KeepAlive` 的一次性 `launchd` 任务安装和启动 App;任务结束后会卸载自身,禁止重复覆盖安装。延迟任务状态与日志位于 `.build/device-refresh/`。 +脚本检测到自己运行在匹配 Mac Agent 承载的 Run 中时,会拒绝替换 Runtime 或当前 iPhone App。先完成 Run,再从外部 Terminal 或 Codex Desktop 执行该命令;Runtime v1 不再使用旧 App WebSocket 的 `turn.acknowledged` 作为维护信号。 -连接已经运行的非局域网 Relay 时显式传入完整 App WebSocket 地址。此模式不会重启本机 Relay 或 Mac Agent: +连接已经运行的其他 Runtime Gateway 时显式传入完整 Origin。此模式不会重启本机 Runtime: ```bash -./scripts/run-device.sh --relay-url "wss://relay.example.com/ws/app" +./scripts/run-device.sh --runtime-url "https://runtime.example.com" ``` -远程 Relay 必须提供同源 `/status`,并由目标环境的 Mac Agent 建立连接。当前协议没有应用层鉴权;不要把无 TLS 的 Relay 直接暴露到公网,远程调试应使用可信私网、VPN,或配有 TLS 与访问控制的入口。 +远程入口必须提供版本化 Gateway、Runtime Auth 与 Runtime v1 契约。当前公网限流、Cursor 过期标准化和容量硬化尚未完成;不要直接把局域网入口暴露到公网。 仓库内提供 `$run-codex-remote-on-iphone` Skill,AI 可根据“真机运行 Codex Remote”“重启局域网调试并更新手机”或“使用指定 Relay 地址部署到 iPhone”等自然语言请求调用上述流程。也可以显式写出 Skill 名称避免歧义。 -打开 Settings 中的“连接”开关时,App 会从当前 WebSocket 地址推导同主机同端口的 HTTP `/status`,检查成功后保存该设备 profile 的地址并建立新的 WebSocket;关闭开关会断开当前连接并清除旧项目缓存。Simulator 与 iPhone 的地址分别保存,互不覆盖。 +打开 Settings 中的“连接”开关时,App 会检查 Gateway 健康状态,保存该设备 profile 的 Origin,并通过 Runtime Auth 恢复会话;关闭开关会停止 SSE 并清除工作区内存。Simulator 与 iPhone 的地址分别保存,互不覆盖。 Debug 构建也可从命令行验证: @@ -96,10 +96,10 @@ Apple 平台能力优先采用公开的 Apple 官方 SwiftUI、UIKit 与系统 ## 协议边界 -- 协议:`spec_version: "2.0"` -- App 入口:`/ws/app` -- App 发送:`project.list`、`execution.profile.list`、`thread.list`、`thread.read`、`turn.start`、`turn.interrupt`、`turn.acknowledged` -- App 接收:Agent 状态与执行能力、`execution.profile.snapshot`、Project/Thread Snapshot、`thread.detail`、Turn 事件 +- 协议:Runtime v1 HTTP、Session SSE 与 Run SSE。 +- App 使用一次性 native pairing、短期 Access Token 和 Keychain Refresh Rotation。 +- HTTP 提供 Project、Session、Run、Bootstrap、执行档位和取消;SSE 只承载可按持久 Cursor 恢复的事件。 +- 旧 `/ws/app` 不再属于 iPhone App 运行路径。 - SwiftUI View 不直接拼装或解析 JSON。 ## 结构 @@ -130,10 +130,9 @@ CodexRemote/ │ └── VoiceTranscriptionController.swift ├── Models/RelayModels.swift └── Services/ - ├── RelayService.swift - ├── RelayClient.swift - ├── RelayWireTypes.swift - └── MockRelayService.swift + ├── RelayService.swift # UI service boundary + ├── Runtime/ # Auth, HTTP/SSE, Cursor and Keychain + └── MockRelayService.swift # UI test only ``` ## 开源许可 diff --git a/docs/architecture.md b/docs/architecture.md index e8f818e..440db2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,14 +3,14 @@ This document defines the ownership boundaries for Codex Remote's iPhone app. Keep feature behavior inside these boundaries when extending the app. -> **Architecture status:** the running app currently uses Relay WebSocket communication and a -> local diagnostics pipeline. The accepted Admin Platform and SLS upload path is a target boundary -> until its queue, credentials, ingestion, and physical-device tests land. +> **Architecture status:** the running app uses authenticated Runtime v1 HTTP, Session/Run SSE, +> durable cursors, and a local diagnostics pipeline. The accepted Admin Platform and SLS upload +> path remains a target boundary until its queue, credentials, ingestion, and physical-device tests land. ## Data Flow ```text -RelayServiceProtocol +RelayServiceProtocol (RuntimeRelayService in production, Mock in UI tests) | v WorkspaceViewModel ----------> project, thread, connection, navigation state @@ -47,8 +47,19 @@ must never be published through `WorkspaceViewModel.objectWillChange`. | `ExecutionAccessView.swift` | Execution-profile warning and capability details | | `WorkspaceComponents.swift` | Small, workspace-wide presentational components | -`RelayClient.swift` owns connection lifecycle and event routing. Transport-only -payloads, errors and JSON date coding live in `Services/RelayWireTypes.swift`. +`Services/Runtime/RuntimeRelayService.swift` adapts Runtime Project/Session/Run state to the +workspace boundary. `RuntimeClient` owns authenticated HTTP/SSE, `RuntimeAuthSession` owns +single-flight rotation, and only the refresh credential is stored in Keychain. + +## Runtime Recovery Rules + +- PostgreSQL-backed Session and Run snapshots are authoritative; an open SSE connection is not. +- Session and Run cursors are separate and scoped by Runtime Origin plus resource ID. +- On selection, foreground recovery, or reconnect, load the snapshot first, advance to its durable + boundary, then resume SSE after that cursor. +- Access Tokens remain in memory. Native Refresh Tokens use the native Auth contract and + `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`; they never enter UserDefaults or diagnostics. +- Runtime URLs, authorization headers, pairing codes, prompts, responses and source paths are not logged. ## Input And Keyboard Rules diff --git a/scripts/complete-device-refresh.sh b/scripts/complete-device-refresh.sh deleted file mode 100755 index 265dda7..0000000 --- a/scripts/complete-device-refresh.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -DEVICE_ID="" -DEVICE_NAME="" -APP_PATH="" -BUNDLE_ID="" -RELAY_URL="" -STATUS_URL="" -PREVIOUS_TURN_ACK="" -PREVIOUS_APP_CONNECTION_ID="" -RESULT_FILE="" -JOB_LABEL="" -JOB_PLIST="" -ACK_TIMEOUT_SECONDS="${CODEX_REMOTE_ACK_TIMEOUT_SECONDS:-180}" -TERMINAL_RESULT_WRITTEN=0 - -status_field() { - local field="$1" - curl -fsS "$STATUS_URL" 2>/dev/null \ - | plutil -extract "$field" raw -o - - 2>/dev/null \ - || true -} - -write_result() { - local status="$1" - local message="$2" - local temporary="$RESULT_FILE.tmp.$$" - { - echo "status=$status" - echo "message=$message" - echo "updated_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - } >"$temporary" - mv "$temporary" "$RESULT_FILE" - case "$status" in - completed|cancelled|failed) TERMINAL_RESULT_WRITTEN=1 ;; - esac -} - -cleanup() { - local exit_code=$? - trap - EXIT - - if [[ "$exit_code" -ne 0 && "$TERMINAL_RESULT_WRITTEN" -eq 0 && -n "$RESULT_FILE" ]]; then - write_result "failed" "Deferred refresh failed; inspect the matching log for details." - fi - if [[ -n "$JOB_PLIST" ]]; then - rm -f "$JOB_PLIST" - fi - if [[ "$JOB_LABEL" == com.ai-coding-remote.device-refresh.* ]]; then - launchctl bootout "gui/$(id -u)/$JOB_LABEL" >/dev/null 2>&1 || true - fi - - exit "$exit_code" -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --device) DEVICE_ID="$2"; shift 2 ;; - --device-name) DEVICE_NAME="$2"; shift 2 ;; - --app-path) APP_PATH="$2"; shift 2 ;; - --bundle-id) BUNDLE_ID="$2"; shift 2 ;; - --relay-url) RELAY_URL="$2"; shift 2 ;; - --status-url) STATUS_URL="$2"; shift 2 ;; - --previous-turn-ack) PREVIOUS_TURN_ACK="$2"; shift 2 ;; - --previous-app-connection-id) PREVIOUS_APP_CONNECTION_ID="$2"; shift 2 ;; - --result-file) RESULT_FILE="$2"; shift 2 ;; - --job-label) JOB_LABEL="$2"; shift 2 ;; - --job-plist) JOB_PLIST="$2"; shift 2 ;; - *) echo "error: unknown option: $1" >&2; exit 2 ;; - esac -done - -if [[ -z "$DEVICE_ID" || -z "$APP_PATH" || -z "$BUNDLE_ID" || -z "$RELAY_URL" || -z "$STATUS_URL" || -z "$RESULT_FILE" ]]; then - echo "error: incomplete deferred refresh request" >&2 - exit 2 -fi - -trap cleanup EXIT - -mkdir -p "$(dirname "$RESULT_FILE")" -write_result "waiting" "Waiting for the iPhone to acknowledge Turn completion." - -acknowledged_turn="" -acknowledged_status="" -for ((attempt = 0; attempt < ACK_TIMEOUT_SECONDS * 10; attempt++)); do - acknowledged_turn="$(status_field last_turn_acknowledged)" - acknowledged_status="$(status_field last_turn_acknowledged_status)" - if [[ -n "$acknowledged_turn" && "$acknowledged_turn" != "$PREVIOUS_TURN_ACK" ]]; then - break - fi - sleep 0.1 -done - -if [[ -z "$acknowledged_turn" || "$acknowledged_turn" == "$PREVIOUS_TURN_ACK" ]]; then - write_result "failed" "Timed out waiting for Turn completion acknowledgement." - echo "error: timed out waiting for Turn completion acknowledgement" >&2 - exit 1 -fi -if [[ "$acknowledged_status" != "completed" ]]; then - write_result "cancelled" "Turn ended with status $acknowledged_status; the iPhone was not refreshed." - echo "==> Turn ended with $acknowledged_status; cancelling deferred refresh" - exit 0 -fi - -write_result "running" "Installing and launching Codex Remote on $DEVICE_NAME." -echo "==> Installing $BUNDLE_ID on $DEVICE_NAME after Turn $acknowledged_turn completed" -xcrun devicectl device install app --device "$DEVICE_ID" "$APP_PATH" - -echo "==> Launching $BUNDLE_ID on $DEVICE_NAME" -xcrun devicectl device process launch \ - --device "$DEVICE_ID" \ - --terminate-existing \ - "$BUNDLE_ID" \ - --relay-url "$RELAY_URL" - -app_connected="false" -app_connection_id="" -for ((attempt = 0; attempt < 100; attempt++)); do - app_connected="$(status_field app_connected)" - app_connection_id="$(status_field app_connection_id)" - if [[ "$app_connected" == "true" && ( -z "$PREVIOUS_APP_CONNECTION_ID" || "$app_connection_id" != "$PREVIOUS_APP_CONNECTION_ID" ) ]]; then - write_result "completed" "Codex Remote refreshed and reconnected to the Relay." - echo "==> iPhone app connected to Relay" - exit 0 - fi - sleep 0.1 -done - -write_result "failed" "The refreshed app did not establish a new Relay connection within 10 seconds." -echo "error: refreshed app did not establish a new Relay connection" >&2 -exit 1 diff --git a/scripts/run-device.sh b/scripts/run-device.sh index 4eb4cc4..b64b891 100755 --- a/scripts/run-device.sh +++ b/scripts/run-device.sh @@ -4,34 +4,31 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -MAC_AGENT_DEV="$APP_DIR/../mac-agent/dev" PROJECT_PATH="$APP_DIR/CodexRemote.xcodeproj" SCHEME="CodexRemote" CONFIGURATION="Debug" DERIVED_DATA_PATH="${CODEX_REMOTE_DERIVED_DATA_PATH:-$APP_DIR/.build/DeviceDerivedData}" DEVICE_SELECTOR="" -RELAY_URL="" +RUNTIME_URL="" RESTART_STACK=0 -DEFER_REFRESH=0 SHOULD_LAUNCH=1 -LOCAL_RELAY_PORT="${CODEX_REMOTE_IPHONE_RELAY_PORT:-18768}" +LOCAL_GATEWAY_PORT="${CODEX_REMOTE_RUNTIME_GATEWAY_PORT:-18874}" +LOCAL_RUN_SERVER_PORT="${CODEX_REMOTE_RUN_SERVER_PORT:-18875}" usage() { cat <<'EOF' Build, install, configure, and launch Codex Remote on a paired physical iPhone. -The existing iPhone Relay and Mac Agent are reused by default. +The existing Runtime Gateway, Run Server, and Mac Agent are reused by default. Usage: - ./scripts/run-device.sh [--device ] [--relay-url ] - [--restart-stack] [--defer] [--no-launch] + ./scripts/run-device.sh [--device ] [--runtime-url ] + [--restart-stack] [--no-launch] Options: --device Select a device by name, CoreDevice identifier, or UDID. - --relay-url Use an existing ws:// or wss:// Relay /ws/app endpoint. - --restart-stack Explicitly restart the local iPhone Relay and Mac Agent. - This is rejected when called from that Agent itself. - --defer Build and preflight now, then install and launch only after - the iPhone acknowledges the current Turn's completion. + --runtime-url Use an existing http:// or https:// Runtime Gateway. + --restart-stack Rebuild and restart the source Runtime through devrun crweb. + This is rejected when called from the matching Agent itself. --no-launch Install the app without launching it. -h, --help Show this help. EOF @@ -66,28 +63,6 @@ status_field() { || true } -write_one_shot_job_plist() { - local plist_path="$1" - local job_label="$2" - local log_path="$3" - shift 3 - - plutil -create xml1 "$plist_path" - plutil -insert Label -string "$job_label" "$plist_path" - plutil -insert ProgramArguments -array "$plist_path" - - local argument_index=0 - local argument - for argument in "$@"; do - plutil -insert "ProgramArguments.$argument_index" -string "$argument" "$plist_path" - argument_index=$((argument_index + 1)) - done - - plutil -insert ProcessType -string Background "$plist_path" - plutil -insert StandardOutPath -string "$log_path" "$plist_path" - plutil -insert StandardErrorPath -string "$log_path" "$plist_path" -} - running_inside_iphone_agent() { case "${CODEX_REMOTE_EXECUTION_ORIGIN:-auto}" in iphone-agent) return 0 ;; @@ -104,7 +79,7 @@ running_inside_iphone_agent() { local parent_pid while [[ "$candidate_pid" =~ ^[0-9]+$ && "$candidate_pid" -gt 1 ]]; do command="$(ps -p "$candidate_pid" -o command= 2>/dev/null || true)" - if [[ "$command" == *"mac-agent serve"* && "$command" == *":$LOCAL_RELAY_PORT/ws/agent"* ]]; then + if [[ "$command" == *"mac-agent serve"* && "$command" == *":$LOCAL_RUN_SERVER_PORT/ws/agent"* ]]; then return 0 fi parent_pid="$(ps -p "$candidate_pid" -o ppid= 2>/dev/null | tr -d '[:space:]')" @@ -121,19 +96,15 @@ while [[ $# -gt 0 ]]; do DEVICE_SELECTOR="$2" shift 2 ;; - --relay-url) - [[ $# -ge 2 ]] || { echo "error: --relay-url requires a value" >&2; exit 2; } - RELAY_URL="$2" + --runtime-url) + [[ $# -ge 2 ]] || { echo "error: $1 requires a value" >&2; exit 2; } + RUNTIME_URL="$2" shift 2 ;; --restart-stack) RESTART_STACK=1 shift ;; - --defer) - DEFER_REFRESH=1 - shift - ;; --no-launch) SHOULD_LAUNCH=0 shift @@ -150,23 +121,18 @@ while [[ $# -gt 0 ]]; do esac done -if [[ "$RESTART_STACK" -eq 1 && -n "$RELAY_URL" ]]; then - echo "error: --restart-stack and --relay-url are mutually exclusive" >&2 - exit 2 -fi -if [[ "$DEFER_REFRESH" -eq 1 && "$SHOULD_LAUNCH" -eq 0 ]]; then - echo "error: --defer cannot be combined with --no-launch" >&2 +if [[ "$RESTART_STACK" -eq 1 && -n "$RUNTIME_URL" ]]; then + echo "error: --restart-stack and --runtime-url are mutually exclusive" >&2 exit 2 fi INSIDE_IPHONE_AGENT=0 if running_inside_iphone_agent; then INSIDE_IPHONE_AGENT=1 - DEFER_REFRESH=1 fi -if [[ "$INSIDE_IPHONE_AGENT" -eq 1 && "$RESTART_STACK" -eq 1 ]]; then - echo "error: refusing to restart the iPhone Mac Agent from a Turn hosted by that Agent" >&2 - echo "Run this command from Codex Desktop or Terminal when a full stack restart is required." >&2 +if [[ "$INSIDE_IPHONE_AGENT" -eq 1 ]]; then + echo "error: refusing to replace the iPhone client or Runtime from a Turn hosted by the matching Agent" >&2 + echo "Finish this Turn, then run the command from Codex Desktop or Terminal." >&2 exit 1 fi @@ -176,59 +142,40 @@ for command_name in curl plutil xcodebuild xcrun; do exit 1 fi done -if [[ "$DEFER_REFRESH" -eq 1 ]] && ! command -v launchctl >/dev/null 2>&1; then - echo "error: launchctl is required for a deferred device refresh" >&2 - exit 1 -fi - DEVICES_JSON="$(mktemp /tmp/codexremote-devices.XXXXXX.json)" -STACK_OUTPUT="$(mktemp /tmp/codexremote-stack.XXXXXX.log)" -trap 'rm -f "$DEVICES_JSON" "$STACK_OUTPUT"' EXIT +trap 'rm -f "$DEVICES_JSON"' EXIT if [[ "$RESTART_STACK" -eq 1 ]]; then - if [[ ! -x "$MAC_AGENT_DEV" ]]; then - echo "error: Mac Agent development script is missing or not executable: $MAC_AGENT_DEV" >&2 - exit 1 - fi - echo "==> Restarting the iPhone Relay and Mac Agent stack" - "$MAC_AGENT_DEV" iphone | tee "$STACK_OUTPUT" - RELAY_URL="$(awk '/^LAN App WS:[[:space:]]*/ { print $NF; exit }' "$STACK_OUTPUT")" -elif [[ -z "$RELAY_URL" ]]; then - LOCAL_STATUS_URL="http://127.0.0.1:$LOCAL_RELAY_PORT/status" - if [[ "$(status_field "$LOCAL_STATUS_URL" agent_connected)" != "true" ]]; then - echo "error: the local iPhone Mac Agent is not connected at $LOCAL_STATUS_URL" >&2 - echo "Start it externally with: ./scripts/run-device.sh --restart-stack" >&2 + if ! command -v devrun >/dev/null 2>&1; then + echo "error: devrun is required for --restart-stack" >&2 exit 1 fi + echo "==> Rebuilding and restarting the source Runtime stack" + devrun crweb +fi + +if [[ -z "$RUNTIME_URL" ]]; then LAN_IPV4="$(resolve_lan_ipv4 || true)" if [[ -z "$LAN_IPV4" ]]; then echo "error: no active LAN IPv4 address is available" >&2 exit 1 fi - RELAY_URL="ws://$LAN_IPV4:$LOCAL_RELAY_PORT/ws/app" - echo "==> Reusing the running iPhone Relay and Mac Agent" + RUNTIME_URL="http://$LAN_IPV4:$LOCAL_GATEWAY_PORT" + echo "==> Using the source Runtime Gateway" else - echo "==> Using the provided Relay without restarting local services" + echo "==> Using the provided Runtime Gateway" fi -if [[ ! "$RELAY_URL" =~ ^wss?://[^/@]+/ws/app$ ]]; then - echo "error: Relay URL must be an unauthenticated ws:// or wss:// URL ending in /ws/app" >&2 +if [[ ! "$RUNTIME_URL" =~ ^https?://[^/@]+/?$ ]]; then + echo "error: Runtime URL must be an http:// or https:// Gateway origin without credentials or a path" >&2 exit 2 fi -echo "==> Using Relay URL: $RELAY_URL" -case "$RELAY_URL" in - ws://*) RELAY_STATUS_URL="http://${RELAY_URL#ws://}" ;; - wss://*) RELAY_STATUS_URL="https://${RELAY_URL#wss://}" ;; -esac -RELAY_STATUS_URL="${RELAY_STATUS_URL%/ws/app}/status" - -if [[ "$(status_field "$RELAY_STATUS_URL" agent_connected)" != "true" ]]; then - echo "error: the selected Relay has no connected Mac Agent: $RELAY_STATUS_URL" >&2 - exit 1 -fi -if [[ "$DEFER_REFRESH" -eq 1 && "$(status_field "$RELAY_STATUS_URL" app_connected)" != "true" ]]; then - echo "error: deferred refresh requires the current iPhone App to be connected" >&2 +RUNTIME_URL="${RUNTIME_URL%/}" +GATEWAY_STATUS_URL="$RUNTIME_URL/gateway/healthz" +echo "==> Using Runtime URL: $RUNTIME_URL" +if [[ "$(status_field "$GATEWAY_STATUS_URL" status)" != "ok" ]]; then + echo "error: Runtime Gateway is not healthy: $GATEWAY_STATUS_URL" >&2 exit 1 fi @@ -293,55 +240,6 @@ if [[ ! -d "$APP_PATH" ]]; then fi BUNDLE_ID="$(plutil -extract CFBundleIdentifier raw -o - "$APP_PATH/Info.plist")" -if [[ "$DEFER_REFRESH" -eq 1 ]]; then - PREVIOUS_TURN_ACK="$(status_field "$RELAY_STATUS_URL" last_turn_acknowledged)" - PREVIOUS_APP_CONNECTION_ID="$(status_field "$RELAY_STATUS_URL" app_connection_id)" - JOB_ID="$(date +%s)-$$" - JOB_LABEL="com.ai-coding-remote.device-refresh.$JOB_ID" - RESULT_DIR="${CODEX_REMOTE_REFRESH_RESULT_DIR:-$APP_DIR/.build/device-refresh}" - RESULT_FILE="$RESULT_DIR/$JOB_ID.status" - LOG_FILE="$RESULT_DIR/$JOB_ID.log" - JOB_PLIST="$RESULT_DIR/$JOB_ID.plist" - mkdir -p "$RESULT_DIR" - - write_one_shot_job_plist \ - "$JOB_PLIST" \ - "$JOB_LABEL" \ - "$LOG_FILE" \ - "$SCRIPT_DIR/complete-device-refresh.sh" \ - --device "$CORE_DEVICE_ID" \ - --device-name "$DEVICE_NAME" \ - --app-path "$APP_PATH" \ - --bundle-id "$BUNDLE_ID" \ - --relay-url "$RELAY_URL" \ - --status-url "$RELAY_STATUS_URL" \ - --previous-turn-ack "$PREVIOUS_TURN_ACK" \ - --previous-app-connection-id "$PREVIOUS_APP_CONNECTION_ID" \ - --result-file "$RESULT_FILE" \ - --job-label "$JOB_LABEL" \ - --job-plist "$JOB_PLIST" - - LAUNCHD_DOMAIN="gui/$(id -u)" - if ! launchctl bootstrap "$LAUNCHD_DOMAIN" "$JOB_PLIST"; then - rm -f "$JOB_PLIST" - echo "error: failed to load deferred refresh job $JOB_LABEL" >&2 - exit 1 - fi - if ! launchctl kickstart "$LAUNCHD_DOMAIN/$JOB_LABEL"; then - launchctl bootout "$LAUNCHD_DOMAIN/$JOB_LABEL" >/dev/null 2>&1 || true - rm -f "$JOB_PLIST" - echo "error: failed to start deferred refresh job $JOB_LABEL" >&2 - exit 1 - fi - - echo "==> Environment ready; deferred iPhone refresh scheduled" - echo "Deferred log: $LOG_FILE" - echo "Deferred status: $RESULT_FILE" - echo "Complete the current Turn now. Installation starts after the iPhone acknowledges completion." - exit 0 -fi - -PREVIOUS_APP_CONNECTION_ID="$(status_field "$RELAY_STATUS_URL" app_connection_id)" echo "==> Installing $BUNDLE_ID on $DEVICE_NAME" xcrun devicectl device install app --device "$CORE_DEVICE_ID" "$APP_PATH" @@ -351,22 +249,7 @@ if [[ "$SHOULD_LAUNCH" -eq 1 ]]; then --device "$CORE_DEVICE_ID" \ --terminate-existing \ "$BUNDLE_ID" \ - --relay-url "$RELAY_URL" - - app_connected="false" - for ((attempt = 0; attempt < 100; attempt++)); do - app_connected="$(status_field "$RELAY_STATUS_URL" app_connected)" - app_connection_id="$(status_field "$RELAY_STATUS_URL" app_connection_id)" - if [[ "$app_connected" == "true" && ( -z "$PREVIOUS_APP_CONNECTION_ID" || "$app_connection_id" != "$PREVIOUS_APP_CONNECTION_ID" ) ]]; then - echo "==> iPhone app connected to Relay" - break - fi - sleep 0.1 - done - if [[ "$app_connected" != "true" || ( -n "$PREVIOUS_APP_CONNECTION_ID" && "$app_connection_id" == "$PREVIOUS_APP_CONNECTION_ID" ) ]]; then - echo "error: the app launched but did not establish a new Relay connection within 10 seconds" >&2 - exit 1 - fi + --runtime-url "$RUNTIME_URL" fi echo "==> Done" diff --git a/scripts/tests/run-device-test.sh b/scripts/tests/run-device-test.sh index 3d4c888..a57a190 100755 --- a/scripts/tests/run-device-test.sh +++ b/scripts/tests/run-device-test.sh @@ -7,26 +7,13 @@ APP_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" TEMP_DIR="$(mktemp -d /tmp/codexremote-device-test.XXXXXX)" FAKE_BIN="$TEMP_DIR/bin" COMMAND_LOG="$TEMP_DIR/commands.log" -STATUS_FILE="$TEMP_DIR/status.json" DERIVED_DATA_PATH="$TEMP_DIR/DerivedData" -RESULT_DIR="$TEMP_DIR/results" -JOB_PLIST_RECORD="$TEMP_DIR/job-plist-path" -JOB_START_LOG="$TEMP_DIR/job-starts.log" -mkdir -p "$FAKE_BIN" "$RESULT_DIR" +mkdir -p "$FAKE_BIN" trap 'rm -rf "$TEMP_DIR"' EXIT -write_status() { - local ack="$1" - local ack_status="$2" - local connection_id="$3" - cat >"$STATUS_FILE" <"$FAKE_BIN/curl" <<'EOF' #!/bin/bash -cat "$CODEX_REMOTE_TEST_STATUS_FILE" +echo '{"status":"ok","contract_version":"run-server-v1"}' EOF cat >"$FAKE_BIN/route" <<'EOF' @@ -49,12 +36,7 @@ cat >"$FAKE_BIN/xcodebuild" <<'EOF' echo "xcodebuild $*" >>"$CODEX_REMOTE_TEST_COMMAND_LOG" derived="" while [[ $# -gt 0 ]]; do - if [[ "$1" == "-derivedDataPath" ]]; then - derived="$2" - shift 2 - else - shift - fi + if [[ "$1" == "-derivedDataPath" ]]; then derived="$2"; shift 2; else shift; fi done app="$derived/Build/Products/Debug-iphoneos/CodexRemote.app" mkdir -p "$app" @@ -66,110 +48,48 @@ cat >"$FAKE_BIN/xcrun" <<'EOF' #!/bin/bash echo "xcrun $*" >>"$CODEX_REMOTE_TEST_COMMAND_LOG" if [[ "$1 $2 $3" == "devicectl list devices" ]]; then - output="" while [[ $# -gt 0 ]]; do - if [[ "$1" == "--json-output" ]]; then - output="$2" - break - fi + if [[ "$1" == "--json-output" ]]; then output="$2"; break; fi shift done cat >"$output" <<'JSON' {"result":{"devices":[{"hardwareProperties":{"platform":"iOS","reality":"physical","udid":"device-udid"},"connectionProperties":{"pairingState":"paired"},"deviceProperties":{"name":"Test iPhone"},"identifier":"core-device-id"}]}} JSON - exit 0 -fi -if [[ "$*" == *"device process launch"* ]]; then - cat >"$CODEX_REMOTE_TEST_STATUS_FILE.tmp" <<'JSON' -{"app_connected":true,"agent_connected":true,"app_connection_id":2,"last_turn_acknowledged":"turn-new","last_turn_acknowledged_status":"completed"} -JSON - mv "$CODEX_REMOTE_TEST_STATUS_FILE.tmp" "$CODEX_REMOTE_TEST_STATUS_FILE" fi EOF -cat >"$FAKE_BIN/launchctl" <<'EOF' +cat >"$FAKE_BIN/devrun" <<'EOF' #!/bin/bash -echo "launchctl $*" >>"$CODEX_REMOTE_TEST_COMMAND_LOG" -case "$1" in - bootstrap) - echo "$3" >"$CODEX_REMOTE_TEST_JOB_PLIST_RECORD" - ;; - kickstart) - job_plist="$(cat "$CODEX_REMOTE_TEST_JOB_PLIST_RECORD")" - argument_count="$(/usr/bin/plutil -extract ProgramArguments raw -o - "$job_plist")" - arguments=() - for ((index = 0; index < argument_count; index++)); do - arguments+=("$(/usr/bin/plutil -extract "ProgramArguments.$index" raw -o - "$job_plist")") - done - stdout_path="$(/usr/bin/plutil -extract StandardOutPath raw -o - "$job_plist")" - echo started >>"$CODEX_REMOTE_TEST_JOB_START_LOG" - "${arguments[@]}" >"$stdout_path" 2>&1 & - ;; -esac +echo "devrun $*" >>"$CODEX_REMOTE_TEST_COMMAND_LOG" EOF chmod +x "$FAKE_BIN"/* export PATH="$FAKE_BIN:/usr/bin:/bin:/usr/sbin:/sbin" -export CODEX_REMOTE_TEST_STATUS_FILE="$STATUS_FILE" export CODEX_REMOTE_TEST_COMMAND_LOG="$COMMAND_LOG" export CODEX_REMOTE_DERIVED_DATA_PATH="$DERIVED_DATA_PATH" -export CODEX_REMOTE_REFRESH_RESULT_DIR="$RESULT_DIR" -export CODEX_REMOTE_TEST_JOB_PLIST_RECORD="$JOB_PLIST_RECORD" -export CODEX_REMOTE_TEST_JOB_START_LOG="$JOB_START_LOG" -write_status "turn-old" "completed" 1 CODEX_REMOTE_EXECUTION_ORIGIN=external "$APP_DIR/scripts/run-device.sh" >"$TEMP_DIR/default.out" -grep -q "Reusing the running iPhone Relay and Mac Agent" "$TEMP_DIR/default.out" +grep -q "Using Runtime URL: http://192.168.1.25:18874" "$TEMP_DIR/default.out" grep -q "device install app" "$COMMAND_LOG" -grep -q "device process launch" "$COMMAND_LOG" -if grep -q "mac-agent/dev" "$COMMAND_LOG"; then - echo "default deployment restarted the Mac Agent" >&2 +grep -q "device process launch.*--runtime-url http://192.168.1.25:18874" "$COMMAND_LOG" +if grep -q "devrun crweb" "$COMMAND_LOG"; then + echo "default deployment restarted the Runtime" >&2 exit 1 fi -: >"$COMMAND_LOG" -write_status "turn-old" "completed" 1 -CODEX_REMOTE_EXECUTION_ORIGIN=iphone-agent "$APP_DIR/scripts/run-device.sh" >"$TEMP_DIR/deferred.out" -grep -q "deferred iPhone refresh scheduled" "$TEMP_DIR/deferred.out" -grep -q "launchctl bootstrap" "$COMMAND_LOG" -grep -q "launchctl kickstart" "$COMMAND_LOG" -if grep -q "launchctl submit" "$COMMAND_LOG"; then - echo "deferred deployment used a keepalive submitted job" >&2 - exit 1 -fi -job_plist="$(cat "$JOB_PLIST_RECORD")" -if /usr/bin/plutil -extract KeepAlive raw -o - "$job_plist" >/dev/null 2>&1; then - echo "deferred deployment configured KeepAlive" >&2 - exit 1 -fi -if [[ "$(/usr/bin/plutil -extract ProgramArguments.0 raw -o - "$job_plist")" != "$APP_DIR/scripts/complete-device-refresh.sh" ]]; then - echo "deferred deployment configured the wrong helper" >&2 - exit 1 -fi -if grep -q "device install app\|device process launch" "$COMMAND_LOG"; then - echo "deferred deployment touched the iPhone before Turn acknowledgement" >&2 +if CODEX_REMOTE_EXECUTION_ORIGIN=iphone-agent "$APP_DIR/scripts/run-device.sh" >"$TEMP_DIR/self.out" 2>&1; then + echo "self replacement was not rejected" >&2 exit 1 fi +grep -q "refusing to replace the iPhone client or Runtime" "$TEMP_DIR/self.out" -if CODEX_REMOTE_EXECUTION_ORIGIN=iphone-agent "$APP_DIR/scripts/run-device.sh" --restart-stack >"$TEMP_DIR/restart.out" 2>&1; then - echo "self-restart was not rejected" >&2 +: >"$COMMAND_LOG" +CODEX_REMOTE_EXECUTION_ORIGIN=external "$APP_DIR/scripts/run-device.sh" --restart-stack --no-launch >"$TEMP_DIR/restart.out" +grep -q '^devrun crweb$' "$COMMAND_LOG" +grep -q "device install app" "$COMMAND_LOG" +if grep -q "device process launch" "$COMMAND_LOG"; then + echo "--no-launch started the app" >&2 exit 1 fi -grep -q "refusing to restart the iPhone Mac Agent" "$TEMP_DIR/restart.out" - -write_status "turn-new" "completed" 1 -refresh_status="$(find "$RESULT_DIR" -name '*.status' -print -quit)" -for ((attempt = 0; attempt < 40; attempt++)); do - if [[ -n "$refresh_status" ]] && grep -q "status=completed" "$refresh_status"; then - break - fi - sleep 0.1 - refresh_status="$(find "$RESULT_DIR" -name '*.status' -print -quit)" -done -grep -q "status=completed" "$refresh_status" -[[ "$(grep -c '^xcrun device install app' "$COMMAND_LOG")" -eq 1 ]] -[[ "$(grep -c '^xcrun device process launch' "$COMMAND_LOG")" -eq 1 ]] -[[ "$(wc -l <"$JOB_START_LOG" | tr -d '[:space:]')" -eq 1 ]] -grep -q "launchctl bootout" "$COMMAND_LOG" echo "run-device tests passed"