From ade37baf07aeea32a9e74be3fb9c20d1d6963fd0 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 20 May 2026 16:58:24 +0800 Subject: [PATCH] feat: add run profile support --- .../RxCodeChatKit/ChatMessageListView.swift | 7 - .../RxCodeChatKit/MessageListView.swift | 75 +- .../RunProfile/RunTaskExecutor.swift | 10 +- .../Sources/RxCodeSync/Protocol/Payload.swift | 263 +++++- .../RxCodeSync/Transport/RelayClient.swift | 31 +- .../Tests/RxCodeSyncTests/PayloadTests.swift | 36 + RxCode/App/AppState.swift | 327 ++++++- RxCode/Services/LocalWebProxyServer.swift | 633 ++++++++++++++ RxCode/Services/MobileSyncService.swift | 55 +- RxCode/Services/RunProfile/RunService.swift | 55 +- RxCode/Views/Sidebar/BriefingView.swift | 20 +- RxCodeMobile/State/MobileAppState.swift | 201 ++++- RxCodeMobile/Views/MobileBriefingView.swift | 20 +- RxCodeMobile/Views/MobileChatView.swift | 54 +- .../Views/MobileInAppBrowserView.swift | 798 ++++++++++++++++++ RxCodeMobile/Views/SessionsList.swift | 331 ++++++++ relay-server/k8s/ingress.yaml | 4 +- relay-server/relay.go | 5 +- 18 files changed, 2827 insertions(+), 98 deletions(-) create mode 100644 RxCode/Services/LocalWebProxyServer.swift create mode 100644 RxCodeMobile/Views/MobileInAppBrowserView.swift diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift index 18c3ff6..8e4678a 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -34,16 +34,9 @@ public struct ChatMessageListView: View { extension View { func chatMessageListRowStyle() -> some View { - #if os(macOS) - padding(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) - .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - #else listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) - #endif } } diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 04fd839..21af677 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -23,52 +23,51 @@ struct MessageListView: View { var body: some View { ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - messageRows(settledItems[...]) - - // Streaming view is outside the settled rows — text deltas don't - // affect settled layout. - if !windowState.focusMode { - StreamingMessageView { - rebuildSettledItems() - if anchor.isNearBottom { scrollToBottomDebounced(proxy) } - } - // Suppress layout animations when switching sessions so the pulse indicator - // doesn't visually jump as StreamingMessageView changes height. - .animation(.none, value: windowState.currentSessionId) - .chatMessageListRowStyle() - } - - if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision { - // Hide the spinner/dots while the CLI is paused waiting on the - // user's plan decision — the model isn't actually generating - // tokens, so showing "in progress" is misleading. - HStack(alignment: .top, spacing: 0) { - StreamingIndicatorView( - isThinking: chatBridge.isThinking, - startDate: chatBridge.streamingStartDate, - agentProvider: chatBridge.agentProvider, - outputTokens: chatBridge.liveOutputTokens - ) - Spacer(minLength: 40) - } - .chatMessageListRowStyle() + List { + messageRows(settledItems[...]) + + // Streaming view is outside VStack — text deltas don't affect settled layout + if !windowState.focusMode { + StreamingMessageView { + rebuildSettledItems() + if anchor.isNearBottom { scrollToBottomDebounced(proxy) } } + // Suppress layout animations when switching sessions so the pulse indicator + // doesn't visually jump as StreamingMessageView changes height. + .animation(.none, value: windowState.currentSessionId) + .chatMessageListRowStyle() + } - if !chatBridge.isStreaming && !settledItems.isEmpty { - WebPreviewButton(messages: settledItems) - .id("web-preview") - .chatMessageListRowStyle() + if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision { + // Hide the spinner/dots while the CLI is paused waiting on the + // user's plan decision — the model isn't actually generating + // tokens, so showing "in progress" is misleading. + HStack(alignment: .top, spacing: 0) { + StreamingIndicatorView( + isThinking: chatBridge.isThinking, + startDate: chatBridge.streamingStartDate, + agentProvider: chatBridge.agentProvider, + outputTokens: chatBridge.liveOutputTokens + ) + Spacer(minLength: 40) } + .chatMessageListRowStyle() + } - Color.clear.frame(height: 1) - .id(Self.bottomAnchorID) + if !chatBridge.isStreaming && !settledItems.isEmpty { + WebPreviewButton(messages: settledItems) + .id("web-preview") .chatMessageListRowStyle() } - .frame(maxWidth: .infinity, alignment: .leading) + + Color.clear.frame(height: 1) + .id(Self.bottomAnchorID) + .chatMessageListRowStyle() } + .listStyle(.plain) .contentMargins(.top, 16, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) .opacity(isSessionReady ? 1 : 0) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollSample.self) { geo in diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index 94d38a6..1400d69 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -68,7 +68,11 @@ public enum RunTaskExecutor { /// Note: banner text is intentionally plain ASCII without SGR styling. /// SwiftTerm renders SGR 2 (dim) as low-contrast on dark backgrounds, /// which made the banners invisible in earlier versions. - public static func buildWrapperScript(profile: RunProfile, projectPath: String) -> String { + public static func buildWrapperScript( + profile: RunProfile, + projectPath: String, + outputLogPath: String? = nil + ) -> String { let cwd = resolveWorkingDirectory(profile.bash.workingDirectory, projectPath: projectPath) let afterSteps = profile.afterSteps.filter { !$0.command.trimmingCharacters(in: .whitespaces).isEmpty } @@ -86,6 +90,10 @@ public enum RunTaskExecutor { // weirdness) can't leak back here. lines.append("__rxcode_user_path=$(/bin/zsh -ic 'printf %s \"$PATH\"' 2>/dev/null)") lines.append("[ -n \"$__rxcode_user_path\" ] && export PATH=\"$__rxcode_user_path\"") + if let outputLogPath { + lines.append(": > \(shellEscape(outputLogPath))") + lines.append("exec > >(tee -a \(shellEscape(outputLogPath))) 2>&1") + } lines.append("") // Trap is registered first so it fires for any subsequent failure — // including a failing `cd` into a stale working directory. diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index e9df608..c458c2a 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -37,11 +37,61 @@ public enum Payload: Sendable { case folderTreeResult(FolderTreeResultPayload) case createProjectRequest(CreateProjectRequestPayload) case createProjectResult(CreateProjectResultPayload) + case runProfileMutationRequest(RunProfileMutationRequestPayload) + case runProfileResult(RunProfileResultPayload) + case runProfileRunRequest(RunProfileRunRequestPayload) + case runProfileStopRequest(RunProfileStopRequestPayload) + case runTaskUpdate(RunTaskUpdatePayload) case ping(PingPayload) case pong(PongPayload) case unknown(type: String) } +public extension Payload { + var logName: String { + switch self { + case .pairRequest: return "pair_request" + case .pairAck: return "pair_ack" + case .unpair: return "unpair" + case .apnsToken: return "apns_token" + case .requestSnapshot: return "request_snapshot" + case .snapshot: return "snapshot" + case .settingsUpdate: return "settings_update" + case .sessionUpdate: return "session_update" + case .subscribeSession: return "subscribe_session" + case .userMessage: return "user_message" + case .cancelStream: return "cancel_stream" + case .removeQueuedMessage: return "remove_queued_message" + case .newSessionRequest: return "new_session_request" + case .threadActionRequest: return "thread_action_request" + case .loadMoreMessages: return "load_more_messages" + case .moreMessages: return "more_messages" + case .searchRequest: return "search_request" + case .searchResults: return "search_results" + case .notification: return "notification" + case .permissionRequest: return "permission_request" + case .permissionResponse: return "permission_response" + case .questionQueue: return "question_queue" + case .questionAnswer: return "question_answer" + case .planDecision: return "plan_decision" + case .branchOpRequest: return "branch_op_request" + case .branchOpResult: return "branch_op_result" + case .folderTreeRequest: return "folder_tree_request" + case .folderTreeResult: return "folder_tree_result" + case .createProjectRequest: return "create_project_request" + case .createProjectResult: return "create_project_result" + case .runProfileMutationRequest: return "run_profile_mutation_request" + case .runProfileResult: return "run_profile_result" + case .runProfileRunRequest: return "run_profile_run_request" + case .runProfileStopRequest: return "run_profile_stop_request" + case .runTaskUpdate: return "run_task_update" + case .ping: return "ping" + case .pong: return "pong" + case .unknown(let type): return type + } + } +} + // MARK: - Wire structs public struct PairRequestPayload: Codable, Sendable { @@ -193,6 +243,14 @@ public struct SnapshotPayload: Codable, Sendable { /// Desktop CPU/memory/thermal load. `nil` when the desktop predates /// computer-status sync. public let hostMetrics: HostMetricsSnapshot? + /// Run profiles grouped per project. `nil` when the desktop predates mobile + /// run-profile sync. + public let runProfiles: [MobileProjectRunProfiles]? + /// Recent and active run tasks mirrored from the desktop. + public let runTasks: [MobileRunTaskSnapshot]? + /// HTTP proxy exposed by the desktop so the mobile in-app browser can open + /// localhost development servers running on the Mac. + public let webProxy: MobileWebProxyInfo? public init( projects: [Project], sessions: [SessionSummary], @@ -204,7 +262,10 @@ public struct SnapshotPayload: Codable, Sendable { activeSessionHasMore: Bool? = nil, projectBranches: [ProjectBranchInfo]? = nil, usage: MobileUsageSnapshot? = nil, - hostMetrics: HostMetricsSnapshot? = nil + hostMetrics: HostMetricsSnapshot? = nil, + runProfiles: [MobileProjectRunProfiles]? = nil, + runTasks: [MobileRunTaskSnapshot]? = nil, + webProxy: MobileWebProxyInfo? = nil ) { self.projects = projects self.sessions = sessions @@ -217,12 +278,15 @@ public struct SnapshotPayload: Codable, Sendable { self.projectBranches = projectBranches self.usage = usage self.hostMetrics = hostMetrics + self.runProfiles = runProfiles + self.runTasks = runTasks + self.webProxy = webProxy } private enum CodingKeys: String, CodingKey { case projects, sessions, branchBriefings, threadSummaries, settings case activeSessionID, activeSessionMessages, activeSessionHasMore, projectBranches - case usage, hostMetrics + case usage, hostMetrics, runProfiles, runTasks, webProxy } public init(from decoder: Decoder) throws { @@ -238,6 +302,23 @@ public struct SnapshotPayload: Codable, Sendable { projectBranches = try c.decodeIfPresent([ProjectBranchInfo].self, forKey: .projectBranches) usage = try c.decodeIfPresent(MobileUsageSnapshot.self, forKey: .usage) hostMetrics = try c.decodeIfPresent(HostMetricsSnapshot.self, forKey: .hostMetrics) + runProfiles = try c.decodeIfPresent([MobileProjectRunProfiles].self, forKey: .runProfiles) + runTasks = try c.decodeIfPresent([MobileRunTaskSnapshot].self, forKey: .runTasks) + webProxy = try c.decodeIfPresent(MobileWebProxyInfo.self, forKey: .webProxy) + } +} + +public struct MobileWebProxyInfo: Codable, Sendable, Equatable { + public let host: String + public let port: Int + public let username: String + public let password: String + + public init(host: String, port: Int, username: String, password: String) { + self.host = host + self.port = port + self.username = username + self.password = password } } @@ -403,6 +484,159 @@ public struct CreateProjectResultPayload: Codable, Sendable { } } +public struct MobileProjectRunProfiles: Codable, Sendable, Equatable { + public let projectId: UUID + public let profiles: [RunProfile] + + public init(projectId: UUID, profiles: [RunProfile]) { + self.projectId = projectId + self.profiles = profiles + } +} + +public struct MobileRunTaskSnapshot: Codable, Sendable, Identifiable, Equatable { + public enum Status: String, Codable, Sendable { + case running + case succeeded + case failed + case signaled + case stopped + } + + public var id: UUID { taskId } + + public let taskId: UUID + public let projectId: UUID + public let profileId: UUID + public let profileName: String + public let status: Status + public let statusLabel: String + public let exitCode: Int32? + public let startedAt: Date + public let resolvedCwd: String + public let commandPreview: String + public let terminalOutputTail: String? + + public init( + taskId: UUID, + projectId: UUID, + profileId: UUID, + profileName: String, + status: Status, + statusLabel: String, + exitCode: Int32? = nil, + startedAt: Date, + resolvedCwd: String, + commandPreview: String, + terminalOutputTail: String? = nil + ) { + self.taskId = taskId + self.projectId = projectId + self.profileId = profileId + self.profileName = profileName + self.status = status + self.statusLabel = statusLabel + self.exitCode = exitCode + self.startedAt = startedAt + self.resolvedCwd = resolvedCwd + self.commandPreview = commandPreview + self.terminalOutputTail = terminalOutputTail + } + + public var isRunning: Bool { status == .running } +} + +public struct RunProfileMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case upsert + case delete + } + + public let clientRequestID: UUID + public let projectID: UUID + public let operation: Operation + public let profile: RunProfile? + public let profileID: UUID? + + public init( + clientRequestID: UUID = UUID(), + projectID: UUID, + operation: Operation, + profile: RunProfile? = nil, + profileID: UUID? = nil + ) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.operation = operation + self.profile = profile + self.profileID = profileID + } +} + +public struct RunProfileRunRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let profileID: UUID + + public init(clientRequestID: UUID = UUID(), projectID: UUID, profileID: UUID) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.profileID = profileID + } +} + +public struct RunProfileStopRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let taskID: UUID? + public let projectID: UUID? + public let profileID: UUID? + + public init( + clientRequestID: UUID = UUID(), + taskID: UUID? = nil, + projectID: UUID? = nil, + profileID: UUID? = nil + ) { + self.clientRequestID = clientRequestID + self.taskID = taskID + self.projectID = projectID + self.profileID = profileID + } +} + +public struct RunProfileResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let ok: Bool + public let errorMessage: String? + public let profiles: [RunProfile]? + public let task: MobileRunTaskSnapshot? + + public init( + clientRequestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String? = nil, + profiles: [RunProfile]? = nil, + task: MobileRunTaskSnapshot? = nil + ) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.ok = ok + self.errorMessage = errorMessage + self.profiles = profiles + self.task = task + } +} + +public struct RunTaskUpdatePayload: Codable, Sendable { + public let task: MobileRunTaskSnapshot + + public init(task: MobileRunTaskSnapshot) { + self.task = task + } +} + public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { public var id: String { "\(projectId.uuidString)::\(branch)" } @@ -647,6 +881,11 @@ public struct SessionSummary: Codable, Sendable, Identifiable { public let isStreaming: Bool public let attention: SessionAttentionKind? public let progress: SessionProgressSnapshot? + /// Latest todo items for this session. Claude sessions can still derive + /// these from `TodoWrite` messages, but Codex plan updates are persisted as + /// snapshots instead of message tool calls, so the mobile app needs the + /// desktop-owned item list. + public let todos: [TodoItem]? /// Messages waiting to be sent once the active turn finishes. Mirrored from /// the desktop's per-session queue (threadStore). `nil` when the summary /// comes from an older desktop that doesn't know about queue sync. @@ -668,6 +907,7 @@ public struct SessionSummary: Codable, Sendable, Identifiable { isStreaming: Bool = false, attention: SessionAttentionKind? = nil, progress: SessionProgressSnapshot? = nil, + todos: [TodoItem]? = nil, queuedMessages: [QueuedUserMessage]? = nil, hasUncheckedCompletion: Bool = false ) { @@ -680,12 +920,13 @@ public struct SessionSummary: Codable, Sendable, Identifiable { self.isStreaming = isStreaming self.attention = attention self.progress = progress + self.todos = todos self.queuedMessages = queuedMessages self.hasUncheckedCompletion = hasUncheckedCompletion } private enum CodingKeys: String, CodingKey { - case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, queuedMessages, hasUncheckedCompletion + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion } public init(from decoder: Decoder) throws { @@ -699,6 +940,7 @@ public struct SessionSummary: Codable, Sendable, Identifiable { isStreaming = try container.decodeIfPresent(Bool.self, forKey: .isStreaming) ?? false attention = try container.decodeIfPresent(SessionAttentionKind.self, forKey: .attention) progress = try container.decodeIfPresent(SessionProgressSnapshot.self, forKey: .progress) + todos = try container.decodeIfPresent([TodoItem].self, forKey: .todos) queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) hasUncheckedCompletion = try container.decodeIfPresent(Bool.self, forKey: .hasUncheckedCompletion) ?? false } @@ -1177,6 +1419,11 @@ extension Payload: Codable { case folderTreeResult = "folder_tree_result" case createProjectRequest = "create_project_request" case createProjectResult = "create_project_result" + case runProfileMutationRequest = "run_profile_mutation_request" + case runProfileResult = "run_profile_result" + case runProfileRunRequest = "run_profile_run_request" + case runProfileStopRequest = "run_profile_stop_request" + case runTaskUpdate = "run_task_update" case ping case pong } @@ -1219,6 +1466,11 @@ extension Payload: Codable { case .folderTreeResult: self = .folderTreeResult(try container.decode(FolderTreeResultPayload.self, forKey: .data)) case .createProjectRequest: self = .createProjectRequest(try container.decode(CreateProjectRequestPayload.self, forKey: .data)) case .createProjectResult: self = .createProjectResult(try container.decode(CreateProjectResultPayload.self, forKey: .data)) + case .runProfileMutationRequest: self = .runProfileMutationRequest(try container.decode(RunProfileMutationRequestPayload.self, forKey: .data)) + case .runProfileResult: self = .runProfileResult(try container.decode(RunProfileResultPayload.self, forKey: .data)) + case .runProfileRunRequest: self = .runProfileRunRequest(try container.decode(RunProfileRunRequestPayload.self, forKey: .data)) + case .runProfileStopRequest: self = .runProfileStopRequest(try container.decode(RunProfileStopRequestPayload.self, forKey: .data)) + case .runTaskUpdate: self = .runTaskUpdate(try container.decode(RunTaskUpdatePayload.self, forKey: .data)) case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data)) case .pong: self = .pong(try container.decode(PongPayload.self, forKey: .data)) } @@ -1257,6 +1509,11 @@ extension Payload: Codable { case .folderTreeResult(let p): try container.encode(TypeKey.folderTreeResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .createProjectRequest(let p): try container.encode(TypeKey.createProjectRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .createProjectResult(let p): try container.encode(TypeKey.createProjectResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runProfileMutationRequest(let p): try container.encode(TypeKey.runProfileMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runProfileResult(let p): try container.encode(TypeKey.runProfileResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runProfileRunRequest(let p): try container.encode(TypeKey.runProfileRunRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runProfileStopRequest(let p): try container.encode(TypeKey.runProfileStopRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runTaskUpdate(let p): try container.encode(TypeKey.runTaskUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .pong(let p): try container.encode(TypeKey.pong.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .unknown(let type): try container.encode(type, forKey: .type) diff --git a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift index 02fecf2..7372bf2 100644 --- a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -1,5 +1,6 @@ import Foundation import CryptoKit +import os /// Manages a single WebSocket to the relay. Handles E2E encrypt/decrypt, ping /// keepalive, and exponential-backoff reconnect. @@ -34,6 +35,7 @@ public actor RelayClient { private let identity: DeviceIdentity private let relayURL: URL private let session: URLSession + private let logger = Logger(subsystem: "com.idealapp.RxCodeSync", category: "RelayClient") private var task: URLSessionWebSocketTask? private(set) public var state: ConnectionState = .disconnected @@ -68,11 +70,13 @@ public actor RelayClient { public func connect() { guard task == nil else { return } shouldReconnect = true + logger.info("[Relay] connect requested relay=\(self.relayURL.absoluteString, privacy: .public) localKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") openSocket() } public func disconnect() { shouldReconnect = false + logger.info("[Relay] disconnect requested relay=\(self.relayURL.absoluteString, privacy: .public) localKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") closeSocketLocally() emit(.stateChanged(.disconnected)) state = .disconnected @@ -80,7 +84,10 @@ public actor RelayClient { /// Encrypt `payload` for `recipient` and send the resulting envelope. public func send(_ payload: Payload, to recipient: Curve25519.KeyAgreement.PublicKey) async throws { - guard let task else { throw RelayError.notConnected } + guard let task else { + logger.error("[Relay] send failed not connected type=\(payload.logName, privacy: .public) to=\(String(recipient.rawRepresentation.hexString.prefix(12)), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + throw RelayError.notConnected + } let plaintext = try JSONEncoder().encode(payload) let (nonce, ct) = try SessionCrypto.seal( plaintext: plaintext, @@ -94,7 +101,12 @@ public actor RelayClient { ct: ct ) let raw = try JSONEncoder().encode(envelope) - try await task.send(.data(raw)) + do { + try await task.send(.data(raw)) + } catch { + logger.error("[Relay] send failed type=\(payload.logName, privacy: .public) to=\(String(envelope.to.prefix(12)), privacy: .public) error=\(error.localizedDescription, privacy: .public)") + throw error + } } // MARK: - Private @@ -135,6 +147,7 @@ public actor RelayClient { return } + logger.info("[Relay] opening websocket url=\(url.absoluteString, privacy: .public)") let newTask = session.webSocketTask(with: url) // Raise the 1 MiB default frame limit so large sync payloads aren't // silently dropped by `task.send`. Applies to both send and receive. @@ -163,6 +176,7 @@ public actor RelayClient { guard socket === task else { return } if state != .connected { reconnectAttempt = 0 + logger.info("[Relay] connected relay=\(self.relayURL.absoluteString, privacy: .public)") updateState(.connected) } } @@ -221,15 +235,22 @@ public actor RelayClient { let decoder = JSONDecoder() if let notice = try? decoder.decode(DeliveryFailedNotice.self, from: raw), notice.type == "delivery_failed" { + logger.warning("[Relay] delivery failed to=\(String(notice.to.prefix(12)), privacy: .public)") emit(.deliveryFailed(toHex: notice.to)) return } - guard let env = try? decoder.decode(Envelope.self, from: raw) else { return } + guard let env = try? decoder.decode(Envelope.self, from: raw) else { + logger.warning("[Relay] dropping non-envelope message bytes=\(raw.count, privacy: .public)") + return + } guard let nonce = env.nonceData, let ct = env.ciphertextData, let fromRaw = Data(hexString: env.from), let fromKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: fromRaw) - else { return } + else { + logger.warning("[Relay] dropping malformed envelope from=\(String(env.from.prefix(12)), privacy: .public)") + return + } do { let plaintext = try SessionCrypto.open( ciphertext: ct, @@ -242,6 +263,7 @@ public actor RelayClient { } catch { // Decrypt or decode failure means the sender isn't a paired peer // we know how to talk to, OR the wire format drifted. Drop quietly. + logger.warning("[Relay] dropping encrypted payload from=\(String(env.from.prefix(12)), privacy: .public) error=\(error.localizedDescription, privacy: .public)") return } } @@ -259,6 +281,7 @@ public actor RelayClient { guard shouldReconnect else { return } reconnectAttempt += 1 let delay = min(30, Int(pow(2.0, Double(reconnectAttempt)))) + logger.error("[Relay] socket failed relay=\(self.relayURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public) reconnectIn=\(delay, privacy: .public)s") updateState(.reconnecting(nextAttemptInSeconds: delay)) Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000) diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index fac281b..41c8b7c 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -64,6 +64,42 @@ struct PayloadTests { #expect(snapshot.settings?.permissionMode == .acceptEdits) } + @Test("session summary carries todo items") + func sessionSummaryCarriesTodoItems() throws { + let projectId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let todos = [ + TodoItem(id: 0, content: "Inspect Codex plan", activeForm: "Inspecting Codex plan", status: .completed), + TodoItem(id: 1, content: "Sync mobile list", activeForm: "Syncing mobile list", status: .inProgress) + ] + let payload = Payload.snapshot( + SnapshotPayload( + projects: [], + sessions: [ + SessionSummary( + id: "thread-1", + projectId: projectId, + title: "Fix Codex todo sync", + updatedAt: Date(timeIntervalSince1970: 12), + isPinned: false, + isArchived: false, + progress: SessionProgressSnapshot(done: 1, total: 2, inProgress: true), + todos: todos + ) + ] + ) + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .snapshot(let snapshot) = decoded else { + Issue.record("Expected snapshot payload") + return + } + + #expect(snapshot.sessions.first?.todos == todos) + #expect(snapshot.sessions.first?.progress?.total == 2) + } + @Test("snapshot carries agent usage") func snapshotCarriesAgentUsage() throws { let payload = Payload.snapshot( diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index c59bde2..596ac62 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -942,6 +942,7 @@ final class AppState { /// Live progress for a user-triggered full reindex. `nil` when idle. var reindexProgress: (done: Int, total: Int)? = nil let runService = RunService() + let localWebProxy = LocalWebProxyServer() let ideMCPServer = IDEMCPServer() private var mobileSyncObservers: [NSObjectProtocol] = [] @@ -961,6 +962,7 @@ final class AppState { return liveWindowRefs.compactMap(\.window) } private var mobileSnapshotBroadcastTask: Task? + private var lastBroadcastRunTaskSnapshots: [UUID: MobileRunTaskSnapshot] = [:] /// Worktrees freshly created by a mobile "create branch" request, keyed by /// project. Consumed by the next mobile new-session request for the same @@ -991,9 +993,12 @@ final class AppState { /// Replace the in-memory list and persist atomically. func setRunProfiles(_ profiles: [RunProfile], for projectId: UUID) { runProfilesByProject[projectId] = profiles + logger.info("[MobileSync] desktop run profiles changed project=\(projectId.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") + scheduleMobileSnapshotBroadcast() Task { [persistence] in do { try await persistence.saveRunProfiles(profiles, projectId: projectId) + logger.info("[MobileSync] persisted run profiles project=\(projectId.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") } catch { logger.error("Failed to save run profiles: \(error.localizedDescription, privacy: .public)") } @@ -1035,6 +1040,11 @@ final class AppState { self.persistence = PersistenceService(metaStore: metaStore, cliStore: cliStore) self.mcp = MCPService(claudeService: claude) self.threadStore = ThreadStore.make() + self.runService.onTasksChanged = { [weak self] in + Task { @MainActor [weak self] in + self?.broadcastMobileRunTasks() + } + } // Bridge ACP `session/request_permission` and Codex in-band permission // requests into the existing PermissionServer. @@ -1235,6 +1245,48 @@ final class AppState { } mobileSyncObservers.append(createProjectObserver) + let runProfileMutationObserver = center.addObserver( + forName: .mobileSyncRunProfileMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunProfileMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunProfileMutation(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runProfileMutationObserver) + + let runProfileRunObserver = center.addObserver( + forName: .mobileSyncRunProfileRunRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunProfileRunRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunProfileRun(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runProfileRunObserver) + + let runProfileStopObserver = center.addObserver( + forName: .mobileSyncRunProfileStopRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunProfileStopRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunProfileStop(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runProfileStopObserver) + let questionAnswerObserver = center.addObserver( forName: .mobileSyncQuestionAnswerReceived, object: nil, @@ -1282,6 +1334,7 @@ final class AppState { _ = allSessionSummaries.count _ = latestRateLimitUsage _ = latestCodexRateLimitUsage + _ = runProfilesByProject.count } onChange: { Task { @MainActor [weak self] in self?.scheduleMobileSnapshotBroadcast() @@ -1539,6 +1592,174 @@ final class AppState { await MobileSyncService.shared.send(.createProjectResult(result), toHex: hex) } + private func handleMobileRunProfileMutation( + _ request: RunProfileMutationRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile mutation operation=\(request.operation.rawValue, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard projects.contains(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] run profile mutation rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + await ensureRunProfilesLoaded(for: request.projectID) + var profiles = runProfiles(for: request.projectID) + let now = Date() + + switch request.operation { + case .upsert: + guard var profile = request.profile else { + logger.error("[MobileSync] run profile upsert rejected missing payload project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Profile payload is missing.", + task: nil, + toHex: fromHex + ) + return + } + profile.projectId = request.projectID + profile.updatedAt = now + if let idx = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[idx] = profile + } else { + profile.createdAt = now + profiles.append(profile) + } + case .delete: + guard let profileID = request.profileID else { + logger.error("[MobileSync] run profile delete rejected missing profile id project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Profile id is missing.", + task: nil, + toHex: fromHex + ) + return + } + profiles.removeAll { $0.id == profileID } + } + + setRunProfiles(profiles, for: request.projectID) + logger.info("[MobileSync] run profile mutation applied project=\(request.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + task: nil, + toHex: fromHex + ) + } + + private func handleMobileRunProfileRun( + _ request: RunProfileRunRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile run project=\(request.projectID.uuidString, privacy: .public) profile=\(request.profileID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] run profile run rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + await ensureRunProfilesLoaded(for: request.projectID) + guard let profile = runProfiles(for: request.projectID).first(where: { $0.id == request.profileID }) else { + logger.error("[MobileSync] run profile run rejected missing profile=\(request.profileID.uuidString, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) knownProfiles=\(self.runProfiles(for: request.projectID).count, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Run profile not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + let task = runService.start(profile: profile, project: project) + logger.info("[MobileSync] run profile started task=\(task.id.uuidString, privacy: .public) profile=\(profile.name, privacy: .public) project=\(project.id.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + task: mobileRunTaskSnapshot(task), + toHex: fromHex + ) + } + + private func handleMobileRunProfileStop( + _ request: RunProfileStopRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile stop task=\(request.taskID?.uuidString ?? "", privacy: .public) project=\(request.projectID?.uuidString ?? "", privacy: .public) profile=\(request.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + let stoppedTask: RunTask? + if let taskID = request.taskID { + stoppedTask = runService.task(id: taskID) + runService.stop(taskId: taskID) + } else if let projectID = request.projectID, let profileID = request.profileID { + stoppedTask = runService.activeTasks.first { + $0.project.id == projectID && $0.profile.id == profileID + } + if let stoppedTask { + runService.stop(taskId: stoppedTask.id) + } + } else { + stoppedTask = nil + } + + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID ?? stoppedTask?.project.id ?? UUID(), + ok: stoppedTask != nil, + errorMessage: stoppedTask == nil ? "No matching running task was found." : nil, + task: stoppedTask.map(mobileRunTaskSnapshot), + toHex: fromHex + ) + } + + private func replyRunProfileResult( + requestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String?, + task: MobileRunTaskSnapshot?, + toHex hex: String + ) async { + await ensureRunProfilesLoaded(for: projectID) + logger.info("[MobileSync] replying run profile result ok=\(ok, privacy: .public) project=\(projectID.uuidString, privacy: .public) profiles=\(self.runProfiles(for: projectID).count, privacy: .public) task=\(task?.taskId.uuidString ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public) error=\(errorMessage ?? "", privacy: .public)") + let result = RunProfileResultPayload( + clientRequestID: requestID, + projectID: projectID, + ok: ok, + errorMessage: errorMessage, + profiles: runProfiles(for: projectID), + task: task + ) + await MobileSyncService.shared.send(.runProfileResult(result), toHex: hex) + if ok { scheduleMobileSnapshotBroadcast() } + } + private func mobileFolderTreeRoot(for request: FolderTreeRequestPayload) throws -> RemoteFolderNode { let depth = max(0, min(request.depth, 2)) guard let path = request.path?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1961,6 +2182,14 @@ final class AppState { await self?.refreshCodexRateLimitUsage() } let hostMetrics = await SystemMetricsService.shared.sample() + let runProfiles = await mobileRunProfiles() + let runTasks = mobileRunTaskSnapshots() + let webProxy = await localWebProxy.proxyInfo() + if let webProxy { + logger.info("[WebBrowserSync] snapshot includes web proxy host=\(webProxy.host, privacy: .public) port=\(webProxy.port, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") + } else { + logger.warning("[WebBrowserSync] snapshot has no web proxy info to mobileKey=\(String(hex.prefix(12)), privacy: .public)") + } let payload = SnapshotPayload( projects: projects, sessions: mobileSessionSummaries(), @@ -1975,7 +2204,10 @@ final class AppState { claudeCode: latestRateLimitUsage, codex: latestCodexRateLimitUsage ), - hostMetrics: hostMetrics + hostMetrics: hostMetrics, + runProfiles: runProfiles, + runTasks: runTasks, + webProxy: webProxy ) await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) // The snapshot doesn't carry the question queue; send it alongside so a @@ -1985,7 +2217,7 @@ final class AppState { toHex: hex ) logger.info( - "[MobileSync] sent snapshot projects=\(self.projects.count, privacy: .public) sessions=\(payload.sessions.count, privacy: .public) active=\(active.id ?? "", privacy: .public)" + "[MobileSync] sent snapshot projects=\(self.projects.count, privacy: .public) sessions=\(payload.sessions.count, privacy: .public) runProfileProjects=\(runProfiles.count, privacy: .public) runProfileTotal=\(runProfiles.reduce(0) { $0 + $1.profiles.count }, privacy: .public) runTasks=\(runTasks.count, privacy: .public) active=\(active.id ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)" ) } @@ -2002,6 +2234,75 @@ final class AppState { } } + private func mobileRunProfiles() async -> [MobileProjectRunProfiles] { + var result: [MobileProjectRunProfiles] = [] + for project in projects { + await ensureRunProfilesLoaded(for: project.id) + let profiles = runProfiles(for: project.id) + result.append(MobileProjectRunProfiles( + projectId: project.id, + profiles: profiles + )) + } + return result + } + + private func mobileRunTaskSnapshots() -> [MobileRunTaskSnapshot] { + runService.tasks.map(mobileRunTaskSnapshot) + } + + private func mobileRunTaskSnapshot(_ task: RunTask) -> MobileRunTaskSnapshot { + MobileRunTaskSnapshot( + taskId: task.id, + projectId: task.project.id, + profileId: task.profile.id, + profileName: task.profile.name, + status: mobileRunTaskStatus(task.status), + statusLabel: task.status.label, + exitCode: task.exitCode, + startedAt: task.startedAt, + resolvedCwd: task.resolvedCwd, + commandPreview: mobileRunTaskCommandPreview(task), + terminalOutputTail: String(task.terminalOutputTail.suffix(8_000)) + ) + } + + private func mobileRunTaskStatus(_ status: RunTaskStatus) -> MobileRunTaskSnapshot.Status { + switch status { + case .running: return .running + case .succeeded: return .succeeded + case .failed: return .failed + case .signaled: return .signaled + case .stopped: return .stopped + } + } + + private func mobileRunTaskCommandPreview(_ task: RunTask) -> String { + let lines = task.wrapperScript + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + if let mainIndex = lines.firstIndex(of: "# --- main ---") { + return lines.dropFirst(mainIndex + 1) + .prefix(8) + .joined(separator: "\n") + } + return lines.suffix(8).joined(separator: "\n") + } + + private func broadcastMobileRunTasks() { + guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } + let currentSnapshots = runService.tasks.prefix(5).map(mobileRunTaskSnapshot) + let currentById = Dictionary(uniqueKeysWithValues: currentSnapshots.map { ($0.taskId, $0) }) + let removedIds = Set(lastBroadcastRunTaskSnapshots.keys).subtracting(currentById.keys) + if !removedIds.isEmpty { + scheduleMobileSnapshotBroadcast() + } + for snapshot in currentSnapshots where lastBroadcastRunTaskSnapshots[snapshot.taskId] != snapshot { + MobileSyncService.shared.broadcastRunTaskUpdate(snapshot) + } + lastBroadcastRunTaskSnapshots = currentById + } + private func mobileSessionSummary(for sessionID: String) -> RxCodeSync.SessionSummary? { guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { return nil @@ -2010,6 +2311,7 @@ final class AppState { } private func mobileSessionSummary(from summary: ChatSession.Summary) -> RxCodeSync.SessionSummary { + let todos = mobileTodoItems(forSessionId: summary.id) let progress = mobileProgressSnapshot(forSessionId: summary.id) let queued = threadStore.loadQueue(sessionKey: summary.id).map { QueuedUserMessage(id: $0.id, text: $0.text) @@ -2024,15 +2326,14 @@ final class AppState { isStreaming: sessionStates[summary.id]?.isStreaming ?? false, attention: mobileAttentionKind(forSessionId: summary.id), progress: progress, + todos: todos, queuedMessages: queued, hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false ) } private func mobileProgressSnapshot(forSessionId id: String) -> SessionProgressSnapshot? { - if let messages = sessionStates[id]?.messages, - let todos = TodoExtractor.latest(in: messages) - { + if let todos = mobileTodoItems(forSessionId: id) { return SessionProgressSnapshot( done: todos.filter { $0.status == .completed }.count, total: todos.count, @@ -2040,15 +2341,21 @@ final class AppState { ) } + return nil + } + + private func mobileTodoItems(forSessionId id: String) -> [TodoItem]? { + if let messages = sessionStates[id]?.messages, + let todos = TodoExtractor.latest(in: messages) + { + return todos + } + guard let snapshot = threadStore.fetchTodoSnapshot(sessionId: id), snapshot.total > 0 else { return nil } - return SessionProgressSnapshot( - done: snapshot.done, - total: snapshot.total, - inProgress: snapshot.inProgress > 0 - ) + return snapshot.items } private func mobileAttentionKind(forSessionId id: String) -> SessionAttentionKind? { diff --git a/RxCode/Services/LocalWebProxyServer.swift b/RxCode/Services/LocalWebProxyServer.swift new file mode 100644 index 0000000..130e58e --- /dev/null +++ b/RxCode/Services/LocalWebProxyServer.swift @@ -0,0 +1,633 @@ +import Darwin +import Foundation +import Network +import os.log +import RxCodeSync + +actor LocalWebProxyServer { + private static let basePort: UInt16 = 19920 + private static let maxPort: UInt16 = 19930 + private static let reverseBootstrapPath = "/__rxcode_browser" + private static let reverseCookieName = "rxcode_proxy_target" + + private var listener: NWListener? + private var port: UInt16 = LocalWebProxyServer.basePort + private let username = "rxcode" + private let password = UUID().uuidString + private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "LocalWebProxy") + + func proxyInfo() async -> MobileWebProxyInfo? { + if listener == nil { + do { + try await start() + } catch { + logger.error("[WebBrowserSync] failed to start local web proxy: \(error.localizedDescription, privacy: .public)") + return nil + } + } + guard let host = Self.localIPv4Address() else { + logger.error("[WebBrowserSync] failed to find a LAN IPv4 address for local web proxy") + return nil + } + logger.info("[WebBrowserSync] proxy info host=\(host, privacy: .public) port=\(self.port, privacy: .public)") + return MobileWebProxyInfo(host: host, port: Int(port), username: username, password: password) + } + + private func start() async throws { + for candidatePort in Self.basePort...Self.maxPort { + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: candidatePort)!) + listener.stateUpdateHandler = { [logger] state in + switch state { + case .ready: + logger.info("[WebBrowserSync] local web proxy listening on port \(candidatePort)") + case .failed(let error): + logger.error("[WebBrowserSync] local web proxy failed: \(error.localizedDescription, privacy: .public)") + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + Task { await self.handleConnection(connection) } + } + listener.start(queue: .global(qos: .userInitiated)) + self.listener = listener + self.port = candidatePort + return + } catch { + logger.warning("[WebBrowserSync] local web proxy port \(candidatePort) unavailable") + } + } + throw LocalWebProxyError.noAvailablePort + } + + private func handleConnection(_ client: NWConnection) async { + client.start(queue: .global(qos: .userInitiated)) + do { + let raw = try await Self.readHTTPRequest(client) + let request = try Self.parseHTTPRequest(raw) + logger.info("[WebBrowserSync] proxy request method=\(request.method, privacy: .public) target=\(request.target, privacy: .public)") + if let bootstrap = Self.reverseBootstrap(from: request, password: password) { + logger.info("[WebBrowserSync] reverse bootstrap target=\(bootstrap.target.absoluteString, privacy: .public)") + await Self.sendReverseBootstrap(client, bootstrap: bootstrap, password: password) + return + } + if let targetOrigin = Self.reverseTargetOrigin(from: request, password: password) { + try await handleReverseHTTP(request, targetOrigin: targetOrigin, client: client) + return + } + guard Self.isAuthorized(request.headers, username: username, password: password) else { + logger.warning("[WebBrowserSync] proxy request unauthorized target=\(request.target, privacy: .public)") + await Self.sendProxyAuthRequired(client) + return + } + + if request.method.uppercased() == "CONNECT" { + try await handleConnect(request, client: client) + } else { + try await handlePlainHTTP(request, client: client) + } + } catch { + logger.error("[WebBrowserSync] proxy request failed: \(error.localizedDescription, privacy: .public)") + await Self.sendError(client, status: "502 Bad Gateway", message: error.localizedDescription) + } + } + + private func handleConnect(_ request: ProxyHTTPRequest, client: NWConnection) async throws { + let hostPort = request.target.split(separator: ":", maxSplits: 1).map(String.init) + guard let host = hostPort.first, !host.isEmpty else { throw LocalWebProxyError.badRequest } + let port = UInt16(hostPort.count > 1 ? hostPort[1] : "443") ?? 443 + logger.info("[WebBrowserSync] proxy CONNECT host=\(host, privacy: .public) port=\(port, privacy: .public)") + let upstream = try await Self.connect(host: host, port: port) + await Self.sendRaw(client, data: Data("HTTP/1.1 200 Connection Established\r\n\r\n".utf8), close: false) + Self.pipe(client, upstream) + Self.pipe(upstream, client) + } + + private func handlePlainHTTP(_ request: ProxyHTTPRequest, client: NWConnection) async throws { + guard let target = request.resolvedURL else { throw LocalWebProxyError.badRequest } + guard let host = target.host, !host.isEmpty else { throw LocalWebProxyError.badRequest } + let port = UInt16(target.port ?? (target.scheme == "https" ? 443 : 80)) + logger.info("[WebBrowserSync] proxy HTTP target=\(target.absoluteString, privacy: .public) host=\(host, privacy: .public) port=\(port, privacy: .public)") + let upstream = try await Self.connect(host: host, port: port) + let rewritten = request.rewrittenForOriginServer(targetURL: target) + await Self.sendRaw(upstream, data: rewritten, close: false) + Self.pipe(client, upstream) + Self.pipe(upstream, client) + } + + private func handleReverseHTTP(_ request: ProxyHTTPRequest, targetOrigin: URL, client: NWConnection) async throws { + let target = try Self.reverseTargetURL(origin: targetOrigin, request: request) + guard let host = target.host, !host.isEmpty else { throw LocalWebProxyError.badRequest } + let port = UInt16(target.port ?? 80) + logger.info("[WebBrowserSync] reverse HTTP target=\(target.absoluteString, privacy: .public) host=\(host, privacy: .public) port=\(port, privacy: .public)") + let upstream = try await Self.connect(host: host, port: port) + let rewritten = request.rewrittenForReverseProxy( + targetURL: target, + targetHostHeader: Self.hostHeader(for: targetOrigin), + targetOrigin: Self.originString(for: targetOrigin), + reverseCookieName: Self.reverseCookieName + ) + await Self.sendRaw(upstream, data: rewritten, close: false) + Self.pipe(client, upstream) + Self.pipe(upstream, client) + } + + private nonisolated static func connect(host: String, port: UInt16) async throws -> NWConnection { + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: port)!, + using: .tcp + ) + return try await withCheckedThrowingContinuation { continuation in + let resumeGate = ProxyConnectResumeGate() + connection.stateUpdateHandler = { state in + switch state { + case .ready: + guard resumeGate.markResumed() else { return } + continuation.resume(returning: connection) + case .failed(let error): + guard resumeGate.markResumed() else { return } + continuation.resume(throwing: error) + default: + break + } + } + connection.start(queue: .global(qos: .userInitiated)) + } + } + + private nonisolated static func readHTTPRequest(_ connection: NWConnection) async throws -> Data { + var buffer = Data() + let headerEnd = Data("\r\n\r\n".utf8) + while !buffer.contains(headerEnd) { + let chunk = try await readChunk(connection, maxLength: 8192) + guard !chunk.isEmpty else { throw LocalWebProxyError.connectionClosed } + buffer.append(chunk) + } + + guard let headerRange = buffer.range(of: headerEnd) else { + throw LocalWebProxyError.badRequest + } + let headerData = buffer[buffer.startIndex.. 0 { + let bodyStart = headerRange.upperBound + let bodyBytesRead = buffer.count - buffer.distance(from: buffer.startIndex, to: bodyStart) + var remaining = contentLength - bodyBytesRead + while remaining > 0 { + let chunk = try await readChunk(connection, maxLength: min(remaining, 8192)) + guard !chunk.isEmpty else { throw LocalWebProxyError.connectionClosed } + buffer.append(chunk) + remaining -= chunk.count + } + } + return buffer + } + + private nonisolated static func readChunk(_ connection: NWConnection, maxLength: Int) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: maxLength) { data, _, _, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: data ?? Data()) + } + } + } + } + + private nonisolated static func parseHTTPRequest(_ data: Data) throws -> ProxyHTTPRequest { + let headerEnd = Data("\r\n\r\n".utf8) + guard let headerRange = data.range(of: headerEnd), + let headerString = String(data: data[data.startIndex.. Bool { + guard let value = headers.first(where: { $0.0.lowercased() == "proxy-authorization" })?.1, + value.lowercased().hasPrefix("basic ") + else { + return false + } + let encoded = String(value.dropFirst("Basic ".count)) + guard let data = Data(base64Encoded: encoded), + let decoded = String(data: data, encoding: .utf8) + else { + return false + } + return decoded == "\(username):\(password)" + } + + private nonisolated static func reverseBootstrap( + from request: ProxyHTTPRequest, + password: String + ) -> ReverseBootstrap? { + guard request.method.uppercased() == "GET", + let components = request.originFormComponents, + components.path == reverseBootstrapPath, + let items = components.queryItems, + items.first(where: { $0.name == "token" })?.value == password, + let targetValue = items.first(where: { $0.name == "target" })?.value, + let target = URL(string: targetValue), + isAllowedReverseTarget(target), + let origin = originURL(for: target) + else { + return nil + } + + var location = target.path.isEmpty ? "/" : target.path + if let query = target.query, !query.isEmpty { + location += "?\(query)" + } + return ReverseBootstrap(target: target, origin: origin, location: location) + } + + private nonisolated static func reverseTargetOrigin( + from request: ProxyHTTPRequest, + password: String + ) -> URL? { + guard request.originFormComponents?.path != reverseBootstrapPath, + let cookie = request.headers.first(where: { $0.0.lowercased() == "cookie" })?.1, + let value = cookieValue(named: reverseCookieName, in: cookie), + let decoded = decodeBase64URL(value), + let payload = String(data: decoded, encoding: .utf8) + else { + return nil + } + let parts = payload.split(separator: "\n", maxSplits: 1).map(String.init) + guard parts.count == 2, + parts[0] == password, + let origin = URL(string: parts[1]), + isAllowedReverseTarget(origin) + else { + return nil + } + return origin + } + + private nonisolated static func reverseTargetURL(origin: URL, request: ProxyHTTPRequest) throws -> URL { + guard let requestComponents = request.originFormComponents else { + throw LocalWebProxyError.badRequest + } + var components = URLComponents(url: origin, resolvingAgainstBaseURL: false) + components?.percentEncodedPath = requestComponents.percentEncodedPath.isEmpty ? "/" : requestComponents.percentEncodedPath + components?.percentEncodedQuery = requestComponents.percentEncodedQuery + guard let url = components?.url else { + throw LocalWebProxyError.badRequest + } + return url + } + + private nonisolated static func sendReverseBootstrap( + _ connection: NWConnection, + bootstrap: ReverseBootstrap, + password: String + ) async { + let payload = Data("\(password)\n\(bootstrap.origin.absoluteString)".utf8) + let cookie = encodeBase64URL(payload) + let response = [ + "HTTP/1.1 302 Found", + "Location: \(sanitizeHeaderValue(bootstrap.location))", + "Set-Cookie: \(reverseCookieName)=\(cookie); Path=/; HttpOnly; SameSite=Lax", + "Content-Length: 0", + "Connection: close", + "", + "" + ].joined(separator: "\r\n") + await sendRaw(connection, data: Data(response.utf8), close: true) + } + + private nonisolated static func isAllowedReverseTarget(_ url: URL) -> Bool { + guard url.scheme?.lowercased() == "http", + let host = url.host?.lowercased() + else { + return false + } + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" + } + + private nonisolated static func originURL(for url: URL) -> URL? { + guard let scheme = url.scheme, let host = url.host else { return nil } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = url.port + components.path = "/" + return components.url + } + + private nonisolated static func hostHeader(for url: URL) -> String { + guard let host = url.host else { return "" } + if let port = url.port { + return "\(host):\(port)" + } + return host + } + + private nonisolated static func originString(for url: URL) -> String { + guard let scheme = url.scheme else { return "" } + return "\(scheme)://\(hostHeader(for: url))" + } + + private nonisolated static func cookieValue(named name: String, in cookieHeader: String) -> String? { + for part in cookieHeader.split(separator: ";") { + let trimmed = part.trimmingCharacters(in: .whitespaces) + let pair = trimmed.split(separator: "=", maxSplits: 1).map(String.init) + if pair.count == 2, pair[0] == name { + return pair[1] + } + } + return nil + } + + private nonisolated static func encodeBase64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private nonisolated static func decodeBase64URL(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - base64.count % 4) % 4 + base64 += String(repeating: "=", count: padding) + return Data(base64Encoded: base64) + } + + private nonisolated static func sanitizeHeaderValue(_ value: String) -> String { + value + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: "\n", with: "") + } + + private nonisolated static func contentLength(from headers: String) -> Int { + for line in headers.components(separatedBy: "\r\n") { + if line.lowercased().hasPrefix("content-length:") { + return Int(line.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) ?? 0 + } + } + return 0 + } + + private nonisolated static func pipe(_ source: NWConnection, _ destination: NWConnection) { + source.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in + if let data, !data.isEmpty { + destination.send(content: data, completion: .contentProcessed { _ in + if isComplete || error != nil { + source.cancel() + destination.cancel() + } else { + pipe(source, destination) + } + }) + } else { + source.cancel() + destination.cancel() + } + } + } + + private nonisolated static func sendProxyAuthRequired(_ connection: NWConnection) async { + let response = [ + "HTTP/1.1 407 Proxy Authentication Required", + #"Proxy-Authenticate: Basic realm="RxCode""#, + "Content-Length: 0", + "Connection: close", + "", + "" + ].joined(separator: "\r\n") + await sendRaw(connection, data: Data(response.utf8), close: true) + } + + private nonisolated static func sendError(_ connection: NWConnection, status: String, message: String) async { + let body = Data(message.utf8) + let response = [ + "HTTP/1.1 \(status)", + "Content-Type: text/plain; charset=utf-8", + "Content-Length: \(body.count)", + "Connection: close", + "", + "" + ].joined(separator: "\r\n") + var data = Data(response.utf8) + data.append(body) + await sendRaw(connection, data: data, close: true) + } + + private nonisolated static func sendRaw(_ connection: NWConnection, data: Data, close: Bool) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { _ in + if close { connection.cancel() } + continuation.resume() + }) + } + } + + private nonisolated static func localIPv4Address() -> String? { + var interfaces: UnsafeMutablePointer? + guard getifaddrs(&interfaces) == 0, let first = interfaces else { return nil } + defer { freeifaddrs(interfaces) } + + var fallback: String? + var cursor: UnsafeMutablePointer? = first + while let item = cursor { + defer { cursor = item.pointee.ifa_next } + let flags = Int32(item.pointee.ifa_flags) + guard flags & IFF_UP != 0, + flags & IFF_RUNNING != 0, + flags & IFF_LOOPBACK == 0, + item.pointee.ifa_addr.pointee.sa_family == UInt8(AF_INET) + else { + continue + } + let address = item.pointee.ifa_addr! + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + address, + socklen_t(address.pointee.sa_len), + &host, + socklen_t(host.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + let ip = String(cString: host) + let name = String(cString: item.pointee.ifa_name) + if name == "en0" { return ip } + fallback = fallback ?? ip + } + return fallback + } +} + +private struct ReverseBootstrap { + let target: URL + let origin: URL + let location: String +} + +private final class ProxyConnectResumeGate: @unchecked Sendable { + private let lock = NSLock() + nonisolated(unsafe) private var didResume = false + + nonisolated init() {} + + nonisolated func markResumed() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !didResume else { return false } + didResume = true + return true + } +} + +private struct ProxyHTTPRequest { + let method: String + let target: String + let version: String + let headers: [(String, String)] + let body: Data + + nonisolated var originFormComponents: URLComponents? { + if let absolute = URLComponents(string: target), absolute.scheme != nil { + var components = URLComponents() + components.percentEncodedPath = absolute.percentEncodedPath.isEmpty ? "/" : absolute.percentEncodedPath + components.percentEncodedQuery = absolute.percentEncodedQuery + return components + } + return URLComponents(string: target) + } + + nonisolated var resolvedURL: URL? { + if let url = URL(string: target), url.scheme != nil { + return url + } + guard let host = headers.first(where: { $0.0.lowercased() == "host" })?.1 else { + return nil + } + return URL(string: "http://\(host)\(target)") + } + + nonisolated func rewrittenForOriginServer(targetURL: URL) -> Data { + let components = URLComponents(url: targetURL, resolvingAgainstBaseURL: false) + var path = components?.percentEncodedPath ?? targetURL.path + if path.isEmpty { path = "/" } + if let query = components?.percentEncodedQuery ?? targetURL.query { + path += "?\(query)" + } + var lines = ["\(method) \(path) \(version)"] + for (name, value) in headers { + let lower = name.lowercased() + guard lower != "proxy-authorization", lower != "proxy-connection" else { continue } + lines.append("\(name): \(value)") + } + var data = Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8) + data.append(body) + return data + } + + nonisolated func rewrittenForReverseProxy( + targetURL: URL, + targetHostHeader: String, + targetOrigin: String, + reverseCookieName: String + ) -> Data { + let components = URLComponents(url: targetURL, resolvingAgainstBaseURL: false) + var path = components?.percentEncodedPath ?? targetURL.path + if path.isEmpty { path = "/" } + if let query = components?.percentEncodedQuery ?? targetURL.query { + path += "?\(query)" + } + + var sawHost = false + var lines = ["\(method) \(path) \(version)"] + for (name, value) in headers { + let lower = name.lowercased() + switch lower { + case "host": + sawHost = true + lines.append("Host: \(targetHostHeader)") + case "proxy-authorization", "proxy-connection": + continue + case "origin": + lines.append("\(name): \(targetOrigin)") + case "referer": + lines.append("\(name): \(targetURL.absoluteString)") + case "cookie": + if let stripped = strippingCookie(named: reverseCookieName, from: value), !stripped.isEmpty { + lines.append("\(name): \(stripped)") + } + default: + lines.append("\(name): \(value)") + } + } + if !sawHost { + lines.append("Host: \(targetHostHeader)") + } + var data = Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8) + data.append(body) + return data + } + + private nonisolated func strippingCookie(named name: String, from value: String) -> String? { + let cookies = value + .split(separator: ";") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { cookie in + !cookie.hasPrefix("\(name)=") + } + return cookies.joined(separator: "; ") + } +} + +private enum LocalWebProxyError: LocalizedError { + case noAvailablePort + case badRequest + case connectionClosed + + var errorDescription: String? { + switch self { + case .noAvailablePort: + return "No local web proxy port is available." + case .badRequest: + return "Malformed proxy request." + case .connectionClosed: + return "Connection closed." + } + } +} diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 6792233..a8d56fe 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -105,8 +105,14 @@ final class MobileSyncService: ObservableObject { /// Begin (or resume) the relay connection. Safe to call multiple times. func start() { Task { @MainActor in + logger.info("[MobileSync] starting relay=\(self.relayURL.absoluteString, privacy: .public) pairedDevices=\(self.pairedDevices.count, privacy: .public) desktopKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") for device in pairedDevices { - try? await client.addPeer(device.pubkeyHex) + do { + try await client.addPeer(device.pubkeyHex) + logger.info("[MobileSync] added paired peer mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } catch { + logger.error("[MobileSync] failed to add paired peer mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } } // Subscribe to events BEFORE calling start() so we don't miss the // initial .connecting / .connected state transitions. @@ -130,6 +136,7 @@ final class MobileSyncService: ObservableObject { /// Update the configured relay URL, persist it, and reconnect. func updateRelay(url: URL) { guard url != relayURL else { return } + logger.info("[MobileSync] relay URL changed from \(self.relayURL.absoluteString, privacy: .public) to \(url.absoluteString, privacy: .public)") UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") relayURL = url eventTask?.cancel() @@ -400,16 +407,23 @@ final class MobileSyncService: ObservableObject { } } + func broadcastRunTaskUpdate(_ task: MobileRunTaskSnapshot) { + Task { + await client.broadcast(.runTaskUpdate(RunTaskUpdatePayload(task: task))) + } + } + // MARK: - Event dispatch private func handle(event: RelayClient.Event) { switch event { case .stateChanged(let s): connectionState = s - case .deliveryFailed: + logger.info("[MobileSync] relay state=\(String(describing: s), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + case .deliveryFailed(let toHex): // Drop-on-offline policy — desktop ignores, mobile will resync on // next reconnect. - break + logger.warning("[MobileSync] relay delivery failed to mobileKey=\(String(toHex.prefix(12)), privacy: .public)") case .inbound(let inbound): handleInbound(inbound) } @@ -440,6 +454,7 @@ final class MobileSyncService: ObservableObject { } case .requestSnapshot(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "request_snapshot") else { return } + logger.info("[MobileSync] snapshot requested by mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) activeSession=\(req.activeSessionID ?? "", privacy: .public)") // AppState owns the data; it observes pendingSnapshotRequests // and replies. Stub for now — wired up by AppState bridge. var userInfo: [String: Any] = ["from": inbound.fromHex] @@ -557,6 +572,30 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .runProfileMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_mutation_request") else { return } + logger.info("[MobileSync] run profile mutation requested operation=\(req.operation.rawValue, privacy: .public) project=\(req.projectID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunProfileMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .runProfileRunRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_run_request") else { return } + logger.info("[MobileSync] run profile start requested project=\(req.projectID.uuidString, privacy: .public) profile=\(req.profileID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunProfileRunRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .runProfileStopRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_stop_request") else { return } + logger.info("[MobileSync] run profile stop requested task=\(req.taskID?.uuidString ?? "", privacy: .public) project=\(req.projectID?.uuidString ?? "", privacy: .public) profile=\(req.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunProfileStopRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .ping: guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } @@ -598,7 +637,12 @@ final class MobileSyncService: ObservableObject { /// Send a payload to a single peer (used by AppState when replying to /// `request_snapshot` etc). func send(_ payload: Payload, toHex hex: String) async { - try? await client.send(payload, toHex: hex) + do { + try await client.send(payload, toHex: hex) + logger.debug("[MobileSync] sent type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") + } catch { + logger.error("[MobileSync] send failed type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } } // MARK: - Persistence @@ -716,4 +760,7 @@ extension Notification.Name { static let mobileSyncBranchOpRequested = Notification.Name("mobileSync.branchOpRequested") static let mobileSyncFolderTreeRequested = Notification.Name("mobileSync.folderTreeRequested") static let mobileSyncCreateProjectRequested = Notification.Name("mobileSync.createProjectRequested") + static let mobileSyncRunProfileMutationRequested = Notification.Name("mobileSync.runProfileMutationRequested") + static let mobileSyncRunProfileRunRequested = Notification.Name("mobileSync.runProfileRunRequested") + static let mobileSyncRunProfileStopRequested = Notification.Name("mobileSync.runProfileStopRequested") } diff --git a/RxCode/Services/RunProfile/RunService.swift b/RxCode/Services/RunProfile/RunService.swift index 08247ac..f715632 100644 --- a/RxCode/Services/RunProfile/RunService.swift +++ b/RxCode/Services/RunProfile/RunService.swift @@ -73,8 +73,12 @@ final class RunTask: Identifiable { let wrapperScript: String let resolvedCwd: String let resolvedEnvironment: [String: String] + let outputLogURL: URL + var terminalOutputTail: String = "" private let delegate: RunTaskTerminalDelegate + nonisolated(unsafe) private var outputCaptureTask: Task? + private let onOutputChanged: @MainActor (UUID) -> Void init( id: UUID = UUID(), @@ -83,6 +87,8 @@ final class RunTask: Identifiable { wrapperScript: String, resolvedCwd: String, resolvedEnvironment: [String: String], + outputLogURL: URL, + onOutputChanged: @escaping @MainActor (UUID) -> Void, onTerminated: @escaping @MainActor (UUID, Int32) -> Void ) { self.id = id @@ -91,6 +97,8 @@ final class RunTask: Identifiable { self.wrapperScript = wrapperScript self.resolvedCwd = resolvedCwd self.resolvedEnvironment = resolvedEnvironment + self.outputLogURL = outputLogURL + self.onOutputChanged = onOutputChanged let tv = LocalProcessTerminalView(frame: .zero) Self.applyTheme(to: tv) @@ -117,6 +125,7 @@ final class RunTask: Identifiable { environment: envPairs, currentDirectory: resolvedCwd ) + startOutputCapture() } func terminate() { @@ -128,6 +137,33 @@ final class RunTask: Identifiable { status = .stopped } + private func startOutputCapture() { + outputCaptureTask?.cancel() + outputCaptureTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 300_000_000) + guard let self else { return } + self.refreshOutputTail() + self.onOutputChanged(self.id) + if self.status.isTerminal { break } + } + self?.refreshOutputTail() + if let self { self.onOutputChanged(self.id) } + } + } + + private func refreshOutputTail() { + guard let data = try? Data(contentsOf: outputLogURL), + let text = String(data: data, encoding: .utf8) + else { return } + terminalOutputTail = String(text.suffix(20_000)) + } + + deinit { + outputCaptureTask?.cancel() + try? FileManager.default.removeItem(at: outputLogURL) + } + private static func applyTheme(to tv: LocalProcessTerminalView) { let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua tv.nativeBackgroundColor = isDark @@ -176,6 +212,7 @@ extension RunService { final class RunService { private let logger = Logger(subsystem: "com.claudework", category: "RunService") + var onTasksChanged: (() -> Void)? /// Most-recent task first. Includes finished tasks so the inspector /// dropdown can show their output until cleared. @@ -210,7 +247,13 @@ final class RunService { projectPath: project.path, baseEnvironment: ProcessInfo.processInfo.environment ) - let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: project.path) + let outputLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("rxcode-run-\(UUID().uuidString).log") + let script = RunTaskExecutor.buildWrapperScript( + profile: profile, + projectPath: project.path, + outputLogPath: outputLogURL.path + ) let task = RunTask( profile: profile, @@ -218,6 +261,10 @@ final class RunService { wrapperScript: script, resolvedCwd: cwd, resolvedEnvironment: env, + outputLogURL: outputLogURL, + onOutputChanged: { [weak self] _ in + self?.onTasksChanged?() + }, onTerminated: { [weak self] id, rawStatus in Task { @MainActor in self?.taskTerminated(id: id, rawStatus: rawStatus) @@ -226,15 +273,18 @@ final class RunService { ) tasks.insert(task, at: 0) task.start() + onTasksChanged?() return task } func stop(taskId: UUID) { tasks.first { $0.id == taskId }?.terminate() + onTasksChanged?() } func stopAll() { for task in activeTasks { task.terminate() } + onTasksChanged?() } func task(id: UUID) -> RunTask? { @@ -246,11 +296,13 @@ final class RunService { guard let idx = tasks.firstIndex(where: { $0.id == taskId }), tasks[idx].status.isTerminal else { return } tasks.remove(at: idx) + onTasksChanged?() } /// Remove every finished task. Active tasks are left in place. func clearFinished() { tasks.removeAll { $0.status.isTerminal } + onTasksChanged?() } private func taskTerminated(id: UUID, rawStatus: Int32) { @@ -270,5 +322,6 @@ final class RunService { if task.status != .stopped { task.status = Self.statusFromRawWaitpid(rawStatus) } + onTasksChanged?() } } diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index a16332c..a81b6b4 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -264,12 +264,12 @@ struct BriefingView: View { Button { showAllBranches = false } label: { - Label("Current branch", systemImage: showAllBranches ? "" : "checkmark") + menuSelectionLabel("Current branch", isSelected: !showAllBranches) } Button { showAllBranches = true } label: { - Label("All branches", systemImage: showAllBranches ? "checkmark" : "") + menuSelectionLabel("All branches", isSelected: showAllBranches) } } label: { HStack(spacing: 6) { @@ -311,17 +311,14 @@ struct BriefingView: View { Button { selectedProjectIds.removeAll() } label: { - Label("All projects", systemImage: selectedProjectIds.isEmpty ? "checkmark" : "") + menuSelectionLabel("All projects", isSelected: selectedProjectIds.isEmpty) } Divider() ForEach(projects) { project in Button { toggleProject(project.id) } label: { - Label( - project.name, - systemImage: selectedProjectIds.contains(project.id) ? "checkmark" : "" - ) + menuSelectionLabel(project.name, isSelected: selectedProjectIds.contains(project.id)) } } } label: { @@ -358,6 +355,15 @@ struct BriefingView: View { } } + @ViewBuilder + private func menuSelectionLabel(_ title: String, isSelected: Bool) -> some View { + if isSelected { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + private func filterMenuLabel(projects: [Project]) -> String { if selectedProjectIds.isEmpty { return "All projects" diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 81202c2..05301e8 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -59,10 +59,19 @@ final class MobileAppState: ObservableObject { /// until the first snapshot arrives, or when paired with a desktop that /// predates computer-status sync. @Published var desktopHostMetrics: HostMetricsSnapshot? + /// Desktop HTTP proxy used by the in-app browser to load localhost URLs + /// from the paired Mac instead of from the iPad. + @Published var desktopWebProxy: MobileWebProxyInfo? /// Current git branch per project, mirrored from the desktop's snapshot. @Published var projectBranches: [UUID: String] = [:] /// Local branch list per project, mirrored from the desktop's snapshot. @Published var availableBranchesByProject: [UUID: [String]] = [:] + /// Desktop-owned run profiles per project, mirrored into the mobile app. + @Published var runProfilesByProject: [UUID: [RunProfile]] = [:] + /// Recent and active run tasks mirrored from the desktop. + @Published var runTasks: [MobileRunTaskSnapshot] = [] + @Published var inFlightRunProfileRequests: Set = [] + @Published var lastRunProfileError: String? /// IDs of branch operations awaiting a `BranchOpResultPayload`. Used so the /// UI can render a spinner on the chip while the desktop runs git. @Published var inFlightBranchOps: Set = [] @@ -216,8 +225,7 @@ final class MobileAppState: ObservableObject { await client.start() // Re-request snapshot on every (re)start so we don't show stale state. if isPaired { - let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) - try? await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) + await requestSnapshot(reason: "client_start") await reportAPNsTokenIfPending() } } @@ -719,6 +727,110 @@ final class MobileAppState: ObservableObject { await requestSnapshot() } + func runProfiles(for projectID: UUID) -> [RunProfile] { + runProfilesByProject[projectID] ?? [] + } + + func runTasks(for projectID: UUID) -> [MobileRunTaskSnapshot] { + runTasks.filter { $0.projectId == projectID } + } + + func runningTask(projectID: UUID, profileID: UUID) -> MobileRunTaskSnapshot? { + runTasks.first { + $0.projectId == projectID && $0.profileId == profileID && $0.isRunning + } + } + + func saveRunProfile(_ profile: RunProfile, projectID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] save dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public)") + return + } + var next = runProfilesByProject[projectID] ?? [] + if let idx = next.firstIndex(where: { $0.id == profile.id }) { + next[idx] = profile + } else { + next.append(profile) + } + runProfilesByProject[projectID] = next + + let payload = RunProfileMutationRequestPayload( + projectID: projectID, + operation: .upsert, + profile: profile, + profileID: profile.id + ) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileMutationRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent save request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send run profile update: \(error.localizedDescription)" + logger.error("[RunProfiles] save send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func deleteRunProfile(projectID: UUID, profileID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] delete dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + return + } + runProfilesByProject[projectID]?.removeAll { $0.id == profileID } + let payload = RunProfileMutationRequestPayload( + projectID: projectID, + operation: .delete, + profileID: profileID + ) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileMutationRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent delete request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send run profile delete: \(error.localizedDescription)" + logger.error("[RunProfiles] delete send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func runProfile(projectID: UUID, profileID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] run dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + return + } + let payload = RunProfileRunRequestPayload(projectID: projectID, profileID: profileID) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileRunRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent run request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send run request: \(error.localizedDescription)" + logger.error("[RunProfiles] run send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func stopRunTask(_ task: MobileRunTaskSnapshot) async { + guard isPaired else { + logger.error("[RunProfiles] stop dropped because mobile is not paired task=\(task.taskId.uuidString, privacy: .public)") + return + } + let payload = RunProfileStopRequestPayload( + taskID: task.taskId, + projectID: task.projectId, + profileID: task.profileId + ) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileStopRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent stop request id=\(payload.clientRequestID.uuidString, privacy: .public) task=\(task.taskId.uuidString, privacy: .public) project=\(task.projectId.uuidString, privacy: .public) profile=\(task.profileId.uuidString, privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send stop request: \(error.localizedDescription)" + logger.error("[RunProfiles] stop send failed id=\(payload.clientRequestID.uuidString, privacy: .public) task=\(task.taskId.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + /// Update the search query and dispatch a debounced search request to the /// paired desktop. Empty queries clear results without hitting the network. /// Stale requests are discarded by `clientRequestID`. @@ -809,8 +921,13 @@ final class MobileAppState: ObservableObject { desktopSettings = nil desktopUsage = nil desktopHostMetrics = nil + desktopWebProxy = nil projectBranches = [:] availableBranchesByProject = [:] + runProfilesByProject = [:] + runTasks = [] + inFlightRunProfileRequests = [] + lastRunProfileError = nil inFlightBranchOps = [] lastBranchOpError = nil messagesBySession = [:] @@ -836,15 +953,15 @@ final class MobileAppState: ObservableObject { private func handle(_ event: RelayClient.Event) { switch event { case .stateChanged(let state): - logger.info("relay connection state changed: \(String(describing: state), privacy: .public)") + logger.info("[Relay] connection state changed: \(String(describing: state), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") let previous = connectionState connectionState = state triggerConnectionFeedback(from: previous, to: state) if case .connected = state, isPaired { - Task { await self.requestSnapshot() } + Task { await self.requestSnapshot(reason: "relay_connected") } } - case .deliveryFailed: - break + case .deliveryFailed(let toHex): + logger.warning("[Relay] delivery failed to desktopKey=\(String(toHex.prefix(12)), privacy: .public)") case .inbound(let inbound): handleInbound(inbound) } @@ -881,6 +998,9 @@ final class MobileAppState: ObservableObject { Task { await self.removePairedDesktopAfterRemoteUnpair(desktop) } case .snapshot(let snap): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "snapshot") else { return } + let profileProjectCount = snap.runProfiles?.count ?? 0 + let profileTotal = snap.runProfiles?.reduce(0) { $0 + $1.profiles.count } ?? 0 + logger.info("[MobileSync] applying snapshot projects=\(snap.projects.count, privacy: .public) sessions=\(snap.sessions.count, privacy: .public) runProfileProjects=\(profileProjectCount, privacy: .public) runProfileTotal=\(profileTotal, privacy: .public) runTasks=\(snap.runTasks?.count ?? 0, privacy: .public) from desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") projects = snap.projects sessions = snap.sessions branchBriefings = snap.branchBriefings ?? [] @@ -888,6 +1008,20 @@ final class MobileAppState: ObservableObject { desktopSettings = snap.settings desktopUsage = snap.usage desktopHostMetrics = snap.hostMetrics + desktopWebProxy = snap.webProxy + if let webProxy = snap.webProxy { + logger.info("[WebBrowserSync] snapshot web proxy host=\(webProxy.host, privacy: .public) port=\(webProxy.port, privacy: .public)") + } else { + logger.warning("[WebBrowserSync] snapshot missing web proxy info") + } + if let runProfiles = snap.runProfiles { + runProfilesByProject = Dictionary( + uniqueKeysWithValues: runProfiles.map { ($0.projectId, $0.profiles) } + ) + } + if let tasks = snap.runTasks { + runTasks = tasks.sorted { $0.startedAt > $1.startedAt } + } if let branches = snap.projectBranches { projectBranches = Dictionary(uniqueKeysWithValues: branches.map { ($0.projectId, $0.currentBranch) }) availableBranchesByProject = Dictionary( @@ -971,6 +1105,13 @@ final class MobileAppState: ObservableObject { } else { remoteProjectCreateError = result.errorMessage ?? "Failed to add project." } + case .runProfileResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_profile_result") else { return } + logger.info("[RunProfiles] received result id=\(result.clientRequestID.uuidString, privacy: .public) ok=\(result.ok, privacy: .public) project=\(result.projectID.uuidString, privacy: .public) profiles=\(result.profiles?.count ?? 0, privacy: .public) task=\(result.task?.taskId.uuidString ?? "", privacy: .public) error=\(result.errorMessage ?? "", privacy: .public)") + applyRunProfileResult(result) + case .runTaskUpdate(let update): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_task_update") else { return } + upsertRunTask(update.task) case .ping: guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } @@ -979,6 +1120,32 @@ final class MobileAppState: ObservableObject { } } + private func applyRunProfileResult(_ result: RunProfileResultPayload) { + inFlightRunProfileRequests.remove(result.clientRequestID) + if let profiles = result.profiles { + runProfilesByProject[result.projectID] = profiles + logger.info("[RunProfiles] applied result profiles project=\(result.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") + } + if let task = result.task { + upsertRunTask(task) + } + if result.ok { + lastRunProfileError = nil + Task { await self.requestSnapshot() } + } else { + lastRunProfileError = result.errorMessage ?? "Run profile operation failed." + } + } + + private func upsertRunTask(_ task: MobileRunTaskSnapshot) { + if let idx = runTasks.firstIndex(where: { $0.taskId == task.taskId }) { + runTasks[idx] = task + } else { + runTasks.insert(task, at: 0) + } + runTasks.sort { $0.startedAt > $1.startedAt } + } + private func applySessionUpdate(_ update: SessionUpdatePayload) { if let previous = update.previousSessionID, previous != update.sessionID { if let carried = messagesBySession.removeValue(forKey: previous) { @@ -1069,7 +1236,9 @@ final class MobileAppState: ObservableObject { isStreaming: isStreaming, attention: current.attention, progress: current.progress, - queuedMessages: current.queuedMessages + todos: current.todos, + queuedMessages: current.queuedMessages, + hasUncheckedCompletion: current.hasUncheckedCompletion ) } @@ -1081,10 +1250,22 @@ final class MobileAppState: ObservableObject { } } - private func requestSnapshot() async { - guard isPaired else { return } + func refreshFromDesktop(reason: String) async { + await requestSnapshot(reason: reason) + } + + private func requestSnapshot(reason: String = "manual") async { + guard isPaired else { + logger.info("[MobileSync] snapshot request skipped reason=\(reason, privacy: .public): mobile is not paired") + return + } let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) - try? await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) + do { + try await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) + logger.info("[MobileSync] requested snapshot reason=\(reason, privacy: .public) activeSession=\(self.activeSessionID ?? "", privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") + } catch { + logger.error("[MobileSync] snapshot request failed reason=\(reason, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } } private func failPairing(_ message: String) { diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 17cab5c..d9ddc79 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -180,12 +180,12 @@ struct MobileBriefingView: View { Button { showAllBranches = false } label: { - Label("Current branch", systemImage: showAllBranches ? "" : "checkmark") + menuSelectionLabel("Current branch", isSelected: !showAllBranches) } Button { showAllBranches = true } label: { - Label("All branches", systemImage: showAllBranches ? "checkmark" : "") + menuSelectionLabel("All branches", isSelected: showAllBranches) } } @@ -195,16 +195,13 @@ struct MobileBriefingView: View { Button { selectedProjectIds.removeAll() } label: { - Label("All projects", systemImage: selectedProjectIds.isEmpty ? "checkmark" : "") + menuSelectionLabel("All projects", isSelected: selectedProjectIds.isEmpty) } ForEach(projects) { project in Button { toggleProject(project.id) } label: { - Label( - project.name, - systemImage: selectedProjectIds.contains(project.id) ? "checkmark" : "" - ) + menuSelectionLabel(project.name, isSelected: selectedProjectIds.contains(project.id)) } } } @@ -216,6 +213,15 @@ struct MobileBriefingView: View { } } + @ViewBuilder + private func menuSelectionLabel(_ title: String, isSelected: Bool) -> some View { + if isSelected { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + // MARK: - Empty state @ViewBuilder diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index e13fd0c..95967ec 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -20,6 +20,8 @@ struct MobileChatView: View { @State private var showingArchiveConfirm = false @State private var showingDeleteConfirm = false @State private var showingTodoSheet = false + @State private var showingRunProfiles = false + @State private var showingBrowser = false /// The question request whose sheet is currently presented, if any. @State private var presentedQuestion: PendingQuestionPayload? /// The plan whose review sheet is currently presented, if any. @@ -81,6 +83,25 @@ struct MobileChatView: View { summary: threadSummary ) } + .sheet(isPresented: $showingRunProfiles) { + if let projectID = currentProjectID { + NavigationStack { + MobileRunProfilesView(projectID: projectID) + .environmentObject(state) + .task { + await state.refreshFromDesktop(reason: "open_run_profiles") + } + } + } + } + .fullScreenCover(isPresented: $showingBrowser) { + NavigationStack { + MobileInAppBrowserView( + initialURL: browserLaunchURL, + proxyInfo: state.desktopWebProxy + ) + } + } .sheet(item: $presentedQuestion) { question in MobileQuestionSheet( request: question, @@ -162,6 +183,18 @@ struct MobileChatView: View { } ToolbarItem(placement: .topBarTrailing) { Menu { + Button { + showingBrowser = true + } label: { + Label("Open in Browser", systemImage: "globe") + } + Button { + showingRunProfiles = true + } label: { + Label("Run Profiles", systemImage: "play.rectangle") + } + .disabled(currentProjectID == nil) + Divider() Button { showingRenameSheet = true } label: { @@ -384,15 +417,30 @@ struct MobileChatView: View { state.messagesBySession[sessionID] ?? [] } + private var currentProjectID: UUID? { + state.sessions.first(where: { $0.id == sessionID })?.projectId + } + + private var browserLaunchURL: URL? { + let threadURL = MobileBrowserURLDetector.detect(in: messages.map(\.content)) + if let threadURL { return threadURL } + guard let projectID = currentProjectID else { return nil } + let taskText = state.runTasks(for: projectID).flatMap { task in + [task.commandPreview, task.terminalOutputTail ?? ""] + } + return MobileBrowserURLDetector.detect(in: taskText) + } + private var title: String { state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" } - /// Live todos extracted from the most recent `TodoWrite` tool call in the - /// thread's messages — the same source the desktop toolbar pill uses. - /// `nil` when the thread has never emitted a todo list. + /// Live todos from synced messages when available, otherwise from the + /// desktop session summary. Codex plan updates arrive as desktop-owned + /// snapshots rather than `TodoWrite` message tool calls. private var todos: [TodoItem]? { TodoExtractor.latest(in: messages) + ?? state.sessions.first(where: { $0.id == sessionID })?.todos } /// The desktop-generated summary for this thread, if one exists. Summaries diff --git a/RxCodeMobile/Views/MobileInAppBrowserView.swift b/RxCodeMobile/Views/MobileInAppBrowserView.swift new file mode 100644 index 0000000..cc7f53d --- /dev/null +++ b/RxCodeMobile/Views/MobileInAppBrowserView.swift @@ -0,0 +1,798 @@ +import Foundation +import Network +import os.log +import RxCodeSync +import SwiftUI +import WebKit +import _WebKit_SwiftUI + +enum MobileBrowserURLDetector { + static func detect(in texts: [String]) -> URL? { + let candidates = texts.reversed().flatMap(extractURLs) + return candidates.first(where: isLocalDevURL) ?? candidates.first + } + + private nonisolated static func extractURLs(from text: String) -> [URL] { + let pattern = #"https?://[^\s<>"')\]]+"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard let range = Range(match.range, in: text) else { return nil } + let raw = String(text[range]) + .trimmingCharacters(in: CharacterSet(charactersIn: ".,;:!?")) + return URL(string: raw) + } + } + + nonisolated static func isLocalDevURL(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" + } + + nonisolated static func desktopProxyBootstrapURL(for url: URL, proxyInfo: MobileWebProxyInfo?) -> URL { + guard let proxyInfo, + isLocalDevURL(url), + url.scheme?.lowercased() == "http" + else { + return url + } + + var components = URLComponents() + components.scheme = "http" + components.host = proxyInfo.host + components.port = proxyInfo.port + components.path = "/__rxcode_browser" + components.queryItems = [ + URLQueryItem(name: "target", value: url.absoluteString), + URLQueryItem(name: "token", value: proxyInfo.password) + ] + return components.url ?? url + } + + nonisolated static func userFacingURL( + for url: URL, + currentDisplayURL: URL?, + proxyInfo: MobileWebProxyInfo? + ) -> URL { + if let bootstrapTarget = reverseBootstrapTargetURL(for: url, proxyInfo: proxyInfo) { + return bootstrapTarget + } + + guard isProxyURL(url, proxyInfo: proxyInfo), + let displayOrigin = currentDisplayURL.flatMap(originURL(for:)), + isLocalDevURL(displayOrigin) + else { + return url + } + + var components = URLComponents(url: displayOrigin, resolvingAgainstBaseURL: false) + let proxyComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + let proxyPath = proxyComponents?.percentEncodedPath ?? url.path + components?.percentEncodedPath = proxyPath.isEmpty ? "/" : proxyPath + components?.percentEncodedQuery = proxyComponents?.percentEncodedQuery + components?.percentEncodedFragment = proxyComponents?.percentEncodedFragment + return components?.url ?? displayOrigin + } + + private nonisolated static func reverseBootstrapTargetURL(for url: URL, proxyInfo: MobileWebProxyInfo?) -> URL? { + guard let proxyInfo, + url.host == proxyInfo.host, + url.port == proxyInfo.port, + url.path == "/__rxcode_browser", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let target = components.queryItems?.first(where: { $0.name == "target" })?.value, + let targetURL = URL(string: target) + else { + return nil + } + return targetURL + } + + private nonisolated static func isProxyURL(_ url: URL, proxyInfo: MobileWebProxyInfo?) -> Bool { + guard let proxyInfo else { return false } + return url.host == proxyInfo.host && url.port == proxyInfo.port + } + + private nonisolated static func originURL(for url: URL) -> URL? { + guard let scheme = url.scheme, let host = url.host else { return nil } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = url.port + components.path = "/" + return components.url + } +} + +struct MobileInAppBrowserView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + let proxyInfo: MobileWebProxyInfo? + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileInAppBrowser") + @State private var addressText: String + @State private var loadedURL: URL? + @State private var displayedURL: URL? + @State private var webPage: WebPage + @State private var errorText: String? + @State private var isAddressEditing = false + @State private var isBottomAddressCollapsed = false + @State private var didTriggerPullRefresh = false + @State private var pageBackgroundColor: Color = Color(.systemBackground) + @FocusState private var isAddressFocused: Bool + + init(initialURL: URL?, proxyInfo: MobileWebProxyInfo?) { + self.proxyInfo = proxyInfo + _addressText = State(initialValue: initialURL?.absoluteString ?? "") + _loadedURL = State(initialValue: initialURL.map { + MobileBrowserURLDetector.desktopProxyBootstrapURL(for: $0, proxyInfo: proxyInfo) + }) + _displayedURL = State(initialValue: initialURL) + _webPage = State(initialValue: Self.makeWebPage(proxyInfo: proxyInfo)) + } + + var body: some View { + GeometryReader { geometry in + let contentInsets = webContentPadding(safeArea: geometry.safeAreaInsets) + + ZStack { + // Fills the screen behind the web view so the status bar strip + // and the area behind the floating address bar take on the + // page color — matching Safari. + pageBackgroundColor + .ignoresSafeArea() + + browserSurface(insets: contentInsets) + + if loadedURL == nil { + startSurface + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } + + browserChrome(safeArea: geometry.safeAreaInsets) + } + .background(Color(.systemBackground)) + .ignoresSafeArea() + .animation(.spring(response: 0.28, dampingFraction: 0.88), value: isAddressEditing) + .animation(.spring(response: 0.28, dampingFraction: 0.88), value: loadedURL == nil) + .animation(.spring(response: 0.24, dampingFraction: 0.86), value: isBottomAddressCollapsed) + .animation(.easeOut(duration: 0.25), value: pageBackgroundColor) + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .task { + await observePageNavigations() + } + .onAppear { + guard let loadedURL, webPage.url == nil else { return } + logger.info("[WebBrowserSync] webview initial load url=\(loadedURL.absoluteString, privacy: .public) hasProxy=\((proxyInfo != nil), privacy: .public)") + webPage.load(loadedURL) + } + .onChange(of: webPage.url) { _, url in + guard let url else { return } + displayedURL = MobileBrowserURLDetector.userFacingURL( + for: url, + currentDisplayURL: displayedURL, + proxyInfo: proxyInfo + ) + } + .onChange(of: webPage.isLoading) { _, isLoading in + guard !isLoading else { return } + Task { await refreshPageBackgroundColor() } + } + .alert("Browser Error", isPresented: Binding( + get: { errorText != nil }, + set: { if !$0 { errorText = nil } } + )) { + Button("OK", role: .cancel) { errorText = nil } + } message: { + Text(errorText ?? "") + } + } + + @ViewBuilder + private func browserSurface(insets: EdgeInsets) -> some View { + if loadedURL == nil { + Color.clear + } else { + WebView(webPage) + .webViewBackForwardNavigationGestures(.enabled) + .webViewContentBackground(.visible) + .webViewOnScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + } action: { oldY, newY in + updateBrowserChromeForScroll(oldY: oldY, newY: newY) + } + .padding(.top, insets.top) + .padding(.bottom, insets.bottom) + .ignoresSafeArea(edges: .horizontal) + } + } + + /// Frame padding that keeps the web view between the status bar and the + /// floating address bar. The new SwiftUI `WebView` does not honor scroll + /// content insets — `contentMargins`, `safeAreaInset` and `safeAreaPadding` + /// are all ignored — so the web view frame itself must be inset. The status + /// bar strip left above the web view is filled with `pageBackgroundColor` + /// to match Safari. + private func webContentPadding(safeArea: EdgeInsets) -> EdgeInsets { + guard loadedURL != nil else { + return EdgeInsets() + } + + if horizontalSizeClass == .regular { + // iPad: reserve the status bar + the floating top chrome bar. + // `iPadChrome`: .padding(.top, safeArea.top + 10), .padding(.vertical, 10) + // around a 48pt-tall address field. + let topBarHeight: CGFloat = 48 + 20 + return EdgeInsets( + top: safeArea.top + 10 + topBarHeight + 10, + leading: 0, + bottom: safeArea.bottom, + trailing: 0 + ) + } + + // iPhone: reserve the status bar at the top and the floating address + // bar at the bottom. A constant (expanded) address bar height is used + // so the web view does not reflow when the bar collapses on scroll. + // `addressDisplayBar`: capsule height 46 (expanded), placed with + // `.padding(.bottom, safeArea.bottom + 18)` in `iPhoneChrome`. + let expandedAddressBarHeight: CGFloat = 46 + return EdgeInsets( + top: safeArea.top, + leading: 0, + bottom: safeArea.bottom + 18 + expandedAddressBarHeight + 8, + trailing: 0 + ) + } + + private var startSurface: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + favoritesSection + privacyReport + Spacer(minLength: 180) + } + .padding(.horizontal, horizontalSizeClass == .regular ? 44 : 16) + .padding(.top, horizontalSizeClass == .regular ? 96 : 88) + .frame(maxWidth: horizontalSizeClass == .regular ? 760 : .infinity, alignment: .leading) + .frame(maxWidth: .infinity) + } + .scrollIndicators(.hidden) + .background(Color(.systemGroupedBackground).opacity(0.96)) + .ignoresSafeArea() + } + + private var favoritesSection: some View { + VStack(alignment: .leading, spacing: 18) { + Label("Favorites", systemImage: "person.fill") + .font(.largeTitle.weight(.bold)) + + HStack(spacing: 22) { + favoriteButton(title: "Apple", systemImage: "apple.logo", url: "https://apple.com") + favoriteButton(title: "Bing", systemImage: "b.circle.fill", url: "https://bing.com") + favoriteButton(title: "Google", systemImage: "g.circle.fill", url: "https://google.com") + favoriteButton(title: "Yahoo", systemImage: "y.circle.fill", url: "https://yahoo.com") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var privacyReport: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Privacy Report") + .font(.title.weight(.bold)) + + HStack(spacing: 18) { + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 24, weight: .semibold)) + Text("RxCode keeps this browser session separate from your regular Safari data.") + .font(.title3) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 28) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 26, style: .continuous)) + } + } + + private func favoriteButton(title: String, systemImage: String, url: String) -> some View { + Button { + MobileHaptics.buttonTap() + addressText = url + loadAddress() + } label: { + VStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 72, height: 72) + .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 18, style: .continuous)) + + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + } + .frame(width: 82) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func browserChrome(safeArea: EdgeInsets) -> some View { + if horizontalSizeClass == .regular { + iPadChrome(safeArea: safeArea) + } else { + iPhoneChrome(safeArea: safeArea) + } + } + + private func iPadChrome(safeArea: EdgeInsets) -> some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + glassIconButton("xmark", action: closeBrowser) + + glassIconButton("chevron.backward", isEnabled: canGoBack) { + goBack() + } + + glassIconButton("chevron.forward", isEnabled: canGoForward) { + goForward() + } + + addressField(isCompact: true) + .frame(maxWidth: 620) + + glassIconButton(isLoading ? "xmark" : "arrow.clockwise", isEnabled: loadedURL != nil) { + reloadOrStop() + } + + browserMenu() + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28, style: .continuous)) + .padding(.top, safeArea.top + 10) + .padding(.horizontal, 24) + + Spacer() + } + } + + private func iPhoneChrome(safeArea: EdgeInsets) -> some View { + VStack(spacing: 0) { + HStack { + Spacer() + glassIconButton("xmark", action: closeBrowser) + } + .padding(.top, safeArea.top + 8) + .padding(.horizontal, 18) + + Spacer() + + if isAddressEditing || loadedURL == nil { + HStack(spacing: 8) { + addressField(isCompact: false) + + glassIconButton("xmark", size: 44) { + if loadedURL == nil { + closeBrowser() + } else { + cancelAddressEditing() + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, safeArea.bottom + 18) + } else { + HStack(spacing: 8) { + if !isBottomAddressCollapsed { + glassIconButton("chevron.left", size: 46, isEnabled: canGoBack) { + goBack() + } + } else { + Spacer(minLength: 0) + } + + addressDisplayBar + + if !isBottomAddressCollapsed { + browserMenu(size: 46) + } else { + Spacer(minLength: 0) + } + } + .padding(.horizontal, 16) + .padding(.bottom, safeArea.bottom + 18) + } + } + } + + private var addressDisplayBar: some View { + HStack(spacing: 10) { + Button { + beginAddressEditing() + } label: { + HStack(spacing: 10) { + if !isBottomAddressCollapsed { + Image(systemName: "globe") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + + Text(isBottomAddressCollapsed ? collapsedDisplayTitle : displayTitle) + .font(.system(size: isBottomAddressCollapsed ? 14 : 17, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.72) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if !isBottomAddressCollapsed { + Button { + MobileHaptics.buttonTap() + reloadOrStop() + } label: { + Image(systemName: isLoading ? "xmark" : "arrow.clockwise") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 30, height: 30) + .contentShape(Circle()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, isBottomAddressCollapsed ? 12 : 14) + .frame(width: isBottomAddressCollapsed ? 170 : nil, height: isBottomAddressCollapsed ? 40 : 46) + .glassEffect(.regular.interactive(), in: .capsule) + .overlay(alignment: .bottom) { + addressLoadingProgressBar + } + } + + private func addressField(isCompact: Bool) -> some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.secondary) + + TextField("Search or enter website name", text: $addressText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + .submitLabel(.go) + .focused($isAddressFocused) + .onSubmit(loadAddress) + .font(.system(size: isCompact ? 17 : 17, weight: .semibold)) + + if !addressText.isEmpty { + Button { + addressText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + Button("Go", action: loadAddress) + .font(.system(size: isCompact ? 15 : 15, weight: .bold)) + .disabled(normalizedAddressURL == nil) + } + .padding(.horizontal, isCompact ? 14 : 14) + .frame(height: isCompact ? 48 : 48) + .glassEffect(.regular.interactive(), in: .capsule) + .overlay(alignment: .bottom) { + addressLoadingProgressBar + } + } + + @ViewBuilder + private var addressLoadingProgressBar: some View { + if isLoading { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.14)) + Capsule() + .fill(Color.accentColor) + .frame(width: max(10, proxy.size.width * CGFloat(clampedLoadingProgress))) + } + } + .frame(height: 2) + .padding(.horizontal, 14) + .padding(.bottom, 2) + .transition(.opacity) + } + } + + private func browserMenu(size: CGFloat = 58) -> some View { + Menu { + Button { + reloadOrStop() + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .disabled(loadedURL == nil) + + Button(role: .cancel, action: closeBrowser) { + Label("Close Browser", systemImage: "xmark") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: size <= 48 ? 18 : 20, weight: .bold)) + .foregroundStyle(.primary) + .frame(width: size, height: size) + .contentShape(Circle()) + .glassEffect(.regular.interactive(), in: .circle) + } + .buttonStyle(.plain) + } + + private func glassIconButton( + _ systemName: String, + size: CGFloat = 48, + isEnabled: Bool = true, + action: @escaping () -> Void + ) -> some View { + Button { + MobileHaptics.buttonTap() + action() + } label: { + Image(systemName: systemName) + .font(.system(size: size <= 48 ? 17 : 21, weight: .semibold)) + .foregroundStyle(isEnabled ? Color.primary : Color.secondary.opacity(0.45)) + .frame(width: size, height: size) + .contentShape(Circle()) + .glassEffect(.regular.interactive(), in: .circle) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } + + private var normalizedAddressURL: URL? { + let trimmed = addressText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), url.scheme != nil { + return url + } + return URL(string: "https://\(trimmed)") + } + + private func loadAddress() { + guard let url = normalizedAddressURL else { return } + let loadURL = MobileBrowserURLDetector.desktopProxyBootstrapURL(for: url, proxyInfo: proxyInfo) + addressText = url.absoluteString + loadedURL = loadURL + displayedURL = url + isAddressEditing = false + isAddressFocused = false + isBottomAddressCollapsed = false + webPage.load(loadURL) + logger.info("[WebBrowserSync] load address url=\(url.absoluteString, privacy: .public) loadURL=\(loadURL.absoluteString, privacy: .public) hasProxy=\((proxyInfo != nil), privacy: .public)") + } + + private var displayTitle: String { + guard let url = currentDisplayURL ?? loadedURL else { + return "Search" + } + + if let host = url.host { + if let port = url.port { + return "\(host):\(port)" + } + return host + } + return url.absoluteString + } + + private var collapsedDisplayTitle: String { + let title = webPage.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? displayTitle : title + } + + private func cancelAddressEditing() { + addressText = currentDisplayURL?.absoluteString ?? addressText + isAddressEditing = false + isAddressFocused = false + } + + private func beginAddressEditing() { + MobileHaptics.buttonTap() + addressText = currentDisplayURL?.absoluteString ?? addressText + isBottomAddressCollapsed = false + isAddressEditing = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 50_000_000) + isAddressFocused = true + } + } + + private func closeBrowser() { + dismiss() + } + + private var currentDisplayURL: URL? { + guard let url = webPage.url else { + return displayedURL + } + return MobileBrowserURLDetector.userFacingURL( + for: url, + currentDisplayURL: displayedURL, + proxyInfo: proxyInfo + ) + } + + private var canGoBack: Bool { + webPage.backForwardList.backList.last != nil + } + + private var canGoForward: Bool { + webPage.backForwardList.forwardList.first != nil + } + + private var isLoading: Bool { + webPage.isLoading + } + + private var clampedLoadingProgress: Double { + min(max(webPage.estimatedProgress, 0.05), 1) + } + + private func goBack() { + guard let item = webPage.backForwardList.backList.last else { return } + logger.info("[WebBrowserSync] webview command back canGoBack=true") + webPage.load(item) + } + + private func goForward() { + guard let item = webPage.backForwardList.forwardList.first else { return } + logger.info("[WebBrowserSync] webview command forward canGoForward=true") + webPage.load(item) + } + + private func reloadOrStop() { + guard loadedURL != nil else { return } + if webPage.isLoading { + logger.info("[WebBrowserSync] webview command stop url=\(webPage.url?.absoluteString ?? "", privacy: .public)") + webPage.stopLoading() + } else { + logger.info("[WebBrowserSync] webview command reload url=\(webPage.url?.absoluteString ?? "", privacy: .public)") + webPage.reload() + } + } + + private func updateBrowserChromeForScroll(oldY: CGFloat, newY: CGFloat) { + guard loadedURL != nil, + horizontalSizeClass != .regular + else { + return + } + + updatePullRefreshState(offsetY: newY) + + guard !isAddressEditing else { return } + let delta = newY - oldY + guard abs(delta) > 4 else { return } + if delta > 0 { + isBottomAddressCollapsed = true + } else { + isBottomAddressCollapsed = false + } + } + + private func updatePullRefreshState(offsetY: CGFloat) { + if offsetY > -12 { + didTriggerPullRefresh = false + } + + guard offsetY < -88, + !didTriggerPullRefresh, + !webPage.isLoading + else { + return + } + + didTriggerPullRefresh = true + MobileHaptics.buttonTap() + logger.info("[WebBrowserSync] pull refresh url=\(webPage.url?.absoluteString ?? "", privacy: .public)") + webPage.reload() + } + + /// Samples the loaded page's background color and uses it to fill the + /// status bar strip above the web view, mirroring how Safari tints the + /// status bar area with the page color. + private func refreshPageBackgroundColor() async { + guard loadedURL != nil else { return } + let script = """ + const transparent = (c) => !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)'; + const bodyBg = document.body ? getComputedStyle(document.body).backgroundColor : null; + if (!transparent(bodyBg)) { return bodyBg; } + const htmlBg = document.documentElement ? getComputedStyle(document.documentElement).backgroundColor : null; + return transparent(htmlBg) ? null : htmlBg; + """ + do { + let result = try await webPage.callJavaScript(script) + guard let cssColor = result as? String, + let color = Self.parseCSSColor(cssColor) + else { + return + } + pageBackgroundColor = color + } catch { + logger.error("[WebBrowserSync] page color sampling failed error=\(error.localizedDescription, privacy: .public)") + } + } + + /// Parses a CSS `rgb()` / `rgba()` color string into a SwiftUI `Color`. + private static func parseCSSColor(_ string: String) -> Color? { + let components = string + .lowercased() + .replacingOccurrences(of: "rgba", with: "") + .replacingOccurrences(of: "rgb", with: "") + .components(separatedBy: CharacterSet(charactersIn: "(), /")) + .filter { !$0.isEmpty } + guard components.count >= 3, + let red = Double(components[0]), + let green = Double(components[1]), + let blue = Double(components[2]) + else { + return nil + } + let alpha = components.count >= 4 ? (Double(components[3]) ?? 1) : 1 + guard alpha > 0 else { return nil } + return Color( + .sRGB, + red: red / 255, + green: green / 255, + blue: blue / 255, + opacity: alpha + ) + } + + private func observePageNavigations() async { + do { + for try await event in webPage.navigations { + logger.info("[WebBrowserSync] navigation event=\(String(describing: event), privacy: .public) url=\(webPage.url?.absoluteString ?? "", privacy: .public)") + } + } catch { + guard !Task.isCancelled else { return } + logger.error("[WebBrowserSync] navigation failed url=\(webPage.url?.absoluteString ?? "", privacy: .public) error=\(error.localizedDescription, privacy: .public)") + errorText = error.localizedDescription + } + } + + private static func makeWebPage(proxyInfo: MobileWebProxyInfo?) -> WebPage { + var configuration = WebPage.Configuration() + configuration.websiteDataStore = websiteDataStore(proxyInfo: proxyInfo) + return WebPage(configuration: configuration) + } + + private static func websiteDataStore(proxyInfo: MobileWebProxyInfo?) -> WKWebsiteDataStore { + let dataStore = WKWebsiteDataStore.nonPersistent() + guard let proxyInfo, + let port = UInt16(exactly: proxyInfo.port) + else { + Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileInAppBrowser") + .warning("[WebBrowserSync] webview proxy disabled: missing proxy info") + return dataStore + } + let endpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host(proxyInfo.host), + port: NWEndpoint.Port(rawValue: port)! + ) + var proxy = ProxyConfiguration(httpCONNECTProxy: endpoint) + proxy.matchDomains = ["localhost", "127.0.0.1", "0.0.0.0"] + proxy.allowFailover = false + proxy.applyCredential(username: proxyInfo.username, password: proxyInfo.password) + dataStore.proxyConfigurations = [proxy] + Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileInAppBrowser") + .info("[WebBrowserSync] webview proxy configured host=\(proxyInfo.host, privacy: .public) port=\(proxyInfo.port, privacy: .public)") + return dataStore + } +} diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 539d17a..1e55615 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -163,3 +163,334 @@ struct SessionsList: View { .accessibilityLabel(attention == .question ? "Question pending" : "Permission pending") } } + +struct MobileRunProfilesView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let projectID: UUID + @State private var editingProfile: RunProfile? + + private var project: Project? { + state.projects.first { $0.id == projectID } + } + + private var profiles: [RunProfile] { + state.runProfiles(for: projectID).sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private var tasks: [MobileRunTaskSnapshot] { + state.runTasks(for: projectID) + } + + var body: some View { + List { + if !tasks.isEmpty { + Section("Runs") { + ForEach(tasks) { task in + runTaskRow(task) + } + } + } + + Section("Profiles") { + if profiles.isEmpty { + ContentUnavailableView( + "No Run Profiles", + systemImage: "play.rectangle", + description: Text("Create a profile to run a command on your Mac.") + ) + } else { + ForEach(profiles) { profile in + profileRow(profile) + } + } + } + } + .navigationTitle(project?.name ?? "Run Profiles") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { + editingProfile = Self.newProfile(projectID: projectID) + } label: { + Image(systemName: "plus") + } + } + } + .refreshable { + await state.refreshSnapshot() + } + .sheet(item: $editingProfile) { profile in + NavigationStack { + MobileRunProfileEditorView(profile: profile, projectID: projectID) + .environmentObject(state) + } + } + .alert("Run Profile Error", isPresented: Binding( + get: { state.lastRunProfileError != nil }, + set: { if !$0 { state.lastRunProfileError = nil } } + )) { + Button("OK", role: .cancel) { state.lastRunProfileError = nil } + } message: { + Text(state.lastRunProfileError ?? "") + } + } + + private func profileRow(_ profile: RunProfile) -> some View { + let task = state.runningTask(projectID: projectID, profileID: profile.id) + return HStack(spacing: 12) { + Image(systemName: iconName(for: profile.type)) + .foregroundStyle(.secondary) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 3) { + Text(profile.name.isEmpty ? "Untitled" : profile.name) + .lineLimit(1) + Text(profileSubtitle(profile)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + if let task { + Button { + Task { await state.stopRunTask(task) } + } label: { + Image(systemName: "stop.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + } else { + Button { + Task { await state.runProfile(projectID: projectID, profileID: profile.id) } + } label: { + Image(systemName: "play.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .contentShape(Rectangle()) + .onTapGesture { + editingProfile = profile + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + Task { await state.deleteRunProfile(projectID: projectID, profileID: profile.id) } + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button("Edit") { editingProfile = profile } + Button("Duplicate") { + var copy = profile + copy.id = UUID() + copy.name = profile.name + " (copy)" + copy.createdAt = Date() + copy.updatedAt = Date() + Task { await state.saveRunProfile(copy, projectID: projectID) } + } + Button("Delete", role: .destructive) { + Task { await state.deleteRunProfile(projectID: projectID, profileID: profile.id) } + } + } + } + + private func runTaskRow(_ task: MobileRunTaskSnapshot) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(task.profileName, systemImage: task.isRunning ? "play.circle.fill" : "checkmark.circle") + .foregroundStyle(task.isRunning ? Color.accentColor : .secondary) + Spacer() + Text(task.statusLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + + if !task.commandPreview.isEmpty { + Text(task.commandPreview) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(4) + } + + if let output = task.terminalOutputTail, !output.isEmpty { + ScrollView([.horizontal, .vertical]) { + Text(output) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .frame(maxHeight: 180) + } + + if task.isRunning { + Button(role: .destructive) { + Task { await state.stopRunTask(task) } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) + } + + private func iconName(for type: RunProfileType) -> String { + switch type { + case .bash: return "terminal" + case .xcode: return "hammer.fill" + case .make: return "wrench.and.screwdriver.fill" + } + } + + private func profileSubtitle(_ profile: RunProfile) -> String { + switch profile.type { + case .bash: + return profile.bash.command.isEmpty ? "Bash command" : profile.bash.command + case .xcode: + let xcode = profile.xcode ?? XcodeRunConfig() + return [xcode.container, xcode.scheme, xcode.action.rawValue].filter { !$0.isEmpty }.joined(separator: " · ") + case .make: + let make = profile.make ?? MakeRunConfig() + return ["make", make.target, make.arguments].filter { !$0.isEmpty }.joined(separator: " ") + } + } + + private static func newProfile(projectID: UUID) -> RunProfile { + let now = Date() + return RunProfile( + projectId: projectID, + name: "New Bash Configuration", + type: .bash, + bash: BashRunConfig(), + createdAt: now, + updatedAt: now + ) + } +} + +private struct MobileRunProfileEditorView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var draft: RunProfile + let projectID: UUID + + init(profile: RunProfile, projectID: UUID) { + self._draft = State(initialValue: profile) + self.projectID = projectID + } + + var body: some View { + Form { + Section("Configuration") { + TextField("Name", text: $draft.name) + Picker("Type", selection: Binding( + get: { draft.type }, + set: { type in + draft.type = type + if type == .xcode, draft.xcode == nil { draft.xcode = XcodeRunConfig() } + if type == .make, draft.make == nil { draft.make = MakeRunConfig() } + } + )) { + Text("Bash").tag(RunProfileType.bash) + Text("Xcode").tag(RunProfileType.xcode) + Text("Make").tag(RunProfileType.make) + } + } + + switch draft.type { + case .bash: + bashSection + case .xcode: + xcodeSection + case .make: + makeSection + } + } + .navigationTitle(draft.name.isEmpty ? "Run Profile" : draft.name) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + var saved = draft + saved.projectId = projectID + saved.updatedAt = Date() + Task { + await state.saveRunProfile(saved, projectID: projectID) + dismiss() + } + } + .disabled(draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + private var bashSection: some View { + Section("Command") { + TextEditor(text: $draft.bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 90) + TextField("Working Directory", text: $draft.bash.workingDirectory, prompt: Text("Project root")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + + private var xcodeSection: some View { + Section("Xcode") { + let xcode = Binding( + get: { draft.xcode ?? XcodeRunConfig() }, + set: { draft.xcode = $0 } + ) + TextField("Project / Workspace", text: xcode.container, prompt: Text("App.xcodeproj")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + Toggle("Use Workspace", isOn: xcode.isWorkspace) + TextField("Scheme", text: xcode.scheme) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Configuration", text: xcode.configuration) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + Picker("Action", selection: xcode.action) { + ForEach(XcodeAction.allCases, id: \.self) { action in + Text(action.rawValue.capitalized).tag(action) + } + } + TextField("Destination", text: xcode.destination, prompt: Text("Optional xcodebuild destination")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + + private var makeSection: some View { + Section("Make") { + let make = Binding( + get: { draft.make ?? MakeRunConfig() }, + set: { draft.make = $0 } + ) + TextField("Makefile", text: make.makefile, prompt: Text("Makefile")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Target", text: make.target) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Arguments", text: make.arguments) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Working Directory", text: $draft.bash.workingDirectory, prompt: Text("Project root")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } +} diff --git a/relay-server/k8s/ingress.yaml b/relay-server/k8s/ingress.yaml index 2a643d1..257153d 100644 --- a/relay-server/k8s/ingress.yaml +++ b/relay-server/k8s/ingress.yaml @@ -19,10 +19,10 @@ spec: ingressClassName: nginx tls: - hosts: - - relay.rxlab.app + - relay.code.rxlab.app secretName: rxcode-relay-tls rules: - - host: relay.rxlab.app + - host: relay.code.rxlab.app http: paths: - path: / diff --git a/relay-server/relay.go b/relay-server/relay.go index 96c0d11..9334229 100644 --- a/relay-server/relay.go +++ b/relay-server/relay.go @@ -12,7 +12,10 @@ import ( ) const ( - maxEnvelopeBytes = 256 * 1024 + // Keep this aligned with RelayClient.maxWebSocketMessageSize. Mobile + // thread snapshots are paged, but an encrypted envelope still carries + // active messages, session summaries, briefings, and base64 overhead. + maxEnvelopeBytes = 10 * 1024 * 1024 pongWait = 90 * time.Second pingPeriod = 25 * time.Second writeWait = 10 * time.Second