From 8814c9c816a6001d0183559efb189b9c86a1c76b Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 18:27:58 +0800 Subject: [PATCH 1/8] feat: add rxcode mobile target --- .gitignore | 1 + Packages/Package.swift | 8 +- .../RxCodeChatKit/AttachmentPreviewItem.swift | 3 + .../RxCodeChatKit/BashTerminalSheet.swift | 9 + .../Sources/RxCodeChatKit/ChatBridge.swift | 16 +- Packages/Sources/RxCodeChatKit/ChatView.swift | 3 + .../Sources/RxCodeChatKit/FileDiffView.swift | 17 +- .../Sources/RxCodeChatKit/IMETextView.swift | 2 + .../RxCodeChatKit/ImagePreviewSheet.swift | 2 + .../RxCodeChatKit/IndentUtilities.swift | 17 + .../Sources/RxCodeChatKit/InputBarView.swift | 3 + .../Sources/RxCodeChatKit/MarkdownView.swift | 2 + .../Sources/RxCodeChatKit/MessageBubble.swift | 2 + .../RxCodeChatKit/MessageListView.swift | 11 +- .../Sources/RxCodeChatKit/PlanCardView.swift | 2 + .../Sources/RxCodeChatKit/PlanLogic.swift | 149 ++++++ .../Sources/RxCodeChatKit/PlanSheetView.swift | 2 + .../RxCodeChatKit/ShortcutManagerView.swift | 3 + .../RxCodeChatKit/SlashCommandBar.swift | 3 + .../SlashCommandManagerView.swift | 3 + .../RxCodeChatKit/TextPreviewSheet.swift | 9 + .../RxCodeChatKit/WebPreviewButton.swift | 3 + .../CLISession/CLISessionStore.swift | 2 + .../RxCodeCore/CLISession/PickerExposer.swift | 4 + .../RxCodeCore/Models/Attachment.swift | 4 + .../RxCodeCore/Theme/ClaudeTheme.swift | 29 ++ .../Utilities/ClipboardHelper.swift | 10 +- .../Utilities/DirectoryWatcher.swift | 6 +- .../RxCodeCore/Utilities/GitHelper.swift | 3 + .../RxCodeCore/Utilities/GitURLHelpers.swift | 4 + .../Utilities/GitWorktreeService.swift | 4 + .../RxCodeCore/Utilities/PathEncoding.swift | 4 + .../Utilities/SyntaxHighlighter.swift | 16 + .../RxCodeSync/APNs/EncryptedAlert.swift | 81 ++++ .../RxCodeSync/Crypto/DeviceIdentity.swift | 113 +++++ .../RxCodeSync/Crypto/SessionCrypto.swift | 78 +++ .../Sources/RxCodeSync/Pairing/Pairing.swift | 79 +++ .../RxCodeSync/Protocol/Envelope.swift | 29 ++ .../Sources/RxCodeSync/Protocol/Payload.swift | 295 +++++++++++ Packages/Sources/RxCodeSync/SyncClient.swift | 68 +++ .../RxCodeSync/Transport/RelayClient.swift | 233 +++++++++ RxCode.xcodeproj/project.pbxproj | 456 ++++++++++++++++++ RxCode/App/RxCodeApp.swift | 1 + RxCode/Services/MobileSyncService.swift | 318 ++++++++++++ RxCode/Services/NotificationService.swift | 40 ++ RxCode/Views/Settings/MobileSettingsTab.swift | 239 +++++++++ RxCode/Views/SettingsView.swift | 6 + RxCodeMobile/AppDelegate.swift | 44 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ RxCodeMobile/Assets.xcassets/Contents.json | 6 + RxCodeMobile/ContentView.swift | 61 +++ RxCodeMobile/Info.plist | 13 + RxCodeMobile/Item.swift | 18 + RxCodeMobile/RxCodeMobile.entitlements | 10 + RxCodeMobile/RxCodeMobileApp.swift | 32 ++ RxCodeMobile/SETUP.md | 131 +++++ RxCodeMobile/State/MobileAppState.swift | 247 ++++++++++ RxCodeMobile/Views/MobileChatView.swift | 47 ++ RxCodeMobile/Views/MobileInputBar.swift | 35 ++ RxCodeMobile/Views/MobileMessageBubble.swift | 60 +++ RxCodeMobile/Views/MobileSettingsView.swift | 72 +++ RxCodeMobile/Views/OnboardingView.swift | 72 +++ .../Views/PermissionApprovalSheet.swift | 67 +++ RxCodeMobile/Views/ProjectsSidebar.swift | 22 + RxCodeMobile/Views/QRScannerView.swift | 63 +++ RxCodeMobile/Views/RootView.swift | 60 +++ RxCodeMobile/Views/SessionsList.swift | 44 ++ RxCodeMobileNotificationService/Info.plist | 31 ++ .../NotificationService.swift | 50 ++ ...CodeMobileNotificationService.entitlements | 10 + RxCodeMobileTests/RxCodeMobileTests.swift | 36 ++ RxCodeMobileUITests/RxCodeMobileUITests.swift | 41 ++ .../RxCodeMobileUITestsLaunchTests.swift | 33 ++ RxCodeTests/RxCodeMobile/AppDelegate.swift | 44 ++ RxCodeTests/RxCodeMobile/Info.plist | 53 ++ .../RxCodeMobile/RxCodeMobile.entitlements | 12 + .../RxCodeMobile/RxCodeMobileApp.swift | 19 + RxCodeTests/RxCodeMobile/SETUP.md | 131 +++++ .../RxCodeMobile/State/MobileAppState.swift | 247 ++++++++++ .../RxCodeMobile/Views/MobileChatView.swift | 47 ++ .../RxCodeMobile/Views/MobileInputBar.swift | 35 ++ .../Views/MobileMessageBubble.swift | 60 +++ .../Views/MobileSettingsView.swift | 72 +++ .../RxCodeMobile/Views/OnboardingView.swift | 72 +++ .../Views/PermissionApprovalSheet.swift | 67 +++ .../RxCodeMobile/Views/ProjectsSidebar.swift | 22 + .../RxCodeMobile/Views/QRScannerView.swift | 63 +++ RxCodeTests/RxCodeMobile/Views/RootView.swift | 60 +++ .../RxCodeMobile/Views/SessionsList.swift | 44 ++ relay-server/Dockerfile | 11 + relay-server/README.md | 88 ++++ relay-server/go.mod | 14 + relay-server/go.sum | 35 ++ relay-server/health.go | 27 ++ relay-server/main.go | 64 +++ relay-server/push.go | 137 ++++++ relay-server/relay.go | 233 +++++++++ 98 files changed, 5370 insertions(+), 30 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/IndentUtilities.swift create mode 100644 Packages/Sources/RxCodeChatKit/PlanLogic.swift create mode 100644 Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift create mode 100644 Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift create mode 100644 Packages/Sources/RxCodeSync/Crypto/SessionCrypto.swift create mode 100644 Packages/Sources/RxCodeSync/Pairing/Pairing.swift create mode 100644 Packages/Sources/RxCodeSync/Protocol/Envelope.swift create mode 100644 Packages/Sources/RxCodeSync/Protocol/Payload.swift create mode 100644 Packages/Sources/RxCodeSync/SyncClient.swift create mode 100644 Packages/Sources/RxCodeSync/Transport/RelayClient.swift create mode 100644 RxCode/Services/MobileSyncService.swift create mode 100644 RxCode/Views/Settings/MobileSettingsTab.swift create mode 100644 RxCodeMobile/AppDelegate.swift create mode 100644 RxCodeMobile/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 RxCodeMobile/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 RxCodeMobile/Assets.xcassets/Contents.json create mode 100644 RxCodeMobile/ContentView.swift create mode 100644 RxCodeMobile/Info.plist create mode 100644 RxCodeMobile/Item.swift create mode 100644 RxCodeMobile/RxCodeMobile.entitlements create mode 100644 RxCodeMobile/RxCodeMobileApp.swift create mode 100644 RxCodeMobile/SETUP.md create mode 100644 RxCodeMobile/State/MobileAppState.swift create mode 100644 RxCodeMobile/Views/MobileChatView.swift create mode 100644 RxCodeMobile/Views/MobileInputBar.swift create mode 100644 RxCodeMobile/Views/MobileMessageBubble.swift create mode 100644 RxCodeMobile/Views/MobileSettingsView.swift create mode 100644 RxCodeMobile/Views/OnboardingView.swift create mode 100644 RxCodeMobile/Views/PermissionApprovalSheet.swift create mode 100644 RxCodeMobile/Views/ProjectsSidebar.swift create mode 100644 RxCodeMobile/Views/QRScannerView.swift create mode 100644 RxCodeMobile/Views/RootView.swift create mode 100644 RxCodeMobile/Views/SessionsList.swift create mode 100644 RxCodeMobileNotificationService/Info.plist create mode 100644 RxCodeMobileNotificationService/NotificationService.swift create mode 100644 RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements create mode 100644 RxCodeMobileTests/RxCodeMobileTests.swift create mode 100644 RxCodeMobileUITests/RxCodeMobileUITests.swift create mode 100644 RxCodeMobileUITests/RxCodeMobileUITestsLaunchTests.swift create mode 100644 RxCodeTests/RxCodeMobile/AppDelegate.swift create mode 100644 RxCodeTests/RxCodeMobile/Info.plist create mode 100644 RxCodeTests/RxCodeMobile/RxCodeMobile.entitlements create mode 100644 RxCodeTests/RxCodeMobile/RxCodeMobileApp.swift create mode 100644 RxCodeTests/RxCodeMobile/SETUP.md create mode 100644 RxCodeTests/RxCodeMobile/State/MobileAppState.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/MobileChatView.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/MobileInputBar.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/MobileMessageBubble.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/MobileSettingsView.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/OnboardingView.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/PermissionApprovalSheet.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/ProjectsSidebar.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/QRScannerView.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/RootView.swift create mode 100644 RxCodeTests/RxCodeMobile/Views/SessionsList.swift create mode 100644 relay-server/Dockerfile create mode 100644 relay-server/README.md create mode 100644 relay-server/go.mod create mode 100644 relay-server/go.sum create mode 100644 relay-server/health.go create mode 100644 relay-server/main.go create mode 100644 relay-server/push.go create mode 100644 relay-server/relay.go diff --git a/.gitignore b/.gitignore index e46d89e9..733f064b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ sparkle.key release_notes.html release_notes.md +relay-server/rxcode-relay diff --git a/Packages/Package.swift b/Packages/Package.swift index 2801c55d..3982af60 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -4,10 +4,11 @@ import PackageDescription let package = Package( name: "RxCodePackages", defaultLocalization: "en", - platforms: [.macOS(.v15)], + platforms: [.macOS(.v15), .iOS(.v18)], products: [ .library(name: "RxCodeCore", targets: ["RxCodeCore"]), .library(name: "RxCodeChatKit", targets: ["RxCodeChatKit"]), + .library(name: "RxCodeSync", targets: ["RxCodeSync"]), ], dependencies: [ .package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.0"), @@ -28,6 +29,11 @@ let package = Package( .defaultIsolation(MainActor.self), ] ), + .target( + name: "RxCodeSync", + dependencies: ["RxCodeCore"], + path: "Sources/RxCodeSync" + ), .testTarget( name: "RxCodeCoreTests", dependencies: ["RxCodeCore"], diff --git a/Packages/Sources/RxCodeChatKit/AttachmentPreviewItem.swift b/Packages/Sources/RxCodeChatKit/AttachmentPreviewItem.swift index e049b997..96d270ac 100644 --- a/Packages/Sources/RxCodeChatKit/AttachmentPreviewItem.swift +++ b/Packages/Sources/RxCodeChatKit/AttachmentPreviewItem.swift @@ -2,6 +2,8 @@ import SwiftUI import LinkPresentation import RxCodeCore +#if os(macOS) + /// Attachment preview item — tall rectangular card /// Shows an X button overlay on mouse hover struct AttachmentPreviewItem: View { @@ -242,3 +244,4 @@ struct AttachmentPreviewItem: View { } } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/BashTerminalSheet.swift b/Packages/Sources/RxCodeChatKit/BashTerminalSheet.swift index e766143f..37e0f467 100644 --- a/Packages/Sources/RxCodeChatKit/BashTerminalSheet.swift +++ b/Packages/Sources/RxCodeChatKit/BashTerminalSheet.swift @@ -1,5 +1,10 @@ import SwiftUI import RxCodeCore +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif /// Terminal-styled sheet that surfaces the output of a completed `Bash` tool call. /// Mirrors the look of the interactive terminal popup (dark background, monospaced @@ -122,8 +127,12 @@ struct BashTerminalSheet: View { } private func copyResult() { +#if os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(resultText, forType: .string) +#elseif os(iOS) + UIPasteboard.general.string = resultText +#endif } } diff --git a/Packages/Sources/RxCodeChatKit/ChatBridge.swift b/Packages/Sources/RxCodeChatKit/ChatBridge.swift index d57a978e..c0994c94 100644 --- a/Packages/Sources/RxCodeChatKit/ChatBridge.swift +++ b/Packages/Sources/RxCodeChatKit/ChatBridge.swift @@ -125,10 +125,10 @@ public final class ChatBridge { let message = messages[messageIdx] for blockIdx in message.blocks.indices.reversed() { guard let toolCall = message.blocks[blockIdx].toolCall, - PlanCardView.isExitPlanMode(toolCall) else { continue } + PlanLogic.isExitPlanMode(toolCall) else { continue } if planDecisionSummaries[toolCall.id] != nil { return false } - if PlanCardView.isPlanDecided(toolCall) { return false } + if PlanLogic.isPlanDecided(toolCall) { return false } // If anything came after this ExitPlanMode — later blocks in the // same message, or any later message — the chat continued past @@ -154,8 +154,8 @@ public final class ChatBridge { for message in messages { for block in message.blocks { guard let toolCall = block.toolCall, - PlanCardView.isExitPlanMode(toolCall) else { continue } - if PlanCardView.isSupersededExitPlanMode( + PlanLogic.isExitPlanMode(toolCall) else { continue } + if PlanLogic.isSupersededExitPlanMode( toolCall: toolCall, in: message, allMessages: messages @@ -163,15 +163,15 @@ public final class ChatBridge { let inlineMd = toolCall.input["plan"]?.stringValue ?? "" let markdown: String = inlineMd.isEmpty - ? (PlanCardView.fallbackPlanMarkdown(in: message) - ?? PlanCardView.latestPriorPlanMarkdown(before: message, in: messages) + ? (PlanLogic.fallbackPlanMarkdown(in: message) + ?? PlanLogic.latestPriorPlanMarkdown(before: message, in: messages) ?? "") : inlineMd let persistedSummary = planDecisionSummaries[toolCall.id] - let decided = persistedSummary != nil || PlanCardView.isPlanDecided(toolCall) + let decided = persistedSummary != nil || PlanLogic.isPlanDecided(toolCall) let summary: String? = persistedSummary - ?? (PlanCardView.isPlanDecided(toolCall) ? toolCall.result : nil) + ?? (PlanLogic.isPlanDecided(toolCall) ? toolCall.result : nil) let isStreaming = message.isStreaming && markdown.isEmpty collected.append(PendingPlan( toolCallId: toolCall.id, diff --git a/Packages/Sources/RxCodeChatKit/ChatView.swift b/Packages/Sources/RxCodeChatKit/ChatView.swift index 711fc6fb..73efc849 100644 --- a/Packages/Sources/RxCodeChatKit/ChatView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatView.swift @@ -2,6 +2,8 @@ import Combine import SwiftUI import RxCodeCore +#if os(macOS) + public struct ChatView: View { @Environment(WindowState.self) private var windowState @Environment(ChatBridge.self) private var chatBridge @@ -172,3 +174,4 @@ public extension ChatView where AboveInputAccessory == EmptyView { self.init(inputAccessory: inputAccessory, bottomAccessory: bottomAccessory, aboveInputAccessory: { EmptyView() }) } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/FileDiffView.swift b/Packages/Sources/RxCodeChatKit/FileDiffView.swift index 30bc8e81..b6057a18 100644 --- a/Packages/Sources/RxCodeChatKit/FileDiffView.swift +++ b/Packages/Sources/RxCodeChatKit/FileDiffView.swift @@ -1,4 +1,5 @@ import SwiftUI +#if os(macOS) import AppKit import RxCodeCore @@ -234,21 +235,6 @@ struct DiffLine { let kind: Kind } -// MARK: - Shared Indent Utility - -nonisolated func stripCommonIndent(old: [String], new: [String]) -> (old: [String], new: [String]) { - let combined = old + new - let commonIndent = combined - .filter { !$0.allSatisfy(\.isWhitespace) } - .map { $0.prefix(while: { $0 == " " || $0 == "\t" }).count } - .min() ?? 0 - guard commonIndent > 0 else { return (old, new) } - func strip(_ line: String) -> String { - line.count >= commonIndent ? String(line.dropFirst(commonIndent)) : line - } - return (old.map(strip), new.map(strip)) -} - // MARK: - NSTextView-based Renderer (TextKit2) private struct DiffTextRenderer: NSViewRepresentable { @@ -487,3 +473,4 @@ private struct DiffTextRenderer: NSViewRepresentable { } } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/IMETextView.swift b/Packages/Sources/RxCodeChatKit/IMETextView.swift index 5be2a008..dd6a19ae 100644 --- a/Packages/Sources/RxCodeChatKit/IMETextView.swift +++ b/Packages/Sources/RxCodeChatKit/IMETextView.swift @@ -1,4 +1,5 @@ import SwiftUI +#if os(macOS) import AppKit import RxCodeCore @@ -407,3 +408,4 @@ fileprivate final class _IMETextView: NSTextView { insertText(composing, replacementRange: range) } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/ImagePreviewSheet.swift b/Packages/Sources/RxCodeChatKit/ImagePreviewSheet.swift index 04a2a7de..7ac6329f 100644 --- a/Packages/Sources/RxCodeChatKit/ImagePreviewSheet.swift +++ b/Packages/Sources/RxCodeChatKit/ImagePreviewSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +#if os(macOS) import AppKit import RxCodeCore @@ -58,3 +59,4 @@ struct ImagePreviewSheet: View { .background(ClaudeTheme.surfaceElevated) } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/IndentUtilities.swift b/Packages/Sources/RxCodeChatKit/IndentUtilities.swift new file mode 100644 index 00000000..e4aa59d2 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/IndentUtilities.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Strips the leading whitespace prefix that's common to every non-blank line +/// across both arrays. Shared between FileDiffView (macOS) and ToolResultView +/// (cross-platform) so both stay consistent. +nonisolated func stripCommonIndent(old: [String], new: [String]) -> (old: [String], new: [String]) { + let combined = old + new + let commonIndent = combined + .filter { !$0.allSatisfy(\.isWhitespace) } + .map { $0.prefix(while: { $0 == " " || $0 == "\t" }).count } + .min() ?? 0 + guard commonIndent > 0 else { return (old, new) } + func strip(_ line: String) -> String { + line.count >= commonIndent ? String(line.dropFirst(commonIndent)) : line + } + return (old.map(strip), new.map(strip)) +} diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift index 161f4d80..cf2ecc64 100644 --- a/Packages/Sources/RxCodeChatKit/InputBarView.swift +++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift @@ -2,6 +2,8 @@ import SwiftUI import UniformTypeIdentifiers import RxCodeCore +#if os(macOS) + struct InputBarView: View { @Environment(ChatBridge.self) private var environmentChatBridge: ChatBridge? @Environment(WindowState.self) private var environmentWindowState: WindowState? @@ -961,3 +963,4 @@ private struct InputHeightMeasurer: View { return capped.hasSuffix("\n") ? capped + " " : capped } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 3618cdae..9aaeec1c 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -1,4 +1,5 @@ import SwiftUI +#if os(macOS) import AppKit import RxCodeCore @@ -971,3 +972,4 @@ private extension String { .frame(width: 500, height: 600) .background(ClaudeTheme.background) } +#endif diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index 8e0e1589..b63d7961 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -1,4 +1,5 @@ import SwiftUI +#if os(macOS) import AppKit import RxCodeCore @@ -701,3 +702,4 @@ private struct MessageImagePreviewSheet: View { .background(ClaudeTheme.surfacePrimary) } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 1de04f47..8e06365c 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -3,6 +3,8 @@ import Combine import RxCodeCore import os +#if os(macOS) + /// Message scroll area — extracted from ChatView to isolate @Observable dependencies on `messages`. struct MessageListView: View { @Environment(ChatBridge.self) private var chatBridge @@ -277,11 +279,11 @@ fileprivate func suppressPlanReadyFollowups(in messages: [ChatMessage]) -> [Chat func flushAssistantRun() { guard !assistantRun.isEmpty else { return } - let hasExitPlan = assistantRun.contains { PlanCardView.containsExitPlanMode($0) } + let hasExitPlan = assistantRun.contains { PlanLogic.containsExitPlanMode($0) } var hasRecentPlanCard = false for message in assistantRun { - if hasExitPlan && PlanCardView.isPurePlanFileWriteMessage(message) { + if hasExitPlan && PlanLogic.isPurePlanFileWriteMessage(message) { continue } @@ -290,7 +292,7 @@ fileprivate func suppressPlanReadyFollowups(in messages: [ChatMessage]) -> [Chat // sees the summary while approval is pending. _ = hasRecentPlanCard - if PlanCardView.containsExitPlanMode(message) { + if PlanLogic.containsExitPlanMode(message) { hasRecentPlanCard = true } result.append(message) @@ -323,7 +325,7 @@ fileprivate func isPlanReadyFollowupMessage(_ message: ChatMessage) -> Bool { } return message.blocks.allSatisfy { block in guard let text = block.text else { return false } - return PlanCardView.isPlanReadyFollowup(text) + return PlanLogic.isPlanReadyFollowup(text) } } @@ -651,3 +653,4 @@ struct ElapsedTimeView: View { } } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/PlanCardView.swift b/Packages/Sources/RxCodeChatKit/PlanCardView.swift index 15dc1228..23f8c401 100644 --- a/Packages/Sources/RxCodeChatKit/PlanCardView.swift +++ b/Packages/Sources/RxCodeChatKit/PlanCardView.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +#if os(macOS) import AppKit /// Compact inline status chip for a Claude `ExitPlanMode` tool call. Replaces the @@ -348,3 +349,4 @@ struct PlanCardView: View { } } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/PlanLogic.swift b/Packages/Sources/RxCodeChatKit/PlanLogic.swift new file mode 100644 index 00000000..885d5e53 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/PlanLogic.swift @@ -0,0 +1,149 @@ +import Foundation +import RxCodeCore + +/// Plan-mode tool-call helpers extracted from `PlanCardView` so they can be +/// shared between the macOS chat UI (which actually renders the card) and +/// cross-platform consumers like `ChatBridge` and the iOS app. +public enum PlanLogic { + public static let userDecisionPrefixes: [String] = PlanDecisionAction.userDecisionResultPrefixes + + public static func isPlanDecided(_ toolCall: ToolCall) -> Bool { + guard let result = toolCall.result else { return false } + return PlanDecisionAction.isUserDecisionResult(result) + } + + public static func planMarkdown(from toolCall: ToolCall) -> String? { + if isExitPlanMode(toolCall) { + return toolCall.input["plan"]?.stringValue + } + if isPlanFileWrite(toolCall) { + return toolCall.input["content"]?.stringValue + } + return nil + } + + public static func isExitPlanMode(_ toolCall: ToolCall) -> Bool { + let n = toolCall.name.lowercased() + return n == "exitplanmode" || n == "exit_plan_mode" + } + + public static func isPlanFileWrite(_ toolCall: ToolCall) -> Bool { + guard toolCall.name.lowercased() == "write", + let path = toolCall.input["file_path"]?.stringValue, + path.hasSuffix(".md") else { + return false + } + return path.contains("/.claude/plans/") || path.contains("/claude/plans/") + } + + public static func isPlanReadyFollowup(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("Plan is ready at ") else { return false } + return trimmed.contains(".md") + } + + public static func containsExitPlanMode(_ message: ChatMessage) -> Bool { + message.blocks.contains { block in + guard let toolCall = block.toolCall else { return false } + return isExitPlanMode(toolCall) + } + } + + public static func fallbackPlanMarkdown(in message: ChatMessage) -> String? { + let markdowns: [String] = message.blocks.compactMap { block -> String? in + guard let toolCall = block.toolCall, isPlanFileWrite(toolCall) else { return nil } + return planMarkdown(from: toolCall) + } + return markdowns.last + } + + public static func latestPriorPlanMarkdown(before message: ChatMessage, in messages: [ChatMessage]) -> String? { + guard let idx = messages.firstIndex(where: { $0.id == message.id }) else { return nil } + for prior in messages[.. Bool { + message.blocks.contains { block in + guard let toolCall = block.toolCall else { return false } + return isPlanFileWrite(toolCall) + } + } + + public static func isPurePlanFileWriteMessage(_ message: ChatMessage) -> Bool { + guard message.role == .assistant, + !message.isError, + !message.isCompactBoundary, + !message.isStreaming, + !message.blocks.isEmpty else { + return false + } + return message.blocks.allSatisfy { block in + guard let toolCall = block.toolCall else { return false } + return isPlanFileWrite(toolCall) + } + } + + public static func renderMarkdown(for toolCall: ToolCall, in message: ChatMessage) -> String? { + if isExitPlanMode(toolCall) { + let markdown = toolCall.input["plan"]?.stringValue ?? "" + return markdown.isEmpty ? (fallbackPlanMarkdown(in: message) ?? markdown) : markdown + } + return planMarkdown(from: toolCall) + } + + public static func shouldHideBlock( + _ block: MessageBlock, + in message: ChatMessage, + allMessages: [ChatMessage] = [] + ) -> Bool { + let hasExitPlanInMessage = containsExitPlanMode(message) + if block.text != nil { return false } + guard let toolCall = block.toolCall, isPlanFileWrite(toolCall) else { return false } + return hasExitPlanInMessage || assistantRunContainsExitPlanMode(after: message, in: allMessages) + } + + private static func assistantRunContainsExitPlanMode( + after message: ChatMessage, + in allMessages: [ChatMessage] + ) -> Bool { + guard let idx = allMessages.firstIndex(where: { $0.id == message.id }) else { return false } + for i in (idx + 1).. 0 { + for i in stride(from: idx - 1, through: 0, by: -1) { + let m = allMessages[i] + if m.role == .user { break } + if containsExitPlanMode(m) { return true } + } + } + return false + } + + public static func isSupersededExitPlanMode( + toolCall: ToolCall, + in message: ChatMessage, + allMessages: [ChatMessage] + ) -> Bool { + guard isExitPlanMode(toolCall) else { return false } + guard let msgIdx = allMessages.firstIndex(where: { $0.id == message.id }) else { return false } + var sawSelf = false + for i in msgIdx.. msgIdx, m.role == .user { return false } + for block in m.blocks { + guard let tc = block.toolCall, isExitPlanMode(tc) else { continue } + if tc.id == toolCall.id { sawSelf = true; continue } + if sawSelf { return true } + } + } + return false + } +} diff --git a/Packages/Sources/RxCodeChatKit/PlanSheetView.swift b/Packages/Sources/RxCodeChatKit/PlanSheetView.swift index 65de53bf..ad4e7c02 100644 --- a/Packages/Sources/RxCodeChatKit/PlanSheetView.swift +++ b/Packages/Sources/RxCodeChatKit/PlanSheetView.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +#if os(macOS) import AppKit import Combine @@ -365,3 +366,4 @@ public struct PlanSheetView: View { .joined(separator: "-") } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/ShortcutManagerView.swift b/Packages/Sources/RxCodeChatKit/ShortcutManagerView.swift index 5d210772..4dc2b709 100644 --- a/Packages/Sources/RxCodeChatKit/ShortcutManagerView.swift +++ b/Packages/Sources/RxCodeChatKit/ShortcutManagerView.swift @@ -2,6 +2,8 @@ import SwiftUI import UniformTypeIdentifiers import RxCodeCore +#if os(macOS) + // MARK: - Shortcut Manager View public struct ShortcutManagerView: View { @@ -424,3 +426,4 @@ struct ShortcutEditView: View { } } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift index 9a07db32..bbd6e4b7 100644 --- a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift +++ b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift @@ -1,6 +1,8 @@ import SwiftUI import RxCodeCore +#if os(macOS) + // MARK: - Slash Command Data public struct SlashCommand: Identifiable, Codable, Hashable { @@ -1063,3 +1065,4 @@ enum AtFileSearch { return results } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift b/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift index 1dd8031d..1d3ab535 100644 --- a/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift +++ b/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift @@ -2,6 +2,8 @@ import SwiftUI import UniformTypeIdentifiers import RxCodeCore +#if os(macOS) + // MARK: - Slash Command Manager View public struct SlashCommandManagerView: View { @@ -618,3 +620,4 @@ struct SlashCommandEditView: View { } } } +#endif diff --git a/Packages/Sources/RxCodeChatKit/TextPreviewSheet.swift b/Packages/Sources/RxCodeChatKit/TextPreviewSheet.swift index 12fd15af..d11d67c8 100644 --- a/Packages/Sources/RxCodeChatKit/TextPreviewSheet.swift +++ b/Packages/Sources/RxCodeChatKit/TextPreviewSheet.swift @@ -1,5 +1,10 @@ import SwiftUI import RxCodeCore +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif /// Detail preview sheet for text attachments struct TextPreviewSheet: View { @@ -20,8 +25,12 @@ struct TextPreviewSheet: View { Button { if let text = attachment.textContent { +#if os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) +#elseif os(iOS) + UIPasteboard.general.string = text +#endif } } label: { Image(systemName: "doc.on.doc") diff --git a/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift b/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift index 92d6672d..c3ea0a05 100644 --- a/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift +++ b/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift @@ -2,6 +2,8 @@ import SwiftUI import WebKit import RxCodeCore +#if os(macOS) + /// Detects localhost URLs and shows a "View Result" button that, /// when clicked, provides an in-app web view preview. struct WebPreviewButton: View { @@ -146,3 +148,4 @@ struct WebViewWrapper: NSViewRepresentable { ]) .padding() } +#endif diff --git a/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift b/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift index 9497854d..1ff704f6 100644 --- a/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift +++ b/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift @@ -439,7 +439,9 @@ public actor CLISessionStore { /// Rewrite the session's jsonl so it appears in the `claude --resume` picker. /// Delegates to ``PickerExposer``; uses the cwd index for accurate URL resolution. public func exposeToPicker(sid: String, cwd: String) async { +#if os(macOS) await PickerExposer.normalize(jsonlAt: await jsonlURL(sid: sid, cwd: cwd)) +#endif } // MARK: - Deletion diff --git a/Packages/Sources/RxCodeCore/CLISession/PickerExposer.swift b/Packages/Sources/RxCodeCore/CLISession/PickerExposer.swift index cd9d3454..cf144557 100644 --- a/Packages/Sources/RxCodeCore/CLISession/PickerExposer.swift +++ b/Packages/Sources/RxCodeCore/CLISession/PickerExposer.swift @@ -1,6 +1,8 @@ import Foundation import os +#if os(macOS) + /// Rewrites Claude Code session jsonl files so that RxCode-spawned sessions /// appear in the interactive `claude --resume` picker. /// @@ -99,3 +101,5 @@ public enum PickerExposer { } } } + +#endif diff --git a/Packages/Sources/RxCodeCore/Models/Attachment.swift b/Packages/Sources/RxCodeCore/Models/Attachment.swift index 546fbfe6..26421321 100644 --- a/Packages/Sources/RxCodeCore/Models/Attachment.swift +++ b/Packages/Sources/RxCodeCore/Models/Attachment.swift @@ -1,5 +1,9 @@ import Foundation +#if os(macOS) import AppKit +#elseif os(iOS) +import UIKit +#endif // MARK: - Attachment diff --git a/Packages/Sources/RxCodeCore/Theme/ClaudeTheme.swift b/Packages/Sources/RxCodeCore/Theme/ClaudeTheme.swift index c18ef58e..5d31d78a 100644 --- a/Packages/Sources/RxCodeCore/Theme/ClaudeTheme.swift +++ b/Packages/Sources/RxCodeCore/Theme/ClaudeTheme.swift @@ -1,5 +1,9 @@ import SwiftUI +#if os(macOS) import AppKit +#elseif os(iOS) +import UIKit +#endif // MARK: - Claude Theme Colors @@ -77,10 +81,18 @@ public enum ClaudeTheme { extension Color { public init(light: Color, dark: Color) { +#if os(macOS) self.init(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua return isDark ? NSColor(dark) : NSColor(light) }) +#elseif os(iOS) + self.init(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) +#else + self = light +#endif } } @@ -111,11 +123,24 @@ extension Color { } public var hexString: String { +#if os(macOS) guard let c = NSColor(self).usingColorSpace(.sRGB) else { return "#0000FF" } let r = Int((c.redComponent * 255).rounded()) let g = Int((c.greenComponent * 255).rounded()) let b = Int((c.blueComponent * 255).rounded()) return String(format: "#%02X%02X%02X", r, g, b) +#elseif os(iOS) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) + return String( + format: "#%02X%02X%02X", + Int((r * 255).rounded()), + Int((g * 255).rounded()), + Int((b * 255).rounded()) + ) +#else + return "#0000FF" +#endif } } @@ -123,9 +148,13 @@ extension Color { extension View { public func pointerCursorOnHover() -> some View { +#if os(macOS) self.onHover { hovering in if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } } +#else + self +#endif } public func claudeCard() -> some View { diff --git a/Packages/Sources/RxCodeCore/Utilities/ClipboardHelper.swift b/Packages/Sources/RxCodeCore/Utilities/ClipboardHelper.swift index 5906aeb3..eeb363e4 100644 --- a/Packages/Sources/RxCodeCore/Utilities/ClipboardHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/ClipboardHelper.swift @@ -1,11 +1,19 @@ -import AppKit import SwiftUI +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif /// Copies text to the clipboard and resets the `feedback` binding to false after 2 seconds. @MainActor public func copyToClipboard(_ text: String, feedback: Binding) { +#if os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) +#elseif os(iOS) + UIPasteboard.general.string = text +#endif feedback.wrappedValue = true Task { try? await Task.sleep(for: .seconds(2)) diff --git a/Packages/Sources/RxCodeCore/Utilities/DirectoryWatcher.swift b/Packages/Sources/RxCodeCore/Utilities/DirectoryWatcher.swift index 54c6f31c..0f6038b4 100644 --- a/Packages/Sources/RxCodeCore/Utilities/DirectoryWatcher.swift +++ b/Packages/Sources/RxCodeCore/Utilities/DirectoryWatcher.swift @@ -1,7 +1,9 @@ -import CoreServices import Foundation import os +#if os(macOS) +import CoreServices + /// Watches one or more directories for filesystem changes using FSEventStream /// with `kFSEventStreamCreateFlagFileEvents`. Push-based: the kernel notifies /// us when files inside the watched root are created, modified, deleted, or @@ -131,3 +133,5 @@ public actor DirectoryWatcher { onChange() } } + +#endif diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index dd209e91..78c1cfd2 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -1,5 +1,6 @@ import Foundation +#if os(macOS) public enum GitHelper { /// Environment for `/usr/bin/git` subprocesses. Augments PATH with the /// standard Homebrew and system bin directories so git can locate @@ -247,3 +248,5 @@ public enum GitHelper { .filter { !$0.isEmpty } } } + +#endif diff --git a/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift b/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift index 42229b71..09165c96 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift @@ -1,5 +1,7 @@ import Foundation +#if os(macOS) + /// Extracts "owner/repo" from a GitHub remote URL. /// Supports HTTPS and SSH formats. /// Returns nil for non-GitHub URLs. @@ -46,3 +48,5 @@ public func detectGitHubOwnerRepo(at path: String) -> String? { return parseGitHubOwnerRepo(from: urlString) } + +#endif diff --git a/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift b/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift index 1abcf3a3..64bf8558 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift @@ -1,5 +1,7 @@ import Foundation +#if os(macOS) + // MARK: - GitWorktreeService public actor GitWorktreeService { @@ -253,3 +255,5 @@ public actor GitWorktreeService { return result } } + +#endif diff --git a/Packages/Sources/RxCodeCore/Utilities/PathEncoding.swift b/Packages/Sources/RxCodeCore/Utilities/PathEncoding.swift index 82af6f1a..dd9d1cbe 100644 --- a/Packages/Sources/RxCodeCore/Utilities/PathEncoding.swift +++ b/Packages/Sources/RxCodeCore/Utilities/PathEncoding.swift @@ -26,7 +26,11 @@ extension String { public enum CLIProjectsDirectory { /// `~/.claude/projects`. Created lazily by the CLI; may not exist yet. public static var url: URL { +#if os(macOS) let home = FileManager.default.homeDirectoryForCurrentUser +#else + let home = URL.homeDirectory +#endif return home.appendingPathComponent(".claude/projects", isDirectory: true) } diff --git a/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift b/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift index f7e5df34..3728792e 100644 --- a/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift +++ b/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift @@ -1,5 +1,9 @@ import SwiftUI +#if os(macOS) import AppKit +#elseif os(iOS) +import UIKit +#endif // MARK: - Syntax Highlighter @@ -8,6 +12,7 @@ public enum SyntaxHighlighter { let normalized = normalizeLanguage(language) let tokens = tokenize(code, language: normalized) let result = NSMutableAttributedString() +#if os(macOS) let regularFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) let mediumFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium) for token in tokens { @@ -17,6 +22,17 @@ public enum SyntaxHighlighter { .foregroundColor: NSColor(color(for: token.kind)), ])) } +#elseif os(iOS) + let regularFont = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + let mediumFont = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .medium) + for token in tokens { + let font = (token.kind == .keyword || token.kind == .builtinType) ? mediumFont : regularFont + result.append(NSAttributedString(string: token.text, attributes: [ + .font: font, + .foregroundColor: UIColor(color(for: token.kind)), + ])) + } +#endif return result } diff --git a/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift b/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift new file mode 100644 index 00000000..fd639aa8 --- /dev/null +++ b/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift @@ -0,0 +1,81 @@ +import Foundation +import CryptoKit + +/// On-the-wire shape of the alert blob nested under `enc` in the APNs payload. +/// +/// The same `SessionCrypto.seal/open` primitive used for relay envelopes is +/// reused here, so the iOS Notification Service Extension only needs the +/// device's long-term Curve25519 private key plus the sender's pubkey to +/// decrypt and present the visible alert. +public struct EncryptedAlert: Codable, Sendable { + public let v: Int + public let from: String + public let nonce: String + public let ct: String + + public init(v: Int = 1, from: String, nonce: Data, ct: Data) { + self.v = v + self.from = from + self.nonce = nonce.base64EncodedString() + self.ct = ct.base64EncodedString() + } + + public var nonceData: Data? { Data(base64Encoded: nonce) } + public var ciphertextData: Data? { Data(base64Encoded: ct) } +} + +/// Plaintext shape of the decrypted alert. The NSE rewrites the visible +/// `aps.alert.title` and `aps.alert.body` from these fields. +public struct AlertPlaintext: Codable, Sendable { + public let title: String + public let body: String + public let sessionID: String? + public let projectID: UUID? + public let kind: String? + + public init( + title: String, + body: String, + sessionID: String? = nil, + projectID: UUID? = nil, + kind: String? = nil + ) { + self.title = title + self.body = body + self.sessionID = sessionID + self.projectID = projectID + self.kind = kind + } +} + +public enum APNsCrypto { + /// Desktop side: seal an alert for a paired mobile. + public static func seal( + plaintext: AlertPlaintext, + sender: Curve25519.KeyAgreement.PrivateKey, + recipient: Curve25519.KeyAgreement.PublicKey + ) throws -> EncryptedAlert { + let data = try JSONEncoder().encode(plaintext) + let (nonce, ct) = try SessionCrypto.seal(plaintext: data, sender: sender, recipient: recipient) + return EncryptedAlert(from: sender.publicKey.rawRepresentation.hexString, nonce: nonce, ct: ct) + } + + /// NSE side: open a sealed alert. The `from` field tells the NSE which + /// stored peer pubkey to use as the sender. + public static func open( + envelope: EncryptedAlert, + recipient: Curve25519.KeyAgreement.PrivateKey, + sender: Curve25519.KeyAgreement.PublicKey + ) throws -> AlertPlaintext { + guard let nonce = envelope.nonceData, let ct = envelope.ciphertextData else { + throw SessionCrypto.CryptoError.openFailed + } + let raw = try SessionCrypto.open( + ciphertext: ct, + nonce: nonce, + recipient: recipient, + sender: sender + ) + return try JSONDecoder().decode(AlertPlaintext.self, from: raw) + } +} diff --git a/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift b/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift new file mode 100644 index 00000000..bbb1b956 --- /dev/null +++ b/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift @@ -0,0 +1,113 @@ +import Foundation +import CryptoKit +import Security + +/// A long-term Curve25519 keypair stored in the Keychain. Identity is per +/// device — desktops, iPhones, and iPads each generate their own and use the +/// public key as their address on the relay. +public struct DeviceIdentity: Sendable { + public let privateKey: Curve25519.KeyAgreement.PrivateKey + public var publicKey: Curve25519.KeyAgreement.PublicKey { privateKey.publicKey } + public var publicKeyHex: String { publicKey.rawRepresentation.hexString } + + public init(privateKey: Curve25519.KeyAgreement.PrivateKey) { + self.privateKey = privateKey + } + + /// Load the device's keypair, creating one on first run. + /// + /// - Parameters: + /// - service: Keychain service identifier. Pass a stable per-app value. + /// - accessGroup: Keychain access group. Pass non-nil to share the + /// identity between the main app and a Notification Service Extension + /// (both must enable the matching `keychain-access-groups` entitlement). + public static func loadOrCreate( + service: String = "com.idealapp.RxCode.deviceKey", + accessGroup: String? = nil + ) throws -> DeviceIdentity { + if let existing = try Keychain.read(service: service, accessGroup: accessGroup) { + let key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: existing) + return DeviceIdentity(privateKey: key) + } + let key = Curve25519.KeyAgreement.PrivateKey() + try Keychain.write(key.rawRepresentation, service: service, accessGroup: accessGroup) + return DeviceIdentity(privateKey: key) + } + + /// Discard the existing identity (e.g. user tapped "Unpair from everything" + /// on mobile). The next `loadOrCreate` call generates a fresh keypair. + public static func reset( + service: String = "com.idealapp.RxCode.deviceKey", + accessGroup: String? = nil + ) throws { + try Keychain.delete(service: service, accessGroup: accessGroup) + } +} + +enum Keychain { + static func read(service: String, accessGroup: String?) throws -> Data? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = item as? Data else { + throw KeychainError.osStatus(status) + } + return data + } + + static func write(_ data: Data, service: String, accessGroup: String?) throws { + var attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + if let accessGroup { attributes[kSecAttrAccessGroup as String] = accessGroup } + SecItemDelete(attributes as CFDictionary) + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.osStatus(status) } + } + + static func delete(service: String, accessGroup: String?) throws { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainError.osStatus(status) + } + } +} + +enum KeychainError: Error { + case osStatus(OSStatus) +} + +extension Data { + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } + + init?(hexString: String) { + let chars = Array(hexString) + guard chars.count % 2 == 0 else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(chars.count / 2) + var i = 0 + while i < chars.count { + guard let b = UInt8(String(chars[i.. (nonce: Data, ciphertext: Data) { + let key = try deriveKey( + privateKey: sender, + publicKey: recipient, + extraSalt: salt + ) + let nonceBytes = (0..<12).map { _ in UInt8.random(in: .min ... .max) } + let nonceData = Data(nonceBytes) + let nonce = try ChaChaPoly.Nonce(data: nonceData) + let sealed = try ChaChaPoly.seal(plaintext, using: key, nonce: nonce) + return (nonceData, sealed.ciphertext + sealed.tag) + } + + /// Decrypt an envelope addressed to `recipient` from `sender`. + public static func open( + ciphertext: Data, + nonce: Data, + recipient: Curve25519.KeyAgreement.PrivateKey, + sender: Curve25519.KeyAgreement.PublicKey, + salt: Data? = nil + ) throws -> Data { + let key = try deriveKey( + privateKey: recipient, + publicKey: sender, + extraSalt: salt + ) + // ChaChaPoly tag is the last 16 bytes + guard ciphertext.count >= 16 else { throw CryptoError.openFailed } + let tag = ciphertext.suffix(16) + let ct = ciphertext.prefix(ciphertext.count - 16) + let chachaNonce = try ChaChaPoly.Nonce(data: nonce) + let sealed = try ChaChaPoly.SealedBox(nonce: chachaNonce, ciphertext: ct, tag: tag) + return try ChaChaPoly.open(sealed, using: key) + } + + private static func deriveKey( + privateKey: Curve25519.KeyAgreement.PrivateKey, + publicKey: Curve25519.KeyAgreement.PublicKey, + extraSalt: Data? + ) throws -> SymmetricKey { + let shared = try privateKey.sharedSecretFromKeyAgreement(with: publicKey) + var salt = sortedConcat(privateKey.publicKey.rawRepresentation, publicKey.rawRepresentation) + if let extraSalt { salt.append(extraSalt) } + return shared.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: salt, + sharedInfo: kdfInfo, + outputByteCount: 32 + ) + } + + private static func sortedConcat(_ a: Data, _ b: Data) -> Data { + a.lexicographicallyPrecedes(b) ? a + b : b + a + } +} diff --git a/Packages/Sources/RxCodeSync/Pairing/Pairing.swift b/Packages/Sources/RxCodeSync/Pairing/Pairing.swift new file mode 100644 index 00000000..3eccd7ce --- /dev/null +++ b/Packages/Sources/RxCodeSync/Pairing/Pairing.swift @@ -0,0 +1,79 @@ +import Foundation +import CryptoKit + +/// QR-code payload the desktop generates and the mobile scans. +/// +/// Encoded as base64-JSON (URL-safe). Carries everything mobile needs to find +/// the relay, encrypt the first envelope, and prove freshness — but contains +/// no long-term mobile secret. +public struct PairingToken: Codable, Sendable { + public let v: Int + public let relayURL: String + public let desktopPubkeyHex: String + public let sessionID: UUID + public let oneTimeSecretHex: String + public let issuedAt: Date + public let expiresAt: Date + public let desktopName: String + + public init( + v: Int = 1, + relayURL: String, + desktopPubkeyHex: String, + sessionID: UUID = UUID(), + oneTimeSecretHex: String, + issuedAt: Date = .now, + expiresAt: Date, + desktopName: String + ) { + self.v = v + self.relayURL = relayURL + self.desktopPubkeyHex = desktopPubkeyHex + self.sessionID = sessionID + self.oneTimeSecretHex = oneTimeSecretHex + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.desktopName = desktopName + } + + /// True if `Date.now` is past `expiresAt`. UI should disable scanned tokens + /// that resolve to `true` and prompt the user to regenerate the QR. + public var isExpired: Bool { Date.now > expiresAt } + + public var oneTimeSecret: Data? { Data(hexString: oneTimeSecretHex) } + + public var desktopPublicKey: Curve25519.KeyAgreement.PublicKey? { + guard let raw = Data(hexString: desktopPubkeyHex) else { return nil } + return try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: raw) + } + + // MARK: - QR encoding + + public func qrString() throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(self) + return "rxcode-pair:" + data.base64EncodedString(options: .init()) + } + + public static func parse(_ qrString: String) throws -> PairingToken { + guard qrString.hasPrefix("rxcode-pair:") else { throw PairingError.malformed } + let b64 = String(qrString.dropFirst("rxcode-pair:".count)) + guard let data = Data(base64Encoded: b64) else { throw PairingError.malformed } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(PairingToken.self, from: data) + } + + public static func makeOneTimeSecret() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + precondition(status == errSecSuccess) + return Data(bytes).hexString + } +} + +public enum PairingError: Error, Sendable { + case malformed + case expired +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Envelope.swift b/Packages/Sources/RxCodeSync/Protocol/Envelope.swift new file mode 100644 index 00000000..5865e89e --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Envelope.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Cleartext relay envelope. Everything inside `ct` is opaque to the relay. +public struct Envelope: Codable, Sendable { + public let v: Int + public let to: String + public let from: String + public let nonce: String + public let ct: String + + public init(v: Int = 1, to: String, from: String, nonce: Data, ct: Data) { + self.v = v + self.to = to + self.from = from + self.nonce = nonce.base64EncodedString() + self.ct = ct.base64EncodedString() + } + + public var nonceData: Data? { Data(base64Encoded: nonce) } + public var ciphertextData: Data? { Data(base64Encoded: ct) } +} + +/// Sent by the relay back to a sender when the recipient is offline. Useful +/// for desktop to know "skip the live channel, only the APNs push will land". +public struct DeliveryFailedNotice: Codable, Sendable { + public let v: Int + public let type: String + public let to: String +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift new file mode 100644 index 00000000..cb393212 --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -0,0 +1,295 @@ +import Foundation +import RxCodeCore + +/// All plaintext payloads exchanged between paired devices. +/// +/// Encoded as a tagged JSON object with `type` as the discriminator so adding +/// new cases stays forward-compatible. Unknown cases decode to +/// `.unknown(type:)` rather than failing the entire envelope decode. +public enum Payload: Sendable { + case pairRequest(PairRequestPayload) + case pairAck(PairAckPayload) + case apnsToken(APNsTokenPayload) + case requestSnapshot(RequestSnapshotPayload) + case snapshot(SnapshotPayload) + case sessionUpdate(SessionUpdatePayload) + case subscribeSession(SubscribeSessionPayload) + case userMessage(UserMessagePayload) + case newSessionRequest(NewSessionRequestPayload) + case notification(NotificationPayload) + case permissionRequest(PermissionRequestPayload) + case permissionResponse(PermissionResponsePayload) + case ping(PingPayload) + case pong(PongPayload) + case unknown(type: String) +} + +// MARK: - Wire structs + +public struct PairRequestPayload: Codable, Sendable { + public let mobilePubkeyHex: String + public let displayName: String + public let platform: String + public let appVersion: String + public init(mobilePubkeyHex: String, displayName: String, platform: String, appVersion: String) { + self.mobilePubkeyHex = mobilePubkeyHex + self.displayName = displayName + self.platform = platform + self.appVersion = appVersion + } +} + +public struct PairAckPayload: Codable, Sendable { + public let accepted: Bool + public let desktopName: String + public let reason: String? + public init(accepted: Bool, desktopName: String, reason: String? = nil) { + self.accepted = accepted + self.desktopName = desktopName + self.reason = reason + } +} + +public struct APNsTokenPayload: Codable, Sendable { + public let tokenHex: String + public let environment: String + public init(tokenHex: String, environment: String) { + self.tokenHex = tokenHex + self.environment = environment + } +} + +public struct RequestSnapshotPayload: Codable, Sendable { + public let activeSessionID: String? + public init(activeSessionID: String? = nil) { + self.activeSessionID = activeSessionID + } +} + +public struct SnapshotPayload: Codable, Sendable { + public let projects: [Project] + public let sessions: [SessionSummary] + public let activeSessionID: String? + public let activeSessionMessages: [ChatMessage]? + public init( + projects: [Project], + sessions: [SessionSummary], + activeSessionID: String? = nil, + activeSessionMessages: [ChatMessage]? = nil + ) { + self.projects = projects + self.sessions = sessions + self.activeSessionID = activeSessionID + self.activeSessionMessages = activeSessionMessages + } +} + +public struct SessionSummary: Codable, Sendable, Identifiable { + public let id: String + public let projectId: UUID + public let title: String + public let updatedAt: Date + public let isPinned: Bool + public let isArchived: Bool + public init( + id: String, + projectId: UUID, + title: String, + updatedAt: Date, + isPinned: Bool, + isArchived: Bool + ) { + self.id = id + self.projectId = projectId + self.title = title + self.updatedAt = updatedAt + self.isPinned = isPinned + self.isArchived = isArchived + } +} + +public struct SessionUpdatePayload: Codable, Sendable { + public enum Kind: String, Codable, Sendable { + case messageAppended + case messageUpdated + case streamingStarted + case streamingFinished + case statusChanged + } + public let sessionID: String + public let kind: Kind + public let message: ChatMessage? + public let isStreaming: Bool? + public init( + sessionID: String, + kind: Kind, + message: ChatMessage? = nil, + isStreaming: Bool? = nil + ) { + self.sessionID = sessionID + self.kind = kind + self.message = message + self.isStreaming = isStreaming + } +} + +public struct SubscribeSessionPayload: Codable, Sendable { + public let sessionID: String? + public init(sessionID: String?) { self.sessionID = sessionID } +} + +public struct UserMessagePayload: Codable, Sendable { + public let clientMessageID: UUID + public let sessionID: String + public let text: String + public init(clientMessageID: UUID = UUID(), sessionID: String, text: String) { + self.clientMessageID = clientMessageID + self.sessionID = sessionID + self.text = text + } +} + +public struct NewSessionRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let initialText: String? + public init(clientRequestID: UUID = UUID(), projectID: UUID, initialText: String? = nil) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.initialText = initialText + } +} + +public struct NotificationPayload: Codable, Sendable { + public enum Kind: String, Codable, Sendable { + case responseComplete + case permissionNeeded + case questionNeeded + case mcpDisconnected + case generic + } + public let kind: Kind + public let title: String + public let body: String + public let sessionID: String? + public let projectID: UUID? + public init( + kind: Kind, + title: String, + body: String, + sessionID: String? = nil, + projectID: UUID? = nil + ) { + self.kind = kind + self.title = title + self.body = body + self.sessionID = sessionID + self.projectID = projectID + } +} + +public struct PermissionRequestPayload: Codable, Sendable { + public let requestID: String + public let toolName: String + public let toolInputJSON: String + public let sessionID: String? + public init(requestID: String, toolName: String, toolInputJSON: String, sessionID: String?) { + self.requestID = requestID + self.toolName = toolName + self.toolInputJSON = toolInputJSON + self.sessionID = sessionID + } +} + +public struct PermissionResponsePayload: Codable, Sendable { + public let requestID: String + public let allow: Bool + public let denyReason: String? + public init(requestID: String, allow: Bool, denyReason: String? = nil) { + self.requestID = requestID + self.allow = allow + self.denyReason = denyReason + } +} + +public struct PingPayload: Codable, Sendable { + public let t: Date + public init(t: Date = .now) { self.t = t } +} + +public struct PongPayload: Codable, Sendable { + public let t: Date + public init(t: Date = .now) { self.t = t } +} + +// MARK: - Codable + +extension Payload: Codable { + private enum CodingKeys: String, CodingKey { + case type + case data + } + + enum TypeKey: String { + case pairRequest = "pair_request" + case pairAck = "pair_ack" + case apnsToken = "apns_token" + case requestSnapshot = "request_snapshot" + case snapshot + case sessionUpdate = "session_update" + case subscribeSession = "subscribe_session" + case userMessage = "user_message" + case newSessionRequest = "new_session_request" + case notification + case permissionRequest = "permission_request" + case permissionResponse = "permission_response" + case ping + case pong + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawType = try container.decode(String.self, forKey: .type) + guard let kind = TypeKey(rawValue: rawType) else { + self = .unknown(type: rawType) + return + } + switch kind { + case .pairRequest: self = .pairRequest(try container.decode(PairRequestPayload.self, forKey: .data)) + case .pairAck: self = .pairAck(try container.decode(PairAckPayload.self, forKey: .data)) + case .apnsToken: self = .apnsToken(try container.decode(APNsTokenPayload.self, forKey: .data)) + case .requestSnapshot: self = .requestSnapshot(try container.decode(RequestSnapshotPayload.self, forKey: .data)) + case .snapshot: self = .snapshot(try container.decode(SnapshotPayload.self, forKey: .data)) + case .sessionUpdate: self = .sessionUpdate(try container.decode(SessionUpdatePayload.self, forKey: .data)) + case .subscribeSession: self = .subscribeSession(try container.decode(SubscribeSessionPayload.self, forKey: .data)) + case .userMessage: self = .userMessage(try container.decode(UserMessagePayload.self, forKey: .data)) + case .newSessionRequest: self = .newSessionRequest(try container.decode(NewSessionRequestPayload.self, forKey: .data)) + case .notification: self = .notification(try container.decode(NotificationPayload.self, forKey: .data)) + case .permissionRequest: self = .permissionRequest(try container.decode(PermissionRequestPayload.self, forKey: .data)) + case .permissionResponse: self = .permissionResponse(try container.decode(PermissionResponsePayload.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)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pairRequest(let p): try container.encode(TypeKey.pairRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .pairAck(let p): try container.encode(TypeKey.pairAck.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .apnsToken(let p): try container.encode(TypeKey.apnsToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .requestSnapshot(let p): try container.encode(TypeKey.requestSnapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .snapshot(let p): try container.encode(TypeKey.snapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .sessionUpdate(let p): try container.encode(TypeKey.sessionUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .subscribeSession(let p): try container.encode(TypeKey.subscribeSession.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .userMessage(let p): try container.encode(TypeKey.userMessage.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .newSessionRequest(let p): try container.encode(TypeKey.newSessionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .notification(let p): try container.encode(TypeKey.notification.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .permissionRequest(let p): try container.encode(TypeKey.permissionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .permissionResponse(let p): try container.encode(TypeKey.permissionResponse.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/SyncClient.swift b/Packages/Sources/RxCodeSync/SyncClient.swift new file mode 100644 index 00000000..bd98d823 --- /dev/null +++ b/Packages/Sources/RxCodeSync/SyncClient.swift @@ -0,0 +1,68 @@ +import Foundation +import CryptoKit + +/// High-level facade used by both the desktop service and the iOS app. +/// +/// Owns a `RelayClient` and translates app-level intents ("send this user +/// message", "respond to permission request") into encrypted envelopes +/// addressed to a paired peer. +public actor SyncClient { + public let identity: DeviceIdentity + public let relayURL: URL + private let relay: RelayClient + + /// Map of paired peer pubkey-hex to their `Curve25519` public key. + /// Desktop usually has 1..n entries (one per paired mobile); mobile has 1 + /// (the paired desktop). + private var peers: [String: Curve25519.KeyAgreement.PublicKey] = [:] + + public init(identity: DeviceIdentity, relayURL: URL) { + self.identity = identity + self.relayURL = relayURL + self.relay = RelayClient(identity: identity, relayURL: relayURL) + } + + public func start() async { + await relay.connect() + } + + public func stop() async { + await relay.disconnect() + } + + public func events() async -> AsyncStream { + await relay.events() + } + + public func addPeer(_ pubkeyHex: String) throws { + guard let raw = Data(hexString: pubkeyHex) else { throw SyncError.invalidPubkey } + let key = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: raw) + peers[pubkeyHex] = key + } + + public func removePeer(_ pubkeyHex: String) { + peers.removeValue(forKey: pubkeyHex) + } + + public func peer(forHex hex: String) -> Curve25519.KeyAgreement.PublicKey? { + peers[hex] + } + + /// Send `payload` to a single paired peer. + public func send(_ payload: Payload, toHex hex: String) async throws { + guard let key = peers[hex] else { throw SyncError.unknownPeer } + try await relay.send(payload, to: key) + } + + /// Broadcast `payload` to every paired peer (e.g. notification fan-out). + public func broadcast(_ payload: Payload) async { + for (_, key) in peers { + try? await relay.send(payload, to: key) + } + } +} + +public enum SyncError: Error, Sendable { + case unknownPeer + case invalidPubkey +} diff --git a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift new file mode 100644 index 00000000..933585dd --- /dev/null +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -0,0 +1,233 @@ +import Foundation +import CryptoKit + +/// Manages a single WebSocket to the relay. Handles E2E encrypt/decrypt, ping +/// keepalive, and exponential-backoff reconnect. +/// +/// The relay sees only `Envelope` and `DeliveryFailedNotice` — every other +/// payload is encrypted before send and decrypted after receive. +public actor RelayClient { + public struct Inbound: Sendable { + public let from: Curve25519.KeyAgreement.PublicKey + public let fromHex: String + public let payload: Payload + } + + public enum ConnectionState: Sendable, Equatable { + case disconnected + case connecting + case connected + case reconnecting(nextAttemptInSeconds: Int) + } + + public enum Event: Sendable { + case stateChanged(ConnectionState) + case inbound(Inbound) + case deliveryFailed(toHex: String) + } + + private let identity: DeviceIdentity + private let relayURL: URL + private let session: URLSession + + private var task: URLSessionWebSocketTask? + private(set) public var state: ConnectionState = .disconnected + private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var reconnectAttempt = 0 + private var pingTask: Task? + private var receiveTask: Task? + private var shouldReconnect = true + + public init(identity: DeviceIdentity, relayURL: URL) { + self.identity = identity + self.relayURL = relayURL + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 30 + cfg.waitsForConnectivity = true + self.session = URLSession(configuration: cfg) + } + + /// Subscribe to a multiplexed event stream. Each subscriber gets its own + /// buffered stream; tearing down the AsyncStream's iterator removes the + /// subscriber. + public func events() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + self.continuations[id] = continuation + continuation.onTermination = { @Sendable [weak self] _ in + Task { await self?.removeContinuation(id) } + } + } + } + + public func connect() { + guard task == nil else { return } + shouldReconnect = true + openSocket() + } + + public func disconnect() { + shouldReconnect = false + closeSocketLocally() + emit(.stateChanged(.disconnected)) + state = .disconnected + } + + /// 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 } + let plaintext = try JSONEncoder().encode(payload) + let (nonce, ct) = try SessionCrypto.seal( + plaintext: plaintext, + sender: identity.privateKey, + recipient: recipient + ) + let envelope = Envelope( + to: recipient.rawRepresentation.hexString, + from: identity.publicKeyHex, + nonce: nonce, + ct: ct + ) + let raw = try JSONEncoder().encode(envelope) + try await task.send(.data(raw)) + } + + // MARK: - Private + + private func removeContinuation(_ id: UUID) { + continuations.removeValue(forKey: id) + } + + private func emit(_ event: Event) { + for c in continuations.values { c.yield(event) } + } + + private func updateState(_ newState: ConnectionState) { + state = newState + emit(.stateChanged(newState)) + } + + private func openSocket() { + updateState(.connecting) + + guard var components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) else { + handleSocketFailure(error: RelayError.invalidURL) + return + } + let basePath = components.path + components.path = basePath.isEmpty ? "/ws" : basePath + "/ws" + components.queryItems = [URLQueryItem(name: "pubkey", value: identity.publicKeyHex)] + guard let url = components.url else { + handleSocketFailure(error: RelayError.invalidURL) + return + } + + let newTask = session.webSocketTask(with: url) + task = newTask + newTask.resume() + + // The URLSessionWebSocketTask only confirms connection on first + // successful send/receive. We optimistically transition to .connected + // and let the receive loop demote on error. + updateState(.connected) + reconnectAttempt = 0 + startPingLoop() + startReceiveLoop() + } + + private func startPingLoop() { + pingTask?.cancel() + pingTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 25 * 1_000_000_000) + guard let self else { return } + await self.sendPing() + } + } + } + + private func sendPing() { + task?.sendPing { [weak self] error in + if error != nil { + Task { await self?.handleSocketFailure(error: error ?? RelayError.notConnected) } + } + } + } + + private func startReceiveLoop() { + receiveTask?.cancel() + receiveTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + await self.receiveOne() + } + } + } + + private func receiveOne() async { + guard let task else { return } + do { + let message = try await task.receive() + switch message { + case .data(let data): handleIncoming(data) + case .string(let s): handleIncoming(Data(s.utf8)) + @unknown default: break + } + } catch { + handleSocketFailure(error: error) + } + } + + private func handleIncoming(_ raw: Data) { + let decoder = JSONDecoder() + if let notice = try? decoder.decode(DeliveryFailedNotice.self, from: raw), + notice.type == "delivery_failed" { + emit(.deliveryFailed(toHex: notice.to)) + return + } + guard let env = try? decoder.decode(Envelope.self, from: raw) else { 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 } + do { + let plaintext = try SessionCrypto.open( + ciphertext: ct, + nonce: nonce, + recipient: identity.privateKey, + sender: fromKey + ) + let payload = try decoder.decode(Payload.self, from: plaintext) + emit(.inbound(Inbound(from: fromKey, fromHex: env.from, payload: payload))) + } 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. + return + } + } + + private func handleSocketFailure(error: Error) { + closeSocketLocally() + guard shouldReconnect else { return } + reconnectAttempt += 1 + let delay = min(30, Int(pow(2.0, Double(reconnectAttempt)))) + updateState(.reconnecting(nextAttemptInSeconds: delay)) + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000) + await self?.openSocket() + } + } + + private func closeSocketLocally() { + pingTask?.cancel(); pingTask = nil + receiveTask?.cancel(); receiveTask = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + } +} + +public enum RelayError: Error, Sendable { + case notConnected + case invalidURL +} diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 6477f1e7..f1bfb038 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -13,6 +13,12 @@ DCA8CF4A05C959C4A6EB391F /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = CCDF9594876099576D4FD46E /* RxCodeCore */; }; DF06CCD12FB4CAB5005991E1 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = DF06CCD02FB4CAB5005991E1 /* ViewInspector */; }; DF06DCC72FB8552B005991E1 /* UnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */; }; + DF230B992FBC738D008929A6 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B982FBC738D008929A6 /* RxCodeChatKit */; }; + DF230B9B2FBC738D008929A6 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B9A2FBC738D008929A6 /* RxCodeCore */; }; + DF230B9D2FBC738D008929A6 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B9C2FBC738D008929A6 /* RxCodeSync */; }; + DF230B9F2FBC73BA008929A6 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B9E2FBC73BA008929A6 /* RxCodeChatKit */; }; + DF230BA12FBC73BA008929A6 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = DF230BA02FBC73BA008929A6 /* RxCodeCore */; }; + DF230BA32FBC73BA008929A6 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = DF230BA22FBC73BA008929A6 /* RxCodeSync */; }; DF23F7362FB8C3EC008929A6 /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DF23F7352FB8C3EC008929A6 /* icon.icon */; }; DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; @@ -23,6 +29,7 @@ E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; }; E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001022FA0000100000001 /* RxCodeChatKit */; }; + E6D001072FA0000100000001 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001062FA0000100000001 /* RxCodeSync */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,6 +47,20 @@ remoteGlobalIDString = E67335372F7356F600FD26C7; remoteInfo = RxCode; }; + DF230B602FBC7368008929A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E67335302F7356F600FD26C7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DF230B4E2FBC7367008929A6; + remoteInfo = RxCodeMobile; + }; + DF230B6A2FBC7368008929A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E67335302F7356F600FD26C7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DF230B4E2FBC7367008929A6; + remoteInfo = RxCodeMobile; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -50,6 +71,9 @@ 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A9993BB72A5307039A88B729 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTestPlan.xctestplan; sourceTree = ""; }; + DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCodeMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DF23F7352FB8C3EC008929A6 /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = ""; }; DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = ""; }; DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = ""; }; @@ -57,7 +81,35 @@ E67335382F7356F600FD26C7 /* RxCode.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCode.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + DF230B772FBC7368008929A6 /* Exceptions for "RxCodeMobile" folder in "RxCodeMobile" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ + DF230B502FBC7367008929A6 /* RxCodeMobile */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DF230B772FBC7368008929A6 /* Exceptions for "RxCodeMobile" folder in "RxCodeMobile" target */, + ); + path = RxCodeMobile; + sourceTree = ""; + }; + DF230B622FBC7368008929A6 /* RxCodeMobileTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RxCodeMobileTests; + sourceTree = ""; + }; + DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RxCodeMobileUITests; + sourceTree = ""; + }; E673353A2F7356F600FD26C7 /* RxCode */ = { isa = PBXFileSystemSynchronizedRootGroup; path = RxCode; @@ -84,6 +136,33 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230B4C2FBC7367008929A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DF230BA32FBC73BA008929A6 /* RxCodeSync in Frameworks */, + DF230BA12FBC73BA008929A6 /* RxCodeCore in Frameworks */, + DF230B9F2FBC73BA008929A6 /* RxCodeChatKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B5C2FBC7368008929A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B662FBC7368008929A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DF230B9D2FBC738D008929A6 /* RxCodeSync in Frameworks */, + DF230B9B2FBC738D008929A6 /* RxCodeCore in Frameworks */, + DF230B992FBC738D008929A6 /* RxCodeChatKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335352F7356F600FD26C7 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -92,6 +171,7 @@ E6C001022F9B000100000001 /* Sparkle in Frameworks */, E6D001032FA0000100000001 /* RxCodeCore in Frameworks */, E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, + E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -142,6 +222,9 @@ 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */, E673353A2F7356F600FD26C7 /* RxCode */, 6E17B0042FC8000100A10001 /* RxCodeUITests */, + DF230B502FBC7367008929A6 /* RxCodeMobile */, + DF230B622FBC7368008929A6 /* RxCodeMobileTests */, + DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */, E67335392F7356F600FD26C7 /* Products */, 609E8EE085862BD7D5B4012F /* Frameworks */, 1525FE6BFB6F06A3F00B92D3 /* RxCodeTests */, @@ -154,6 +237,9 @@ E67335382F7356F600FD26C7 /* RxCode.app */, 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */, 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */, + DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */, + DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */, + DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */, ); name = Products; sourceTree = ""; @@ -204,6 +290,80 @@ productReference = 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DF230B4E2FBC7367008929A6 /* RxCodeMobile */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF230B782FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobile" */; + buildPhases = ( + DF230B4B2FBC7367008929A6 /* Sources */, + DF230B4C2FBC7367008929A6 /* Frameworks */, + DF230B4D2FBC7367008929A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DF230B502FBC7367008929A6 /* RxCodeMobile */, + ); + name = RxCodeMobile; + packageProductDependencies = ( + DF230B9E2FBC73BA008929A6 /* RxCodeChatKit */, + DF230BA02FBC73BA008929A6 /* RxCodeCore */, + DF230BA22FBC73BA008929A6 /* RxCodeSync */, + ); + productName = RxCodeMobile; + productReference = DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */; + productType = "com.apple.product-type.application.on-demand-install-capable"; + }; + DF230B5E2FBC7368008929A6 /* RxCodeMobileTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF230B792FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobileTests" */; + buildPhases = ( + DF230B5B2FBC7368008929A6 /* Sources */, + DF230B5C2FBC7368008929A6 /* Frameworks */, + DF230B5D2FBC7368008929A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DF230B612FBC7368008929A6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DF230B622FBC7368008929A6 /* RxCodeMobileTests */, + ); + name = RxCodeMobileTests; + packageProductDependencies = ( + ); + productName = RxCodeMobileTests; + productReference = DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DF230B682FBC7368008929A6 /* RxCodeMobileUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF230B7A2FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobileUITests" */; + buildPhases = ( + DF230B652FBC7368008929A6 /* Sources */, + DF230B662FBC7368008929A6 /* Frameworks */, + DF230B672FBC7368008929A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DF230B6B2FBC7368008929A6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */, + ); + name = RxCodeMobileUITests; + packageProductDependencies = ( + DF230B982FBC738D008929A6 /* RxCodeChatKit */, + DF230B9A2FBC738D008929A6 /* RxCodeCore */, + DF230B9C2FBC738D008929A6 /* RxCodeSync */, + ); + productName = RxCodeMobileUITests; + productReference = DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E67335372F7356F600FD26C7 /* RxCode */ = { isa = PBXNativeTarget; buildConfigurationList = E67335432F7356F700FD26C7 /* Build configuration list for PBXNativeTarget "RxCode" */; @@ -225,6 +385,7 @@ E6C001012F9B000100000001 /* Sparkle */, E6D001012FA0000100000001 /* RxCodeCore */, E6D001022FA0000100000001 /* RxCodeChatKit */, + E6D001062FA0000100000001 /* RxCodeSync */, DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, ); productName = RxCode; @@ -241,6 +402,17 @@ LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 2630; TargetAttributes = { + DF230B4E2FBC7367008929A6 = { + CreatedOnToolsVersion = 26.3; + }; + DF230B5E2FBC7368008929A6 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = DF230B4E2FBC7367008929A6; + }; + DF230B682FBC7368008929A6 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = DF230B4E2FBC7367008929A6; + }; E67335372F7356F600FD26C7 = { CreatedOnToolsVersion = 26.3; }; @@ -271,6 +443,9 @@ E67335372F7356F600FD26C7 /* RxCode */, 5D74FE7B782850D23F8D9BF7 /* RxCodeTests */, 6E17B0062FC8000100A10001 /* RxCodeUITests */, + DF230B4E2FBC7367008929A6 /* RxCodeMobile */, + DF230B5E2FBC7368008929A6 /* RxCodeMobileTests */, + DF230B682FBC7368008929A6 /* RxCodeMobileUITests */, ); }; /* End PBXProject section */ @@ -290,6 +465,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230B4D2FBC7367008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B5D2FBC7368008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B672FBC7368008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335362F7356F600FD26C7 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -321,6 +517,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230B4B2FBC7367008929A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B5B2FBC7368008929A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B652FBC7368008929A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335342F7356F600FD26C7 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -343,6 +560,16 @@ target = E67335372F7356F600FD26C7 /* RxCode */; targetProxy = 35C1B17CDEF83F212F648418 /* PBXContainerItemProxy */; }; + DF230B612FBC7368008929A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; + targetProxy = DF230B602FBC7368008929A6 /* PBXContainerItemProxy */; + }; + DF230B6B2FBC7368008929A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; + targetProxy = DF230B6A2FBC7368008929A6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -402,6 +629,173 @@ }; name = Release; }; + DF230B712FBC7368008929A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RxCodeMobile/RxCodeMobile.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = P9KK452K8P; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeMobile/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "___ASSOCIATEDTARGET_bundleDisplayName___"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = rxlab.Clip; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DF230B722FBC7368008929A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RxCodeMobile/RxCodeMobile.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = P9KK452K8P; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeMobile/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "___ASSOCIATEDTARGET_bundleDisplayName___"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = rxlab.Clip; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DF230B732FBC7368008929A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = P9KK452K8P; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = rxlab.RxCodeMobileTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RxCodeMobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RxCodeMobile"; + }; + name = Debug; + }; + DF230B742FBC7368008929A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = P9KK452K8P; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = rxlab.RxCodeMobileTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RxCodeMobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RxCodeMobile"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DF230B752FBC7368008929A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = P9KK452K8P; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = rxlab.RxCodeMobileUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = RxCodeMobile; + }; + name = Debug; + }; + DF230B762FBC7368008929A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = P9KK452K8P; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = rxlab.RxCodeMobileUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = RxCodeMobile; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; E67335412F7356F700FD26C7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -611,6 +1005,33 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DF230B782FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF230B712FBC7368008929A6 /* Debug */, + DF230B722FBC7368008929A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DF230B792FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobileTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF230B732FBC7368008929A6 /* Debug */, + DF230B742FBC7368008929A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DF230B7A2FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobileUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF230B752FBC7368008929A6 /* Debug */, + DF230B762FBC7368008929A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; E67335332F7356F600FD26C7 /* Build configuration list for PBXProject "RxCode" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -683,6 +1104,36 @@ package = DF06CCCF2FB4CAB5005991E1 /* XCRemoteSwiftPackageReference "ViewInspector" */; productName = ViewInspector; }; + DF230B982FBC738D008929A6 /* RxCodeChatKit */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeChatKit; + }; + DF230B9A2FBC738D008929A6 /* RxCodeCore */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeCore; + }; + DF230B9C2FBC738D008929A6 /* RxCodeSync */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeSync; + }; + DF230B9E2FBC73BA008929A6 /* RxCodeChatKit */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeChatKit; + }; + DF230BA02FBC73BA008929A6 /* RxCodeCore */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeCore; + }; + DF230BA22FBC73BA008929A6 /* RxCodeSync */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeSync; + }; DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = { isa = XCSwiftPackageProductDependency; package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; @@ -712,6 +1163,11 @@ package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; productName = RxCodeChatKit; }; + E6D001062FA0000100000001 /* RxCodeSync */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeSync; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = E67335302F7356F600FD26C7 /* Project object */; diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index c956df43..017dff2d 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -487,6 +487,7 @@ struct MainWindowRoot: View { NotificationService.shared.onNotificationTapped = { projectId, sessionId in appState.handleNotificationTap(projectId: projectId, sessionId: sessionId, mainWindow: windowState) } + MobileSyncService.shared.start() } } } diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift new file mode 100644 index 00000000..78060461 --- /dev/null +++ b/RxCode/Services/MobileSyncService.swift @@ -0,0 +1,318 @@ +import Foundation +import AppKit +import Combine +import CryptoKit +import RxCodeCore +import RxCodeSync +import os.log + +/// One paired mobile device. Persisted to +/// `~/Library/Application Support/RxCode/paired_devices.json`. +struct PairedDevice: Codable, Identifiable, Sendable, Hashable { + var pubkeyHex: String + var displayName: String + var platform: String + var apnsToken: String? + var apnsEnvironment: String? + var pairedAt: Date + var lastSeen: Date? + + var id: String { pubkeyHex } +} + +/// Result of a pairing handshake propagated to the SwiftUI pairing sheet. +enum PairingOutcome: Sendable { + case pending + case received(PairRequestPayload) + case accepted(PairedDevice) + case cancelled +} + +/// Bridges the desktop app to paired mobile devices over the E2E-encrypted +/// relay channel. Owns the long-term `DeviceIdentity`, the `SyncClient`, and +/// the persistent paired-device list. +@MainActor +final class MobileSyncService: ObservableObject { + + static let shared = MobileSyncService() + + @Published private(set) var pairedDevices: [PairedDevice] = [] + @Published private(set) var connectionState: RelayClient.ConnectionState = .disconnected + @Published private(set) var isPairing: Bool = false + @Published private(set) var pendingPairing: PairRequestPayload? + @Published var relayURL: URL + + private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") + private let identity: DeviceIdentity + private let client: SyncClient + private var subscribedSessions: [String: String] = [:] + private var eventTask: Task? + private var pairingToken: PairingToken? + private var pairingContinuation: CheckedContinuation? + + /// The single AppState reference is set in init order from RxCodeApp, + /// because AppState owns the storage layer and the streaming loop. + private weak var appState: AnyObject? + + init() { + // Persisted relay URL or sensible default for self-host. + let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") + let initial = URL(string: stored ?? "ws://localhost:8787") ?? URL(string: "ws://localhost:8787")! + self.relayURL = initial + do { + self.identity = try DeviceIdentity.loadOrCreate() + } catch { + Self.logFatalKeychain(error) + fatalError("Failed to load device identity: \(error)") + } + self.client = SyncClient(identity: identity, relayURL: initial) + loadPairedDevices() + } + + private static func logFatalKeychain(_ error: Error) { + Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") + .fault("device-identity load failed: \(String(describing: error))") + } + + // MARK: - Public API + + var localPublicKeyHex: String { identity.publicKeyHex } + var localDeviceName: String { Host.current().localizedName ?? "Mac" } + + /// Begin (or resume) the relay connection. Safe to call multiple times. + func start() { + Task { @MainActor in + do { + for device in pairedDevices { + try? await client.addPeer(device.pubkeyHex) + } + await client.start() + let events = await client.events() + eventTask?.cancel() + eventTask = Task { @MainActor in + for await event in events { + self.handle(event: event) + } + } + } + } + } + + func stop() { + eventTask?.cancel() + eventTask = nil + Task { await client.stop() } + } + + /// Update the configured relay URL, persist it, and reconnect. + func updateRelay(url: URL) { + UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") + relayURL = url + // SyncClient holds its URL at init time. The simplest restart is to + // recreate the service on next launch — for now we just reset peers. + // (A full hot-swap is left for a future refactor.) + } + + // MARK: - Pairing + + /// Generate a fresh pairing token + show it as QR. Token expires in 2 min. + func beginPairing() -> PairingToken { + let token = PairingToken( + relayURL: relayURL.absoluteString, + desktopPubkeyHex: identity.publicKeyHex, + oneTimeSecretHex: PairingToken.makeOneTimeSecret(), + expiresAt: Date.now.addingTimeInterval(120), + desktopName: localDeviceName + ) + pairingToken = token + isPairing = true + pendingPairing = nil + return token + } + + /// Wait until a mobile finishes the pairing handshake. Returns when the + /// user has either approved or cancelled the pending request. + func awaitPairing() async -> PairingOutcome { + await withCheckedContinuation { (continuation: CheckedContinuation) in + pairingContinuation = continuation + } + } + + /// Accept the pending pair request shown to the user in the pairing sheet. + func acceptPendingPairing() async { + guard let pending = pendingPairing else { return } + let device = PairedDevice( + pubkeyHex: pending.mobilePubkeyHex, + displayName: pending.displayName, + platform: pending.platform, + apnsToken: nil, + apnsEnvironment: nil, + pairedAt: .now, + lastSeen: .now + ) + pairedDevices.removeAll { $0.pubkeyHex == device.pubkeyHex } + pairedDevices.append(device) + savePairedDevices() + try? await client.addPeer(device.pubkeyHex) + let ack = PairAckPayload(accepted: true, desktopName: localDeviceName) + try? await client.send(.pairAck(ack), toHex: device.pubkeyHex) + isPairing = false + pendingPairing = nil + pairingToken = nil + pairingContinuation?.resume(returning: .accepted(device)) + pairingContinuation = nil + } + + func cancelPairing() { + Task { + if let pending = pendingPairing { + let ack = PairAckPayload(accepted: false, desktopName: localDeviceName, reason: "rejected") + try? await client.addPeer(pending.mobilePubkeyHex) + try? await client.send(.pairAck(ack), toHex: pending.mobilePubkeyHex) + await client.removePeer(pending.mobilePubkeyHex) + } + } + isPairing = false + pendingPairing = nil + pairingToken = nil + pairingContinuation?.resume(returning: .cancelled) + pairingContinuation = nil + } + + /// Remove a paired device. The mobile loses access immediately on the + /// next message — desktop simply forgets its pubkey. + func unpair(_ device: PairedDevice) async { + pairedDevices.removeAll { $0.pubkeyHex == device.pubkeyHex } + savePairedDevices() + await client.removePeer(device.pubkeyHex) + } + + // MARK: - Notification fan-out + + /// Send a notification to every paired device. + func broadcastNotification(_ payload: NotificationPayload) { + Task { + await client.broadcast(.notification(payload)) + // Best-effort APNs path is intentionally not yet wired here — the + // /push endpoint in the relay server provides it; a follow-up + // patch will submit encrypted alerts for each paired device that + // has an APNs token. See PLAN risk areas. + } + } + + // MARK: - Streaming hooks + + /// Called by AppState's streaming loop after each StreamEvent is folded + /// into local state. + func broadcastSessionUpdate(sessionID: String, kind: SessionUpdatePayload.Kind, message: ChatMessage?, isStreaming: Bool?) { + Task { + let payload = SessionUpdatePayload( + sessionID: sessionID, + kind: kind, + message: message, + isStreaming: isStreaming + ) + await client.broadcast(.sessionUpdate(payload)) + } + } + + // MARK: - Event dispatch + + private func handle(event: RelayClient.Event) { + switch event { + case .stateChanged(let s): + connectionState = s + case .deliveryFailed: + // Drop-on-offline policy — desktop ignores, mobile will resync on + // next reconnect. + break + case .inbound(let inbound): + handleInbound(inbound) + } + } + + private func handleInbound(_ inbound: RelayClient.Inbound) { + switch inbound.payload { + case .pairRequest(let req): + pendingPairing = req + Task { try? await client.addPeer(req.mobilePubkeyHex) } + case .apnsToken(let t): + if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { + pairedDevices[idx].apnsToken = t.tokenHex + pairedDevices[idx].apnsEnvironment = t.environment + pairedDevices[idx].lastSeen = .now + savePairedDevices() + } + case .requestSnapshot: + // AppState owns the data; it observes pendingSnapshotRequests + // and replies. Stub for now — wired up by AppState bridge. + NotificationCenter.default.post( + name: .mobileSyncSnapshotRequested, + object: nil, + userInfo: ["from": inbound.fromHex] + ) + case .userMessage(let msg): + NotificationCenter.default.post( + name: .mobileSyncUserMessageReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": msg] + ) + case .newSessionRequest(let req): + NotificationCenter.default.post( + name: .mobileSyncNewSessionRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .subscribeSession(let sub): + subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" + case .permissionResponse(let resp): + NotificationCenter.default.post( + name: .mobileSyncPermissionResponse, + object: nil, + userInfo: ["payload": resp] + ) + case .ping: + Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } + default: + break + } + } + + /// 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) + } + + // MARK: - Persistence + + private var pairedDevicesURL: URL { + AppSupport.bundleScopedURL.appendingPathComponent("paired_devices.json") + } + + private func loadPairedDevices() { + guard let data = try? Data(contentsOf: pairedDevicesURL) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + pairedDevices = (try? decoder.decode([PairedDevice].self, from: data)) ?? [] + } + + private func savePairedDevices() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + do { + let data = try encoder.encode(pairedDevices) + try data.write(to: pairedDevicesURL, options: .atomic) + } catch { + logger.error("save paired_devices.json: \(error.localizedDescription)") + } + } +} + +extension Notification.Name { + static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") + static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") + static let mobileSyncNewSessionRequested = Notification.Name("mobileSync.newSessionRequested") + static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") +} diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index 10f99e39..9a85b48d 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -1,5 +1,6 @@ import AppKit import UserNotifications +import RxCodeSync import os.log /// Thin wrapper around UNUserNotificationCenter for "response complete" banners. @@ -36,6 +37,14 @@ final class NotificationService: NSObject { /// Post a "permission needed" notification when the CLI queues a tool approval. /// Silently no-ops if the user hasn't granted notification permission. func postPermissionNeeded(toolName: String, projectName: String?, projectId: UUID?, sessionId: String?) async { + let projectSuffixForMirror: String = projectName.map { " — \($0)" } ?? "" + await fanoutToMobile(.init( + kind: .permissionNeeded, + title: "Permission needed\(projectSuffixForMirror)", + body: "Approve to run \(toolName)", + sessionID: sessionId, + projectID: projectId + )) let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional: @@ -78,6 +87,14 @@ final class NotificationService: NSObject { /// Post a "question needed" notification when the CLI invokes AskUserQuestion. /// Silently no-ops if the user hasn't granted notification permission. func postQuestionNeeded(projectName: String?, projectId: UUID?, sessionId: String?) async { + let projectSuffixForMirror: String = projectName.map { " — \($0)" } ?? "" + await fanoutToMobile(.init( + kind: .questionNeeded, + title: "Assistant has a question\(projectSuffixForMirror)", + body: "Tap to answer", + sessionID: sessionId, + projectID: projectId + )) let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional: @@ -120,6 +137,15 @@ final class NotificationService: NSObject { /// transitions a server from connected to failed. Silently no-ops if the user /// hasn't granted notification permission. func postMCPDisconnected(name: String, error: String?) async { + let detailForMirror = (error?.trimmingCharacters(in: .whitespacesAndNewlines)) + .flatMap { $0.isEmpty ? nil : $0 } ?? "connection lost" + await fanoutToMobile(.init( + kind: .mcpDisconnected, + title: "MCP server disconnected", + body: "\(name) — \(detailForMirror)", + sessionID: nil, + projectID: nil + )) let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional: @@ -158,6 +184,13 @@ final class NotificationService: NSObject { /// Post a "response complete" notification. Silently no-ops if unauthorized. func postResponseComplete(title: String, body: String, projectId: UUID, sessionId: String) async { + await fanoutToMobile(.init( + kind: .responseComplete, + title: title, + body: body.isEmpty ? "Response complete" : body, + sessionID: sessionId, + projectID: projectId + )) let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional: @@ -186,6 +219,13 @@ final class NotificationService: NSObject { logger.error("Failed to post notification: \(error.localizedDescription)") } } + + /// Forward the notification to every paired mobile device. Best-effort — + /// devices that are offline simply miss the live channel and fall back to + /// the APNs path (when configured). + private func fanoutToMobile(_ payload: NotificationPayload) async { + MobileSyncService.shared.broadcastNotification(payload) + } } // MARK: - UNUserNotificationCenterDelegate diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift new file mode 100644 index 00000000..02c48d01 --- /dev/null +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -0,0 +1,239 @@ +import SwiftUI +import CoreImage.CIFilterBuiltins +import RxCodeCore +import RxCodeSync + +/// "Mobile" tab in SettingsView. Lists paired iOS / iPadOS devices and lets +/// the user pair a new one via QR code or unpair existing devices. +struct MobileSettingsTab: View { + @StateObject private var sync = MobileSyncService.shared + @State private var showPairingSheet = false + @State private var relayURLText: String = MobileSyncService.shared.relayURL.absoluteString + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + headerSection + relaySection + Divider() + pairedSection + } + .padding(20) + } + .sheet(isPresented: $showPairingSheet) { + PairingSheet(sync: sync) + } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Mobile companion") + .font(.title3) + .fontWeight(.semibold) + Text("Pair an iPhone or iPad to view threads, get notifications, and send messages to your desktop agent.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private var relaySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Relay server") + .font(.headline) + Text("All sync traffic flows through this relay, end-to-end encrypted. Set up your own at github.com/rxlab/rxcode-relay for self-hosting.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("ws://host:port", text: $relayURLText) + .textFieldStyle(.roundedBorder) + Button("Apply") { + if let url = URL(string: relayURLText) { + sync.updateRelay(url: url) + } + } + .disabled(URL(string: relayURLText) == nil) + } + connectionBadge + } + } + + @ViewBuilder + private var connectionBadge: some View { + switch sync.connectionState { + case .disconnected: + Label("Disconnected", systemImage: "circle.slash").foregroundStyle(.secondary) + case .connecting: + Label("Connecting…", systemImage: "circle.dotted").foregroundStyle(.orange) + case .connected: + Label("Connected", systemImage: "checkmark.circle.fill").foregroundStyle(.green) + case .reconnecting(let s): + Label("Reconnecting in \(s)s", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) + } + } + + private var pairedSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Paired devices") + .font(.headline) + Spacer() + Button { + showPairingSheet = true + } label: { + Label("Pair new device", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + } + + if sync.pairedDevices.isEmpty { + Text("No paired devices yet.") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.vertical, 12) + } else { + ForEach(sync.pairedDevices) { device in + pairedRow(device) + } + } + } + } + + private func pairedRow(_ device: PairedDevice) -> some View { + HStack { + Image(systemName: device.platform.lowercased().contains("ipad") ? "ipad" : "iphone") + .font(.title2) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(device.displayName) + .fontWeight(.medium) + HStack(spacing: 6) { + if let token = device.apnsToken, !token.isEmpty { + Label("Push", systemImage: "bell.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Label("Live channel only", systemImage: "bell.slash") + .font(.caption) + .foregroundStyle(.secondary) + } + if let lastSeen = device.lastSeen { + Text("• last seen \(lastSeen.formatted(.relative(presentation: .named)))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + Spacer() + Button(role: .destructive) { + Task { await sync.unpair(device) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Pairing sheet + +private struct PairingSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var sync: MobileSyncService + @State private var token: PairingToken? + @State private var qrImage: NSImage? + + var body: some View { + VStack(spacing: 18) { + Text("Pair a new device") + .font(.title2) + .fontWeight(.semibold) + + if let pending = sync.pendingPairing { + pendingView(pending) + } else if let image = qrImage { + qrView(image) + } else { + ProgressView() + .frame(width: 280, height: 280) + } + + HStack { + Button("Cancel", role: .cancel) { + sync.cancelPairing() + dismiss() + } + Spacer() + } + } + .frame(width: 360) + .padding(24) + .onAppear { startPairing() } + } + + private func startPairing() { + let fresh = sync.beginPairing() + token = fresh + if let qrString = try? fresh.qrString() { + qrImage = generateQRCode(from: qrString) + } + } + + @ViewBuilder + private func qrView(_ image: NSImage) -> some View { + VStack(spacing: 10) { + Image(nsImage: image) + .interpolation(.none) + .resizable() + .frame(width: 240, height: 240) + Text("Scan with the RxCode app on your iPhone or iPad.") + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + if let token, token.isExpired { + Text("Expired — close and reopen to regenerate") + .font(.caption) + .foregroundStyle(.red) + } + } + } + + @ViewBuilder + private func pendingView(_ pending: PairRequestPayload) -> some View { + VStack(spacing: 10) { + Image(systemName: "iphone.gen3.badge.play") + .font(.system(size: 56)) + .foregroundStyle(.tint) + Text("\(pending.displayName) wants to pair") + .font(.headline) + Text("Platform: \(pending.platform) • \(pending.appVersion)") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 12) { + Button("Reject", role: .destructive) { + sync.cancelPairing() + dismiss() + } + Button("Accept") { + Task { + await sync.acceptPendingPairing() + dismiss() + } + } + .buttonStyle(.borderedProminent) + } + } + } + + private func generateQRCode(from string: String) -> NSImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.setValue(Data(string.utf8), forKey: "inputMessage") + filter.correctionLevel = "M" + guard let outputImage = filter.outputImage else { return nil } + let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return NSImage(cgImage: cgImage, size: NSSize(width: 240, height: 240)) + } +} diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 2ec9112c..1b236a4b 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -51,6 +51,12 @@ struct SettingsView: View { Label("ACP Clients", systemImage: "link.circle") } .tag(5) + + MobileSettingsTab() + .tabItem { + Label("Mobile", systemImage: "iphone.gen3") + } + .tag(6) } .frame(width: 680, height: 620) .focusable(false) diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift new file mode 100644 index 00000000..09fb6fd2 --- /dev/null +++ b/RxCodeMobile/AppDelegate.swift @@ -0,0 +1,44 @@ +import UIKit +import UserNotifications + +/// Bridges UIKit's APNs registration into `MobileAppState`. The state object +/// forwards the device token to the paired desktop over the encrypted relay. +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + weak var mobileState: MobileAppState? + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + UNUserNotificationCenter.current().delegate = self + Task { + let granted = (try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge, .sound])) ?? false + if granted { + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenHex = deviceToken.map { String(format: "%02x", $0) }.joined() +#if DEBUG + let environment = "sandbox" +#else + let environment = "production" +#endif + mobileState?.reportAPNsToken(hex: tokenHex, environment: environment) + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + // Best-effort only; relay channel still works. + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .sound, .list] + } +} diff --git a/RxCodeMobile/Assets.xcassets/AccentColor.colorset/Contents.json b/RxCodeMobile/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/RxCodeMobile/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/RxCodeMobile/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/RxCodeMobile/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeMobile/Assets.xcassets/Contents.json b/RxCodeMobile/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/RxCodeMobile/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeMobile/ContentView.swift b/RxCodeMobile/ContentView.swift new file mode 100644 index 00000000..865ca64f --- /dev/null +++ b/RxCodeMobile/ContentView.swift @@ -0,0 +1,61 @@ +// +// ContentView.swift +// RxCodeMobile +// +// Created by Qiwei Li on 5/19/26. +// + +import SwiftUI +import SwiftData + +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + @Query private var items: [Item] + + var body: some View { + NavigationSplitView { + List { + ForEach(items) { item in + NavigationLink { + Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") + } label: { + Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) + } + } + .onDelete(perform: deleteItems) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + EditButton() + } + ToolbarItem { + Button(action: addItem) { + Label("Add Item", systemImage: "plus") + } + } + } + } detail: { + Text("Select an item") + } + } + + private func addItem() { + withAnimation { + let newItem = Item(timestamp: Date()) + modelContext.insert(newItem) + } + } + + private func deleteItems(offsets: IndexSet) { + withAnimation { + for index in offsets { + modelContext.delete(items[index]) + } + } + } +} + +#Preview { + ContentView() + .modelContainer(for: Item.self, inMemory: true) +} diff --git a/RxCodeMobile/Info.plist b/RxCodeMobile/Info.plist new file mode 100644 index 00000000..074a6d12 --- /dev/null +++ b/RxCodeMobile/Info.plist @@ -0,0 +1,13 @@ + + + + + NSAppClip + + NSAppClipRequestEphemeralUserNotification + + NSAppClipRequestLocationConfirmation + + + + diff --git a/RxCodeMobile/Item.swift b/RxCodeMobile/Item.swift new file mode 100644 index 00000000..d0a36d9f --- /dev/null +++ b/RxCodeMobile/Item.swift @@ -0,0 +1,18 @@ +// +// Item.swift +// RxCodeMobile +// +// Created by Qiwei Li on 5/19/26. +// + +import Foundation +import SwiftData + +@Model +final class Item { + var timestamp: Date + + init(timestamp: Date) { + self.timestamp = timestamp + } +} diff --git a/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements new file mode 100644 index 00000000..72a2d0bf --- /dev/null +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.parent-application-identifiers + + $(AppIdentifierPrefix) + + + \ No newline at end of file diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift new file mode 100644 index 00000000..df4c4333 --- /dev/null +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -0,0 +1,32 @@ +// +// RxCodeMobileApp.swift +// RxCodeMobile +// +// Created by Qiwei Li on 5/19/26. +// + +import SwiftUI +import SwiftData + +@main +struct RxCodeMobileApp: App { + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + Item.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(sharedModelContainer) + } +} diff --git a/RxCodeMobile/SETUP.md b/RxCodeMobile/SETUP.md new file mode 100644 index 00000000..b205f20e --- /dev/null +++ b/RxCodeMobile/SETUP.md @@ -0,0 +1,131 @@ +# RxCodeMobile — Xcode setup + +This directory holds the source files for the **iOS / iPadOS companion app** +and its Notification Service Extension. Adding new app + extension targets +safely is best done in the Xcode UI rather than by editing +`project.pbxproj` by hand. This file walks you through the steps once. + +## Prerequisites + +- Xcode 26+ +- Apple Developer account +- The desktop app (`RxCode` scheme) already builds cleanly. + +## Step 1 — Add the iOS app target + +1. Open `RxCode.xcodeproj` in Xcode. +2. File → New → Target… → **App** (iOS). +3. Product Name: **`RxCodeMobile`** +4. Bundle Identifier: **`com.idealapp.RxCode.Mobile`** +5. Interface: SwiftUI · Language: Swift · Storage: None · Tests: optional +6. Deployment target: **iOS 26.0** (or whatever you confirmed in the plan) +7. After creation, **delete** the generated `ContentView.swift`, + `RxCodeMobileApp.swift` and `Assets.xcassets` stub — we ship our own. + +## Step 2 — Add the source files + +In Project Navigator, right-click the new `RxCodeMobile` group → +**Add Files to "RxCode"…** and select: + +``` +RxCodeMobile/RxCodeMobileApp.swift +RxCodeMobile/AppDelegate.swift +RxCodeMobile/Info.plist (replace the auto-generated one) +RxCodeMobile/RxCodeMobile.entitlements +RxCodeMobile/State/MobileAppState.swift +RxCodeMobile/Views/RootView.swift +RxCodeMobile/Views/OnboardingView.swift +RxCodeMobile/Views/QRScannerView.swift +RxCodeMobile/Views/ProjectsSidebar.swift +RxCodeMobile/Views/SessionsList.swift +RxCodeMobile/Views/MobileChatView.swift +RxCodeMobile/Views/MobileMessageBubble.swift +RxCodeMobile/Views/MobileInputBar.swift +RxCodeMobile/Views/PermissionApprovalSheet.swift +RxCodeMobile/Views/MobileSettingsView.swift +``` + +Make sure **"Add to targets"** has **only** `RxCodeMobile` checked, not +`RxCode` (macOS). + +## Step 3 — Link Swift package products + +`RxCodeMobile` → **General** → **Frameworks, Libraries, and Embedded +Content** → **+** → add: + +- `RxCodeCore` +- `RxCodeChatKit` +- `RxCodeSync` + +The local `Packages` reference should already be visible — these are the +same products the macOS app links. + +## Step 4 — Add the Notification Service Extension + +1. File → New → Target… → **Notification Service Extension**. +2. Product Name: **`RxCodeMobileNotificationService`** +3. Bundle Identifier: **`com.idealapp.RxCode.Mobile.NotificationService`** + (must be `.` for iOS to load it) +4. Embed in Application: **`RxCodeMobile`** +5. After creation, delete the auto-generated `NotificationService.swift` and + add ours from `RxCodeMobileNotificationService/`. Same for the Info.plist + and entitlements files. Link `RxCodeSync` to this target as well (no + need for `RxCodeCore` / `RxCodeChatKit`). + +## Step 5 — Configure capabilities + +For **`RxCodeMobile`**: + +- Signing & Capabilities → **+ Capability** → **Push Notifications** +- **+ Capability** → **Background Modes** — check + *Remote notifications* and *Background processing* +- **+ Capability** → **Keychain Sharing** — add group + `com.idealapp.RxCode.Mobile.shared` (Xcode prepends the team prefix + at sign time) + +For **`RxCodeMobileNotificationService`**: + +- **+ Capability** → **Keychain Sharing** — add the same group + `com.idealapp.RxCode.Mobile.shared` + +The Keychain group lets the extension read the device's long-term +Curve25519 private key written by the main app, so it can decrypt the +`enc` blob carried in incoming APNs payloads. + +## Step 6 — APNs key on the relay server + +Configure the relay server with an APNs auth key (`.p8`) for the topic +`com.idealapp.RxCode.Mobile`. See `relay-server/README.md`. + +## Step 7 — First run + +1. Build + run `RxCode` (macOS) on your Mac. +2. Run the relay: `cd relay-server && go run . -addr :8787 …` (see relay + README for APNs flags). +3. In the Mac app, **Settings → Mobile**, set the relay URL (e.g. + `ws://your-mac.local:8787` for LAN dev), then tap **Pair new device** + to show the QR. +4. Build + run `RxCodeMobile` on a device or iPad simulator. On the + onboarding screen, tap **Scan QR** and point at the Mac's screen. +5. The Mac shows a confirmation modal — tap **Accept** — and the mobile + transitions to the root split view. +6. Open a project on Mac and start a session — the mobile mirrors it. + +## Known limitations (v1) + +- The relay drops envelopes on offline by design; mobile re-syncs via + `requestSnapshot` on every reconnect. +- The main app currently only fans notifications out over the live + WebSocket channel. The desktop-side APNs `POST /push` integration that + packages encrypted alerts and submits them to the relay is wired into + the protocol and NSE but the desktop submitter is left as a follow-up + patch — see `MobileSyncService.broadcastNotification`. +- The desktop `MobileSyncService` listens for inbound `userMessage` / + `newSessionRequest` / `requestSnapshot` events but the AppState bridge + that actually routes those into `ClaudeService.send()` and emits + `snapshot` payloads is left as a follow-up patch — the + `NotificationCenter` posts (`.mobileSync*`) are the seam to wire up. +- iOS app's snapshot rendering covers projects, sessions, and chat + messages, but session-level streaming-state mirroring (token usage, + status chips) is not surfaced yet — those land via `sessionUpdate` + payloads but the iOS UI doesn't display them. diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift new file mode 100644 index 00000000..d57a23fc --- /dev/null +++ b/RxCodeMobile/State/MobileAppState.swift @@ -0,0 +1,247 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeSync + +/// Single source of truth for the mobile app. Owns the `SyncClient`, the +/// decoded projects/sessions/messages mirrored from the paired desktop, and +/// the pairing flow. +@MainActor +final class MobileAppState: ObservableObject { + @Published var isPaired: Bool = false + @Published var pairedDesktopName: String = "" + @Published var pairedDesktopPubkey: String = "" + @Published var connectionState: RelayClient.ConnectionState = .disconnected + @Published var relayURL: URL + + @Published var projects: [Project] = [] + @Published var sessions: [SessionSummary] = [] + @Published var messagesBySession: [String: [ChatMessage]] = [:] + @Published var activeSessionID: String? + @Published var pendingPermission: PermissionRequestPayload? + + private let identity: DeviceIdentity + private let client: SyncClient + private var eventTask: Task? + private var apnsTokenHex: String? + private var apnsEnvironment: String? + + init() { + let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") + let initial = URL(string: stored ?? "wss://example.invalid") ?? URL(string: "wss://example.invalid")! + self.relayURL = initial + do { + // Shared access group lets the Notification Service Extension + // read the private key for decrypting APNs alerts. + self.identity = try DeviceIdentity.loadOrCreate( + accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + ) + } catch { + fatalError("Failed to load mobile device identity: \(error)") + } + self.client = SyncClient(identity: identity, relayURL: initial) + loadPairedDesktop() + } + + var localPublicKeyHex: String { identity.publicKeyHex } + + func start() { + Task { @MainActor in + if !pairedDesktopPubkey.isEmpty { + try? await client.addPeer(pairedDesktopPubkey) + } + await client.start() + let events = await client.events() + eventTask?.cancel() + eventTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in events { + self.handle(event) + } + } + // 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 reportAPNsTokenIfPending() + } + } + } + + // MARK: - Pairing + + func pair(with token: PairingToken, displayName: String) async { + guard !token.isExpired, + let desktopKey = token.desktopPublicKey else { return } + let desktopHex = token.desktopPubkeyHex + try? await client.addPeer(desktopHex) + // Persist the relay URL we just learned from the QR. + if let url = URL(string: token.relayURL) { + UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") + relayURL = url + } + let req = PairRequestPayload( + mobilePubkeyHex: identity.publicKeyHex, + displayName: displayName, + platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + ) + try? await client.send(.pairRequest(req), toHex: desktopHex) + _ = desktopKey // silence unused + } + + // MARK: - User intents + + func sendUserMessage(_ text: String, sessionID: String) async { + guard isPaired else { return } + let payload = UserMessagePayload(sessionID: sessionID, text: text) + try? await client.send(.userMessage(payload), toHex: pairedDesktopPubkey) + } + + func requestNewSession(projectID: UUID, initialText: String? = nil) async { + guard isPaired else { return } + let payload = NewSessionRequestPayload(projectID: projectID, initialText: initialText) + try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) + } + + func subscribe(to sessionID: String?) async { + activeSessionID = sessionID + guard isPaired else { return } + let payload = SubscribeSessionPayload(sessionID: sessionID) + try? await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) + } + + func respondToPermission(allow: Bool, denyReason: String? = nil) async { + guard let pending = pendingPermission else { return } + let payload = PermissionResponsePayload( + requestID: pending.requestID, + allow: allow, + denyReason: denyReason + ) + try? await client.send(.permissionResponse(payload), toHex: pairedDesktopPubkey) + pendingPermission = nil + } + + func reportAPNsToken(hex: String, environment: String) { + apnsTokenHex = hex + apnsEnvironment = environment + Task { await reportAPNsTokenIfPending() } + } + + func unpair() async { + if !pairedDesktopPubkey.isEmpty { + await client.removePeer(pairedDesktopPubkey) + } + pairedDesktopPubkey = "" + pairedDesktopName = "" + isPaired = false + savePairedDesktop() + try? DeviceIdentity.reset( + accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + ) + } + + // MARK: - Inbound events + + private func handle(_ event: RelayClient.Event) { + switch event { + case .stateChanged(let state): + connectionState = state + if case .connected = state, isPaired { + Task { await self.requestSnapshot() } + } + case .deliveryFailed: + break + case .inbound(let inbound): + handleInbound(inbound) + } + } + + private func handleInbound(_ inbound: RelayClient.Inbound) { + switch inbound.payload { + case .pairAck(let ack): + if ack.accepted { + pairedDesktopPubkey = inbound.fromHex + pairedDesktopName = ack.desktopName + isPaired = true + savePairedDesktop() + Task { + await self.requestSnapshot() + await self.reportAPNsTokenIfPending() + } + } else { + isPaired = false + } + case .snapshot(let snap): + projects = snap.projects + sessions = snap.sessions + if let active = snap.activeSessionID, let messages = snap.activeSessionMessages { + messagesBySession[active] = messages + activeSessionID = active + } + case .sessionUpdate(let update): + applySessionUpdate(update) + case .permissionRequest(let req): + pendingPermission = req + case .notification: + // Foreground notifications arriving over WS — iOS won't show a + // banner automatically; UI surfaces these in a toast/badge. + break + case .ping: + Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } + default: + break + } + } + + private func applySessionUpdate(_ update: SessionUpdatePayload) { + switch update.kind { + case .messageAppended: + if let m = update.message { + messagesBySession[update.sessionID, default: []].append(m) + } + case .messageUpdated: + if let m = update.message, + var list = messagesBySession[update.sessionID], + let idx = list.firstIndex(where: { $0.id == m.id }) { + list[idx] = m + messagesBySession[update.sessionID] = list + } + case .streamingStarted, .streamingFinished, .statusChanged: + // Surface as a flag on the relevant session row. + break + } + } + + private func requestSnapshot() async { + guard isPaired else { return } + let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) + try? await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) + } + + private func reportAPNsTokenIfPending() async { + guard isPaired, + let tokenHex = apnsTokenHex, + let env = apnsEnvironment else { return } + let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) + try? await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) + } + + // MARK: - Persistence + + private func loadPairedDesktop() { + pairedDesktopPubkey = UserDefaults.standard.string(forKey: "mobileSync.desktopPubkey") ?? "" + pairedDesktopName = UserDefaults.standard.string(forKey: "mobileSync.desktopName") ?? "" + isPaired = !pairedDesktopPubkey.isEmpty + } + + private func savePairedDesktop() { + UserDefaults.standard.set(pairedDesktopPubkey, forKey: "mobileSync.desktopPubkey") + UserDefaults.standard.set(pairedDesktopName, forKey: "mobileSync.desktopName") + } +} + +#if canImport(UIKit) +import UIKit +#endif diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift new file mode 100644 index 00000000..21b28866 --- /dev/null +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import RxCodeCore + +/// Read-write chat view. User messages are forwarded to the desktop and the +/// desktop agent's stream is mirrored back as `session_update` payloads. +struct MobileChatView: View { + @EnvironmentObject private var state: MobileAppState + let sessionID: String + @State private var composer: String = "" + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(messages, id: \.id) { message in + MobileMessageBubble(message: message) + .id(message.id) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .onChange(of: messages.last?.id) { _, _ in + if let last = messages.last?.id { + withAnimation { proxy.scrollTo(last, anchor: .bottom) } + } + } + } + Divider() + MobileInputBar(text: $composer) { trimmed in + Task { + await state.sendUserMessage(trimmed, sessionID: sessionID) + composer = "" + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + } + + private var messages: [ChatMessage] { state.messagesBySession[sessionID] ?? [] } + + private var title: String { + state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" + } +} diff --git a/RxCodeMobile/Views/MobileInputBar.swift b/RxCodeMobile/Views/MobileInputBar.swift new file mode 100644 index 00000000..baf5a4e3 --- /dev/null +++ b/RxCodeMobile/Views/MobileInputBar.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct MobileInputBar: View { + @Binding var text: String + let onSend: (String) -> Void + @FocusState private var focused: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Message…", text: $text, axis: .vertical) + .focused($focused) + .lineLimit(1...6) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.gray.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + Button { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSend(trimmed) + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(canSend ? Color.accentColor : Color.gray) + } + .disabled(!canSend) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var canSend: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/RxCodeMobile/Views/MobileMessageBubble.swift b/RxCodeMobile/Views/MobileMessageBubble.swift new file mode 100644 index 00000000..44e2675f --- /dev/null +++ b/RxCodeMobile/Views/MobileMessageBubble.swift @@ -0,0 +1,60 @@ +import SwiftUI +import RxCodeCore + +/// Minimal iOS message bubble. Intentionally simpler than the desktop's +/// `MessageBubble`: just text + tool-call summaries, no markdown highlighting +/// or rich diffs. The mobile companion is a glance-and-respond view; deep +/// inspection still lives on the desktop. +struct MobileMessageBubble: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .top) { + if message.role == .user { Spacer(minLength: 40) } + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(message.blocks.enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + .padding(12) + .background(bubbleBackground) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + if message.role == .assistant { Spacer(minLength: 40) } + } + } + + @ViewBuilder + private func blockView(_ block: MessageBlock) -> some View { + if let text = block.text, !text.isEmpty { + Text(text) + .textSelection(.enabled) + .font(.body) + .foregroundStyle(textColor) + } else if let tool = block.toolCall { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver") + .font(.caption) + Text(tool.name) + .font(.caption.monospaced()) + .fontWeight(.semibold) + } + .foregroundStyle(.secondary) + if let result = tool.result, !result.isEmpty { + Text(result) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(4) + } + } + } + } + + private var bubbleBackground: Color { + message.role == .user ? Color.accentColor.opacity(0.18) : Color.gray.opacity(0.12) + } + + private var textColor: Color { + message.role == .user ? .primary : .primary + } +} diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift new file mode 100644 index 00000000..47617341 --- /dev/null +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct MobileSettingsView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var showUnpairConfirm = false + + var body: some View { + NavigationStack { + Form { + Section("Paired Mac") { + HStack { + Image(systemName: "desktopcomputer").frame(width: 22) + Text(state.pairedDesktopName.isEmpty ? "Unknown Mac" : state.pairedDesktopName) + } + HStack { + Text("Connection") + Spacer() + connectionLabel + } + HStack { + Text("Relay") + Spacer() + Text(state.relayURL.absoluteString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Section { + Button(role: .destructive) { + showUnpairConfirm = true + } label: { + Label("Unpair", systemImage: "minus.circle") + } + } footer: { + Text("Unpairing regenerates this device's identity. You'll need to scan a fresh QR to re-pair.") + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .alert("Unpair this device?", isPresented: $showUnpairConfirm) { + Button("Cancel", role: .cancel) {} + Button("Unpair", role: .destructive) { + Task { await state.unpair() } + dismiss() + } + } + } + } + + @ViewBuilder + private var connectionLabel: some View { + switch state.connectionState { + case .connected: + Label("Live", systemImage: "checkmark.circle.fill").foregroundStyle(.green) + case .connecting: + Label("Connecting…", systemImage: "circle.dotted") + case .disconnected: + Label("Disconnected", systemImage: "circle.slash").foregroundStyle(.secondary) + case .reconnecting: + Label("Reconnecting…", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) + } + } +} diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift new file mode 100644 index 00000000..a7603748 --- /dev/null +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -0,0 +1,72 @@ +import SwiftUI +import RxCodeSync + +struct OnboardingView: View { + @EnvironmentObject private var state: MobileAppState + @State private var showScanner = false + @State private var pairingError: String? + @State private var displayName: String = UIDevice.current.name + + var body: some View { + VStack(spacing: 24) { + Image(systemName: "iphone.gen3.badge.checkmark") + .font(.system(size: 72)) + .foregroundStyle(.tint) + Text("Pair with your Mac") + .font(.title2) + .fontWeight(.semibold) + Text("Open RxCode on your Mac, go to Settings → Mobile, and tap “Pair new device”. Then scan the QR code with this app.") + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + + VStack(alignment: .leading, spacing: 6) { + Text("Device name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Device name", text: $displayName) + .textFieldStyle(.roundedBorder) + } + .padding(.horizontal, 40) + + Button { + showScanner = true + } label: { + Label("Scan QR", systemImage: "qrcode.viewfinder") + .frame(maxWidth: 240) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + + if let err = pairingError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + .padding(.vertical, 40) + .sheet(isPresented: $showScanner) { + QRScannerView { result in + showScanner = false + handleScan(result) + } + } + } + + private func handleScan(_ value: String) { + do { + let token = try PairingToken.parse(value) + guard !token.isExpired else { + pairingError = "This QR has expired. Generate a new one on your Mac." + return + } + pairingError = nil + Task { await state.pair(with: token, displayName: displayName) } + } catch { + pairingError = "Unrecognized QR. Try scanning the one from the Mac app." + } + } +} diff --git a/RxCodeMobile/Views/PermissionApprovalSheet.swift b/RxCodeMobile/Views/PermissionApprovalSheet.swift new file mode 100644 index 00000000..2d416964 --- /dev/null +++ b/RxCodeMobile/Views/PermissionApprovalSheet.swift @@ -0,0 +1,67 @@ +import SwiftUI +import RxCodeSync + +struct PermissionApprovalSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let request: PermissionRequestPayload + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "lock.shield") + .font(.title) + Text("Permission needed") + .font(.title3) + .fontWeight(.semibold) + } + VStack(alignment: .leading, spacing: 6) { + Text("Tool") + .font(.caption) + .foregroundStyle(.secondary) + Text(request.toolName) + .font(.body.monospaced()) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + VStack(alignment: .leading, spacing: 6) { + Text("Input") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView { + Text(request.toolInputJSON) + .font(.caption.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(maxHeight: 180) + .background(Color.gray.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + Spacer(minLength: 8) + HStack(spacing: 12) { + Button("Deny", role: .destructive) { + Task { + await state.respondToPermission(allow: false) + dismiss() + } + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + + Button("Allow") { + Task { + await state.respondToPermission(allow: true) + dismiss() + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + } + .padding(20) + .presentationDetents([.medium, .large]) + } +} diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift new file mode 100644 index 00000000..e7aa2dfb --- /dev/null +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct ProjectsSidebar: View { + @EnvironmentObject private var state: MobileAppState + @Binding var selected: UUID? + + var body: some View { + List(state.projects, selection: $selected) { project in + NavigationLink(value: project.id) { + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.headline) + Text(project.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .navigationTitle("Projects") + .listStyle(.sidebar) + } +} diff --git a/RxCodeMobile/Views/QRScannerView.swift b/RxCodeMobile/Views/QRScannerView.swift new file mode 100644 index 00000000..b0ed65f5 --- /dev/null +++ b/RxCodeMobile/Views/QRScannerView.swift @@ -0,0 +1,63 @@ +import SwiftUI +import AVFoundation + +/// Presents the rear camera and reports the first detected QR string. +struct QRScannerView: UIViewControllerRepresentable { + let onResult: (String) -> Void + + func makeUIViewController(context: Context) -> ScannerVC { + let vc = ScannerVC() + vc.onResult = onResult + return vc + } + + func updateUIViewController(_ uiViewController: ScannerVC, context: Context) {} + + final class ScannerVC: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onResult: ((String) -> Void)? + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + } + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.layer.bounds + view.layer.addSublayer(preview) + previewLayer = preview + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.session.startRunning() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.layer.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if session.isRunning { session.stopRunning() } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = obj.stringValue else { return } + session.stopRunning() + onResult?(value) + } + } +} diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift new file mode 100644 index 00000000..f73cf226 --- /dev/null +++ b/RxCodeMobile/Views/RootView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// Mobile app root. On iPad / wide screens this uses NavigationSplitView; on +/// iPhone it auto-collapses to a stack. +struct RootView: View { + @EnvironmentObject private var state: MobileAppState + @State private var selectedProject: UUID? + @State private var selectedSession: String? + @State private var showSettings = false + + var body: some View { + Group { + if state.isPaired { + paired + } else { + OnboardingView() + } + } + .sheet(item: $state.pendingPermission) { req in + PermissionApprovalSheet(request: req) + .environmentObject(state) + } + } + + private var paired: some View { + NavigationSplitView { + ProjectsSidebar(selected: $selectedProject) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showSettings = true } label: { + Image(systemName: "gear") + } + } + } + } content: { + if let projectID = selectedProject { + SessionsList(projectID: projectID, selected: $selectedSession) + } else { + Text("Select a project") + .foregroundStyle(.secondary) + } + } detail: { + if let sessionID = selectedSession { + MobileChatView(sessionID: sessionID) + .id(sessionID) + } else { + Text("Select a thread") + .foregroundStyle(.secondary) + } + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $showSettings) { + MobileSettingsView() + .environmentObject(state) + } + .onChange(of: selectedSession) { _, newValue in + Task { await state.subscribe(to: newValue) } + } + } +} diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift new file mode 100644 index 00000000..a0dff85e --- /dev/null +++ b/RxCodeMobile/Views/SessionsList.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct SessionsList: View { + @EnvironmentObject private var state: MobileAppState + let projectID: UUID + @Binding var selected: String? + + var body: some View { + List(filtered, selection: $selected) { session in + NavigationLink(value: session.id) { + VStack(alignment: .leading, spacing: 2) { + HStack { + if session.isPinned { + Image(systemName: "pin.fill") + .font(.caption) + .foregroundStyle(.orange) + } + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.headline) + } + Text(session.updatedAt.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Threads") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await state.requestNewSession(projectID: projectID) } + } label: { + Image(systemName: "square.and.pencil") + } + } + } + } + + private var filtered: [SessionSummary] { + state.sessions + .filter { $0.projectId == projectID && !$0.isArchived } + .sorted { $0.updatedAt > $1.updatedAt } + } +} diff --git a/RxCodeMobileNotificationService/Info.plist b/RxCodeMobileNotificationService/Info.plist new file mode 100644 index 00000000..360689da --- /dev/null +++ b/RxCodeMobileNotificationService/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + RxCodeMobileNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/RxCodeMobileNotificationService/NotificationService.swift b/RxCodeMobileNotificationService/NotificationService.swift new file mode 100644 index 00000000..0e37ea01 --- /dev/null +++ b/RxCodeMobileNotificationService/NotificationService.swift @@ -0,0 +1,50 @@ +import UserNotifications +import CryptoKit +import RxCodeSync + +/// Notification Service Extension: decrypts the `enc` payload that the desktop +/// sealed for this device and rewrites the visible alert before iOS displays +/// the banner. The shared Keychain access group makes the device's long-term +/// Curve25519 private key available to this extension. +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + self.bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent) + guard let attempt = bestAttempt else { + contentHandler(request.content); return + } + + guard let encB64 = request.content.userInfo["enc"] as? String, + let raw = Data(base64Encoded: encB64), + let envelope = try? JSONDecoder().decode(EncryptedAlert.self, from: raw), + let identity = try? DeviceIdentity.loadOrCreate( + accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + ), + let senderRaw = Data(hexString: envelope.from), + let senderKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: senderRaw), + let plaintext = try? APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) + else { + contentHandler(attempt) + return + } + + attempt.title = plaintext.title + attempt.body = plaintext.body + var userInfo = attempt.userInfo + if let sessionID = plaintext.sessionID { userInfo["sessionId"] = sessionID } + if let projectID = plaintext.projectID { userInfo["projectId"] = projectID.uuidString } + if let kind = plaintext.kind { userInfo["kind"] = kind } + attempt.userInfo = userInfo + contentHandler(attempt) + } + + override func serviceExtensionTimeWillExpire() { + if let handler = contentHandler, let attempt = bestAttempt { + handler(attempt) + } + } +} diff --git a/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements b/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements new file mode 100644 index 00000000..1051617f --- /dev/null +++ b/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared + + + diff --git a/RxCodeMobileTests/RxCodeMobileTests.swift b/RxCodeMobileTests/RxCodeMobileTests.swift new file mode 100644 index 00000000..16518d97 --- /dev/null +++ b/RxCodeMobileTests/RxCodeMobileTests.swift @@ -0,0 +1,36 @@ +// +// RxCodeMobileTests.swift +// RxCodeMobileTests +// +// Created by Qiwei Li on 5/19/26. +// + +import XCTest +@testable import RxCodeMobile + +final class RxCodeMobileTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/RxCodeMobileUITests/RxCodeMobileUITests.swift b/RxCodeMobileUITests/RxCodeMobileUITests.swift new file mode 100644 index 00000000..f2f01a32 --- /dev/null +++ b/RxCodeMobileUITests/RxCodeMobileUITests.swift @@ -0,0 +1,41 @@ +// +// RxCodeMobileUITests.swift +// RxCodeMobileUITests +// +// Created by Qiwei Li on 5/19/26. +// + +import XCTest + +final class RxCodeMobileUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/RxCodeMobileUITests/RxCodeMobileUITestsLaunchTests.swift b/RxCodeMobileUITests/RxCodeMobileUITestsLaunchTests.swift new file mode 100644 index 00000000..18b946fa --- /dev/null +++ b/RxCodeMobileUITests/RxCodeMobileUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// RxCodeMobileUITestsLaunchTests.swift +// RxCodeMobileUITests +// +// Created by Qiwei Li on 5/19/26. +// + +import XCTest + +final class RxCodeMobileUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/RxCodeTests/RxCodeMobile/AppDelegate.swift b/RxCodeTests/RxCodeMobile/AppDelegate.swift new file mode 100644 index 00000000..09fb6fd2 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/AppDelegate.swift @@ -0,0 +1,44 @@ +import UIKit +import UserNotifications + +/// Bridges UIKit's APNs registration into `MobileAppState`. The state object +/// forwards the device token to the paired desktop over the encrypted relay. +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + weak var mobileState: MobileAppState? + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + UNUserNotificationCenter.current().delegate = self + Task { + let granted = (try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge, .sound])) ?? false + if granted { + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenHex = deviceToken.map { String(format: "%02x", $0) }.joined() +#if DEBUG + let environment = "sandbox" +#else + let environment = "production" +#endif + mobileState?.reportAPNsToken(hex: tokenHex, environment: environment) + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + // Best-effort only; relay channel still works. + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .sound, .list] + } +} diff --git a/RxCodeTests/RxCodeMobile/Info.plist b/RxCodeTests/RxCodeMobile/Info.plist new file mode 100644 index 00000000..515e5943 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + RxCode + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSCameraUsageDescription + Scan a QR code shown by RxCode on your Mac to pair this device. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + remote-notification + processing + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/RxCodeTests/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeTests/RxCodeMobile/RxCodeMobile.entitlements new file mode 100644 index 00000000..c9170c68 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/RxCodeMobile.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + keychain-access-groups + + $(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared + + + diff --git a/RxCodeTests/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeTests/RxCodeMobile/RxCodeMobileApp.swift new file mode 100644 index 00000000..27328ffd --- /dev/null +++ b/RxCodeTests/RxCodeMobile/RxCodeMobileApp.swift @@ -0,0 +1,19 @@ +import SwiftUI +import RxCodeSync + +@main +struct RxCodeMobileApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var state = MobileAppState() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(state) + .onAppear { + appDelegate.mobileState = state + state.start() + } + } + } +} diff --git a/RxCodeTests/RxCodeMobile/SETUP.md b/RxCodeTests/RxCodeMobile/SETUP.md new file mode 100644 index 00000000..b205f20e --- /dev/null +++ b/RxCodeTests/RxCodeMobile/SETUP.md @@ -0,0 +1,131 @@ +# RxCodeMobile — Xcode setup + +This directory holds the source files for the **iOS / iPadOS companion app** +and its Notification Service Extension. Adding new app + extension targets +safely is best done in the Xcode UI rather than by editing +`project.pbxproj` by hand. This file walks you through the steps once. + +## Prerequisites + +- Xcode 26+ +- Apple Developer account +- The desktop app (`RxCode` scheme) already builds cleanly. + +## Step 1 — Add the iOS app target + +1. Open `RxCode.xcodeproj` in Xcode. +2. File → New → Target… → **App** (iOS). +3. Product Name: **`RxCodeMobile`** +4. Bundle Identifier: **`com.idealapp.RxCode.Mobile`** +5. Interface: SwiftUI · Language: Swift · Storage: None · Tests: optional +6. Deployment target: **iOS 26.0** (or whatever you confirmed in the plan) +7. After creation, **delete** the generated `ContentView.swift`, + `RxCodeMobileApp.swift` and `Assets.xcassets` stub — we ship our own. + +## Step 2 — Add the source files + +In Project Navigator, right-click the new `RxCodeMobile` group → +**Add Files to "RxCode"…** and select: + +``` +RxCodeMobile/RxCodeMobileApp.swift +RxCodeMobile/AppDelegate.swift +RxCodeMobile/Info.plist (replace the auto-generated one) +RxCodeMobile/RxCodeMobile.entitlements +RxCodeMobile/State/MobileAppState.swift +RxCodeMobile/Views/RootView.swift +RxCodeMobile/Views/OnboardingView.swift +RxCodeMobile/Views/QRScannerView.swift +RxCodeMobile/Views/ProjectsSidebar.swift +RxCodeMobile/Views/SessionsList.swift +RxCodeMobile/Views/MobileChatView.swift +RxCodeMobile/Views/MobileMessageBubble.swift +RxCodeMobile/Views/MobileInputBar.swift +RxCodeMobile/Views/PermissionApprovalSheet.swift +RxCodeMobile/Views/MobileSettingsView.swift +``` + +Make sure **"Add to targets"** has **only** `RxCodeMobile` checked, not +`RxCode` (macOS). + +## Step 3 — Link Swift package products + +`RxCodeMobile` → **General** → **Frameworks, Libraries, and Embedded +Content** → **+** → add: + +- `RxCodeCore` +- `RxCodeChatKit` +- `RxCodeSync` + +The local `Packages` reference should already be visible — these are the +same products the macOS app links. + +## Step 4 — Add the Notification Service Extension + +1. File → New → Target… → **Notification Service Extension**. +2. Product Name: **`RxCodeMobileNotificationService`** +3. Bundle Identifier: **`com.idealapp.RxCode.Mobile.NotificationService`** + (must be `.` for iOS to load it) +4. Embed in Application: **`RxCodeMobile`** +5. After creation, delete the auto-generated `NotificationService.swift` and + add ours from `RxCodeMobileNotificationService/`. Same for the Info.plist + and entitlements files. Link `RxCodeSync` to this target as well (no + need for `RxCodeCore` / `RxCodeChatKit`). + +## Step 5 — Configure capabilities + +For **`RxCodeMobile`**: + +- Signing & Capabilities → **+ Capability** → **Push Notifications** +- **+ Capability** → **Background Modes** — check + *Remote notifications* and *Background processing* +- **+ Capability** → **Keychain Sharing** — add group + `com.idealapp.RxCode.Mobile.shared` (Xcode prepends the team prefix + at sign time) + +For **`RxCodeMobileNotificationService`**: + +- **+ Capability** → **Keychain Sharing** — add the same group + `com.idealapp.RxCode.Mobile.shared` + +The Keychain group lets the extension read the device's long-term +Curve25519 private key written by the main app, so it can decrypt the +`enc` blob carried in incoming APNs payloads. + +## Step 6 — APNs key on the relay server + +Configure the relay server with an APNs auth key (`.p8`) for the topic +`com.idealapp.RxCode.Mobile`. See `relay-server/README.md`. + +## Step 7 — First run + +1. Build + run `RxCode` (macOS) on your Mac. +2. Run the relay: `cd relay-server && go run . -addr :8787 …` (see relay + README for APNs flags). +3. In the Mac app, **Settings → Mobile**, set the relay URL (e.g. + `ws://your-mac.local:8787` for LAN dev), then tap **Pair new device** + to show the QR. +4. Build + run `RxCodeMobile` on a device or iPad simulator. On the + onboarding screen, tap **Scan QR** and point at the Mac's screen. +5. The Mac shows a confirmation modal — tap **Accept** — and the mobile + transitions to the root split view. +6. Open a project on Mac and start a session — the mobile mirrors it. + +## Known limitations (v1) + +- The relay drops envelopes on offline by design; mobile re-syncs via + `requestSnapshot` on every reconnect. +- The main app currently only fans notifications out over the live + WebSocket channel. The desktop-side APNs `POST /push` integration that + packages encrypted alerts and submits them to the relay is wired into + the protocol and NSE but the desktop submitter is left as a follow-up + patch — see `MobileSyncService.broadcastNotification`. +- The desktop `MobileSyncService` listens for inbound `userMessage` / + `newSessionRequest` / `requestSnapshot` events but the AppState bridge + that actually routes those into `ClaudeService.send()` and emits + `snapshot` payloads is left as a follow-up patch — the + `NotificationCenter` posts (`.mobileSync*`) are the seam to wire up. +- iOS app's snapshot rendering covers projects, sessions, and chat + messages, but session-level streaming-state mirroring (token usage, + status chips) is not surfaced yet — those land via `sessionUpdate` + payloads but the iOS UI doesn't display them. diff --git a/RxCodeTests/RxCodeMobile/State/MobileAppState.swift b/RxCodeTests/RxCodeMobile/State/MobileAppState.swift new file mode 100644 index 00000000..d57a23fc --- /dev/null +++ b/RxCodeTests/RxCodeMobile/State/MobileAppState.swift @@ -0,0 +1,247 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeSync + +/// Single source of truth for the mobile app. Owns the `SyncClient`, the +/// decoded projects/sessions/messages mirrored from the paired desktop, and +/// the pairing flow. +@MainActor +final class MobileAppState: ObservableObject { + @Published var isPaired: Bool = false + @Published var pairedDesktopName: String = "" + @Published var pairedDesktopPubkey: String = "" + @Published var connectionState: RelayClient.ConnectionState = .disconnected + @Published var relayURL: URL + + @Published var projects: [Project] = [] + @Published var sessions: [SessionSummary] = [] + @Published var messagesBySession: [String: [ChatMessage]] = [:] + @Published var activeSessionID: String? + @Published var pendingPermission: PermissionRequestPayload? + + private let identity: DeviceIdentity + private let client: SyncClient + private var eventTask: Task? + private var apnsTokenHex: String? + private var apnsEnvironment: String? + + init() { + let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") + let initial = URL(string: stored ?? "wss://example.invalid") ?? URL(string: "wss://example.invalid")! + self.relayURL = initial + do { + // Shared access group lets the Notification Service Extension + // read the private key for decrypting APNs alerts. + self.identity = try DeviceIdentity.loadOrCreate( + accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + ) + } catch { + fatalError("Failed to load mobile device identity: \(error)") + } + self.client = SyncClient(identity: identity, relayURL: initial) + loadPairedDesktop() + } + + var localPublicKeyHex: String { identity.publicKeyHex } + + func start() { + Task { @MainActor in + if !pairedDesktopPubkey.isEmpty { + try? await client.addPeer(pairedDesktopPubkey) + } + await client.start() + let events = await client.events() + eventTask?.cancel() + eventTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in events { + self.handle(event) + } + } + // 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 reportAPNsTokenIfPending() + } + } + } + + // MARK: - Pairing + + func pair(with token: PairingToken, displayName: String) async { + guard !token.isExpired, + let desktopKey = token.desktopPublicKey else { return } + let desktopHex = token.desktopPubkeyHex + try? await client.addPeer(desktopHex) + // Persist the relay URL we just learned from the QR. + if let url = URL(string: token.relayURL) { + UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") + relayURL = url + } + let req = PairRequestPayload( + mobilePubkeyHex: identity.publicKeyHex, + displayName: displayName, + platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + ) + try? await client.send(.pairRequest(req), toHex: desktopHex) + _ = desktopKey // silence unused + } + + // MARK: - User intents + + func sendUserMessage(_ text: String, sessionID: String) async { + guard isPaired else { return } + let payload = UserMessagePayload(sessionID: sessionID, text: text) + try? await client.send(.userMessage(payload), toHex: pairedDesktopPubkey) + } + + func requestNewSession(projectID: UUID, initialText: String? = nil) async { + guard isPaired else { return } + let payload = NewSessionRequestPayload(projectID: projectID, initialText: initialText) + try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) + } + + func subscribe(to sessionID: String?) async { + activeSessionID = sessionID + guard isPaired else { return } + let payload = SubscribeSessionPayload(sessionID: sessionID) + try? await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) + } + + func respondToPermission(allow: Bool, denyReason: String? = nil) async { + guard let pending = pendingPermission else { return } + let payload = PermissionResponsePayload( + requestID: pending.requestID, + allow: allow, + denyReason: denyReason + ) + try? await client.send(.permissionResponse(payload), toHex: pairedDesktopPubkey) + pendingPermission = nil + } + + func reportAPNsToken(hex: String, environment: String) { + apnsTokenHex = hex + apnsEnvironment = environment + Task { await reportAPNsTokenIfPending() } + } + + func unpair() async { + if !pairedDesktopPubkey.isEmpty { + await client.removePeer(pairedDesktopPubkey) + } + pairedDesktopPubkey = "" + pairedDesktopName = "" + isPaired = false + savePairedDesktop() + try? DeviceIdentity.reset( + accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + ) + } + + // MARK: - Inbound events + + private func handle(_ event: RelayClient.Event) { + switch event { + case .stateChanged(let state): + connectionState = state + if case .connected = state, isPaired { + Task { await self.requestSnapshot() } + } + case .deliveryFailed: + break + case .inbound(let inbound): + handleInbound(inbound) + } + } + + private func handleInbound(_ inbound: RelayClient.Inbound) { + switch inbound.payload { + case .pairAck(let ack): + if ack.accepted { + pairedDesktopPubkey = inbound.fromHex + pairedDesktopName = ack.desktopName + isPaired = true + savePairedDesktop() + Task { + await self.requestSnapshot() + await self.reportAPNsTokenIfPending() + } + } else { + isPaired = false + } + case .snapshot(let snap): + projects = snap.projects + sessions = snap.sessions + if let active = snap.activeSessionID, let messages = snap.activeSessionMessages { + messagesBySession[active] = messages + activeSessionID = active + } + case .sessionUpdate(let update): + applySessionUpdate(update) + case .permissionRequest(let req): + pendingPermission = req + case .notification: + // Foreground notifications arriving over WS — iOS won't show a + // banner automatically; UI surfaces these in a toast/badge. + break + case .ping: + Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } + default: + break + } + } + + private func applySessionUpdate(_ update: SessionUpdatePayload) { + switch update.kind { + case .messageAppended: + if let m = update.message { + messagesBySession[update.sessionID, default: []].append(m) + } + case .messageUpdated: + if let m = update.message, + var list = messagesBySession[update.sessionID], + let idx = list.firstIndex(where: { $0.id == m.id }) { + list[idx] = m + messagesBySession[update.sessionID] = list + } + case .streamingStarted, .streamingFinished, .statusChanged: + // Surface as a flag on the relevant session row. + break + } + } + + private func requestSnapshot() async { + guard isPaired else { return } + let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) + try? await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) + } + + private func reportAPNsTokenIfPending() async { + guard isPaired, + let tokenHex = apnsTokenHex, + let env = apnsEnvironment else { return } + let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) + try? await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) + } + + // MARK: - Persistence + + private func loadPairedDesktop() { + pairedDesktopPubkey = UserDefaults.standard.string(forKey: "mobileSync.desktopPubkey") ?? "" + pairedDesktopName = UserDefaults.standard.string(forKey: "mobileSync.desktopName") ?? "" + isPaired = !pairedDesktopPubkey.isEmpty + } + + private func savePairedDesktop() { + UserDefaults.standard.set(pairedDesktopPubkey, forKey: "mobileSync.desktopPubkey") + UserDefaults.standard.set(pairedDesktopName, forKey: "mobileSync.desktopName") + } +} + +#if canImport(UIKit) +import UIKit +#endif diff --git a/RxCodeTests/RxCodeMobile/Views/MobileChatView.swift b/RxCodeTests/RxCodeMobile/Views/MobileChatView.swift new file mode 100644 index 00000000..21b28866 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/MobileChatView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import RxCodeCore + +/// Read-write chat view. User messages are forwarded to the desktop and the +/// desktop agent's stream is mirrored back as `session_update` payloads. +struct MobileChatView: View { + @EnvironmentObject private var state: MobileAppState + let sessionID: String + @State private var composer: String = "" + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(messages, id: \.id) { message in + MobileMessageBubble(message: message) + .id(message.id) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .onChange(of: messages.last?.id) { _, _ in + if let last = messages.last?.id { + withAnimation { proxy.scrollTo(last, anchor: .bottom) } + } + } + } + Divider() + MobileInputBar(text: $composer) { trimmed in + Task { + await state.sendUserMessage(trimmed, sessionID: sessionID) + composer = "" + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + } + + private var messages: [ChatMessage] { state.messagesBySession[sessionID] ?? [] } + + private var title: String { + state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/MobileInputBar.swift b/RxCodeTests/RxCodeMobile/Views/MobileInputBar.swift new file mode 100644 index 00000000..baf5a4e3 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/MobileInputBar.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct MobileInputBar: View { + @Binding var text: String + let onSend: (String) -> Void + @FocusState private var focused: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Message…", text: $text, axis: .vertical) + .focused($focused) + .lineLimit(1...6) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.gray.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + Button { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSend(trimmed) + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(canSend ? Color.accentColor : Color.gray) + } + .disabled(!canSend) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var canSend: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/MobileMessageBubble.swift b/RxCodeTests/RxCodeMobile/Views/MobileMessageBubble.swift new file mode 100644 index 00000000..44e2675f --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/MobileMessageBubble.swift @@ -0,0 +1,60 @@ +import SwiftUI +import RxCodeCore + +/// Minimal iOS message bubble. Intentionally simpler than the desktop's +/// `MessageBubble`: just text + tool-call summaries, no markdown highlighting +/// or rich diffs. The mobile companion is a glance-and-respond view; deep +/// inspection still lives on the desktop. +struct MobileMessageBubble: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .top) { + if message.role == .user { Spacer(minLength: 40) } + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(message.blocks.enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + .padding(12) + .background(bubbleBackground) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + if message.role == .assistant { Spacer(minLength: 40) } + } + } + + @ViewBuilder + private func blockView(_ block: MessageBlock) -> some View { + if let text = block.text, !text.isEmpty { + Text(text) + .textSelection(.enabled) + .font(.body) + .foregroundStyle(textColor) + } else if let tool = block.toolCall { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver") + .font(.caption) + Text(tool.name) + .font(.caption.monospaced()) + .fontWeight(.semibold) + } + .foregroundStyle(.secondary) + if let result = tool.result, !result.isEmpty { + Text(result) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(4) + } + } + } + } + + private var bubbleBackground: Color { + message.role == .user ? Color.accentColor.opacity(0.18) : Color.gray.opacity(0.12) + } + + private var textColor: Color { + message.role == .user ? .primary : .primary + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeTests/RxCodeMobile/Views/MobileSettingsView.swift new file mode 100644 index 00000000..47617341 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/MobileSettingsView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct MobileSettingsView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var showUnpairConfirm = false + + var body: some View { + NavigationStack { + Form { + Section("Paired Mac") { + HStack { + Image(systemName: "desktopcomputer").frame(width: 22) + Text(state.pairedDesktopName.isEmpty ? "Unknown Mac" : state.pairedDesktopName) + } + HStack { + Text("Connection") + Spacer() + connectionLabel + } + HStack { + Text("Relay") + Spacer() + Text(state.relayURL.absoluteString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Section { + Button(role: .destructive) { + showUnpairConfirm = true + } label: { + Label("Unpair", systemImage: "minus.circle") + } + } footer: { + Text("Unpairing regenerates this device's identity. You'll need to scan a fresh QR to re-pair.") + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .alert("Unpair this device?", isPresented: $showUnpairConfirm) { + Button("Cancel", role: .cancel) {} + Button("Unpair", role: .destructive) { + Task { await state.unpair() } + dismiss() + } + } + } + } + + @ViewBuilder + private var connectionLabel: some View { + switch state.connectionState { + case .connected: + Label("Live", systemImage: "checkmark.circle.fill").foregroundStyle(.green) + case .connecting: + Label("Connecting…", systemImage: "circle.dotted") + case .disconnected: + Label("Disconnected", systemImage: "circle.slash").foregroundStyle(.secondary) + case .reconnecting: + Label("Reconnecting…", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) + } + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/OnboardingView.swift b/RxCodeTests/RxCodeMobile/Views/OnboardingView.swift new file mode 100644 index 00000000..a7603748 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/OnboardingView.swift @@ -0,0 +1,72 @@ +import SwiftUI +import RxCodeSync + +struct OnboardingView: View { + @EnvironmentObject private var state: MobileAppState + @State private var showScanner = false + @State private var pairingError: String? + @State private var displayName: String = UIDevice.current.name + + var body: some View { + VStack(spacing: 24) { + Image(systemName: "iphone.gen3.badge.checkmark") + .font(.system(size: 72)) + .foregroundStyle(.tint) + Text("Pair with your Mac") + .font(.title2) + .fontWeight(.semibold) + Text("Open RxCode on your Mac, go to Settings → Mobile, and tap “Pair new device”. Then scan the QR code with this app.") + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + + VStack(alignment: .leading, spacing: 6) { + Text("Device name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Device name", text: $displayName) + .textFieldStyle(.roundedBorder) + } + .padding(.horizontal, 40) + + Button { + showScanner = true + } label: { + Label("Scan QR", systemImage: "qrcode.viewfinder") + .frame(maxWidth: 240) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + + if let err = pairingError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + .padding(.vertical, 40) + .sheet(isPresented: $showScanner) { + QRScannerView { result in + showScanner = false + handleScan(result) + } + } + } + + private func handleScan(_ value: String) { + do { + let token = try PairingToken.parse(value) + guard !token.isExpired else { + pairingError = "This QR has expired. Generate a new one on your Mac." + return + } + pairingError = nil + Task { await state.pair(with: token, displayName: displayName) } + } catch { + pairingError = "Unrecognized QR. Try scanning the one from the Mac app." + } + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/PermissionApprovalSheet.swift b/RxCodeTests/RxCodeMobile/Views/PermissionApprovalSheet.swift new file mode 100644 index 00000000..2d416964 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/PermissionApprovalSheet.swift @@ -0,0 +1,67 @@ +import SwiftUI +import RxCodeSync + +struct PermissionApprovalSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let request: PermissionRequestPayload + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "lock.shield") + .font(.title) + Text("Permission needed") + .font(.title3) + .fontWeight(.semibold) + } + VStack(alignment: .leading, spacing: 6) { + Text("Tool") + .font(.caption) + .foregroundStyle(.secondary) + Text(request.toolName) + .font(.body.monospaced()) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + VStack(alignment: .leading, spacing: 6) { + Text("Input") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView { + Text(request.toolInputJSON) + .font(.caption.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(maxHeight: 180) + .background(Color.gray.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + Spacer(minLength: 8) + HStack(spacing: 12) { + Button("Deny", role: .destructive) { + Task { + await state.respondToPermission(allow: false) + dismiss() + } + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + + Button("Allow") { + Task { + await state.respondToPermission(allow: true) + dismiss() + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + } + .padding(20) + .presentationDetents([.medium, .large]) + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeTests/RxCodeMobile/Views/ProjectsSidebar.swift new file mode 100644 index 00000000..e7aa2dfb --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/ProjectsSidebar.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct ProjectsSidebar: View { + @EnvironmentObject private var state: MobileAppState + @Binding var selected: UUID? + + var body: some View { + List(state.projects, selection: $selected) { project in + NavigationLink(value: project.id) { + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.headline) + Text(project.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .navigationTitle("Projects") + .listStyle(.sidebar) + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/QRScannerView.swift b/RxCodeTests/RxCodeMobile/Views/QRScannerView.swift new file mode 100644 index 00000000..b0ed65f5 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/QRScannerView.swift @@ -0,0 +1,63 @@ +import SwiftUI +import AVFoundation + +/// Presents the rear camera and reports the first detected QR string. +struct QRScannerView: UIViewControllerRepresentable { + let onResult: (String) -> Void + + func makeUIViewController(context: Context) -> ScannerVC { + let vc = ScannerVC() + vc.onResult = onResult + return vc + } + + func updateUIViewController(_ uiViewController: ScannerVC, context: Context) {} + + final class ScannerVC: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onResult: ((String) -> Void)? + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + } + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.layer.bounds + view.layer.addSublayer(preview) + previewLayer = preview + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.session.startRunning() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.layer.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if session.isRunning { session.stopRunning() } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = obj.stringValue else { return } + session.stopRunning() + onResult?(value) + } + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/RootView.swift b/RxCodeTests/RxCodeMobile/Views/RootView.swift new file mode 100644 index 00000000..f73cf226 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/RootView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// Mobile app root. On iPad / wide screens this uses NavigationSplitView; on +/// iPhone it auto-collapses to a stack. +struct RootView: View { + @EnvironmentObject private var state: MobileAppState + @State private var selectedProject: UUID? + @State private var selectedSession: String? + @State private var showSettings = false + + var body: some View { + Group { + if state.isPaired { + paired + } else { + OnboardingView() + } + } + .sheet(item: $state.pendingPermission) { req in + PermissionApprovalSheet(request: req) + .environmentObject(state) + } + } + + private var paired: some View { + NavigationSplitView { + ProjectsSidebar(selected: $selectedProject) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showSettings = true } label: { + Image(systemName: "gear") + } + } + } + } content: { + if let projectID = selectedProject { + SessionsList(projectID: projectID, selected: $selectedSession) + } else { + Text("Select a project") + .foregroundStyle(.secondary) + } + } detail: { + if let sessionID = selectedSession { + MobileChatView(sessionID: sessionID) + .id(sessionID) + } else { + Text("Select a thread") + .foregroundStyle(.secondary) + } + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $showSettings) { + MobileSettingsView() + .environmentObject(state) + } + .onChange(of: selectedSession) { _, newValue in + Task { await state.subscribe(to: newValue) } + } + } +} diff --git a/RxCodeTests/RxCodeMobile/Views/SessionsList.swift b/RxCodeTests/RxCodeMobile/Views/SessionsList.swift new file mode 100644 index 00000000..a0dff85e --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/SessionsList.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct SessionsList: View { + @EnvironmentObject private var state: MobileAppState + let projectID: UUID + @Binding var selected: String? + + var body: some View { + List(filtered, selection: $selected) { session in + NavigationLink(value: session.id) { + VStack(alignment: .leading, spacing: 2) { + HStack { + if session.isPinned { + Image(systemName: "pin.fill") + .font(.caption) + .foregroundStyle(.orange) + } + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.headline) + } + Text(session.updatedAt.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Threads") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await state.requestNewSession(projectID: projectID) } + } label: { + Image(systemName: "square.and.pencil") + } + } + } + } + + private var filtered: [SessionSummary] { + state.sessions + .filter { $0.projectId == projectID && !$0.isArchived } + .sorted { $0.updatedAt > $1.updatedAt } + } +} diff --git a/relay-server/Dockerfile b/relay-server/Dockerfile new file mode 100644 index 00000000..9d0b8246 --- /dev/null +++ b/relay-server/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/relay . + +FROM gcr.io/distroless/static +COPY --from=build /out/relay /relay +EXPOSE 8787 +ENTRYPOINT ["/relay"] diff --git a/relay-server/README.md b/relay-server/README.md new file mode 100644 index 00000000..8ac00518 --- /dev/null +++ b/relay-server/README.md @@ -0,0 +1,88 @@ +# rxcode-relay + +Stateless WebSocket relay + APNs forwarder for the RxCode desktop ↔ mobile sync channel. + +The relay never decrypts payloads. All sync messages are E2E encrypted between +device pairs using Curve25519 + ChaCha20-Poly1305; the relay only sees opaque +envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. + +## Endpoints + +- `GET /ws?pubkey=<64-hex>` — upgrade to WebSocket. The connecting client + claims the pubkey; the relay does not verify ownership because the E2E layer + already prevents reading or forging messages. Drop-on-offline: if the + recipient pubkey isn't currently connected, the envelope is dropped and the + sender receives a `delivery_failed` notice. +- `POST /push` — desktop submits APNs pushes. Body: + ```json + { + "device_token": "", + "encrypted_alert": "", + "category": "permission_request", // optional + "collapse_id": "" // optional + } + ``` + The relay signs a JWT with the configured APNs auth key and forwards the + push. The encrypted alert blob is decrypted on-device by the iOS + Notification Service Extension before iOS displays the banner. +- `GET /healthz` — liveness probe. + +## Run locally + +```bash +go mod tidy +go run . -addr :8787 +``` + +To also enable APNs: + +```bash +go run . \ + -addr :8787 \ + -apns-key ./AuthKey_ABCDE12345.p8 \ + -apns-key-id ABCDE12345 \ + -apns-team-id YYYYYYYYYY \ + -apns-topic com.idealapp.RxCode.Mobile \ + -apns-production=false +``` + +## Docker + +```bash +docker build -t rxcode-relay . +docker run -p 8787:8787 \ + -v $(pwd)/AuthKey_ABCDE12345.p8:/keys/apns.p8:ro \ + rxcode-relay \ + -addr :8787 \ + -apns-key /keys/apns.p8 \ + -apns-key-id ABCDE12345 \ + -apns-team-id YYYYYYYYYY \ + -apns-topic com.idealapp.RxCode.Mobile \ + -apns-production=true +``` + +## APNs setup notes + +- Generate a `.p8` auth key in https://developer.apple.com/account/resources/authkeys/list. +- `-apns-topic` must equal the iOS app's bundle identifier exactly + (`com.idealapp.RxCode.Mobile`). +- Sandbox (`-apns-production=false`) is required for `xcrun simctl push` and + development builds; production builds and TestFlight require + `-apns-production=true`. +- The auth key file is sensitive — mount it via a secrets manager / Docker + secret in production. Never commit `.p8` files. + +## Wire envelope + +```json +{ + "v": 1, + "to": "<64-hex Curve25519 pubkey>", + "from": "<64-hex Curve25519 pubkey>", + "nonce": "", + "ct": "" +} +``` + +Plaintext payload schema lives in the `RxCodeSync` Swift package +(`Sources/RxCodeSync/Protocol/Payload.swift`). diff --git a/relay-server/go.mod b/relay-server/go.mod new file mode 100644 index 00000000..418bc336 --- /dev/null +++ b/relay-server/go.mod @@ -0,0 +1,14 @@ +module github.com/rxlab/rxcode-relay + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/sideshow/apns2 v0.25.0 +) + +require ( + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/text v0.19.0 // indirect +) diff --git a/relay-server/go.sum b/relay-server/go.sum new file mode 100644 index 00000000..98706fa2 --- /dev/null +++ b/relay-server/go.sum @@ -0,0 +1,35 @@ +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4= +github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/relay-server/health.go b/relay-server/health.go new file mode 100644 index 00000000..62584e67 --- /dev/null +++ b/relay-server/health.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" +) + +var startedAt = time.Now() + +// healthHandler returns a JSON liveness probe with current connection count +// and APNs availability. Used by orchestrators and by the desktop "Mobile" +// settings tab to verify the configured relay is reachable. +func healthHandler(hub *Hub, sender *PushSender) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + status := map[string]any{ + "ok": true, + "uptime_sec": int(time.Since(startedAt).Seconds()), + "peers": hub.ConnectedCount(), + "apns": sender != nil, + "version": "0.1.0", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(status) + } +} diff --git a/relay-server/main.go b/relay-server/main.go new file mode 100644 index 00000000..0cb63eca --- /dev/null +++ b/relay-server/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + addr := flag.String("addr", ":8787", "listen address") + apnsKeyPath := flag.String("apns-key", "", "path to APNs .p8 auth key") + apnsKeyID := flag.String("apns-key-id", "", "APNs Key ID") + apnsTeamID := flag.String("apns-team-id", "", "APNs Team ID") + apnsTopic := flag.String("apns-topic", "", "APNs topic (mobile app bundle ID)") + apnsProduction := flag.Bool("apns-production", false, "use production APNs endpoint instead of sandbox") + flag.Parse() + + hub := NewHub() + go hub.Run() + + var pushSender *PushSender + if *apnsKeyPath != "" { + p, err := NewPushSender(*apnsKeyPath, *apnsKeyID, *apnsTeamID, *apnsTopic, *apnsProduction) + if err != nil { + log.Fatalf("init APNs sender: %v", err) + } + pushSender = p + log.Printf("APNs sender enabled (topic=%s production=%v)", *apnsTopic, *apnsProduction) + } else { + log.Printf("APNs sender disabled (no -apns-key provided)") + } + + mux := http.NewServeMux() + mux.HandleFunc("/ws", hub.ServeWS) + mux.HandleFunc("/push", pushHandler(pushSender)) + mux.HandleFunc("/healthz", healthHandler(hub, pushSender)) + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("relay listening on %s", *addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + <-sig + + log.Printf("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) +} diff --git a/relay-server/push.go b/relay-server/push.go new file mode 100644 index 00000000..38cc6f15 --- /dev/null +++ b/relay-server/push.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/sideshow/apns2" + "github.com/sideshow/apns2/token" +) + +// PushSender wraps APNs HTTP/2 client. +type PushSender struct { + client *apns2.Client + topic string +} + +// NewPushSender loads the APNs auth key and prepares the HTTP/2 client. +func NewPushSender(keyPath, keyID, teamID, topic string, production bool) (*PushSender, error) { + if keyID == "" || teamID == "" || topic == "" { + return nil, fmt.Errorf("apns key id, team id, and topic are required") + } + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("read apns key: %w", err) + } + authKey, err := token.AuthKeyFromBytes(keyBytes) + if err != nil { + return nil, fmt.Errorf("parse apns key: %w", err) + } + tok := &token.Token{ + AuthKey: authKey, + KeyID: keyID, + TeamID: teamID, + } + client := apns2.NewTokenClient(tok) + if production { + client = client.Production() + } else { + client = client.Development() + } + return &PushSender{client: client, topic: topic}, nil +} + +// PushRequest is the JSON body accepted by POST /push. +// +// `EncryptedAlertB64` is an opaque base64 blob produced by the desktop sender — +// the relay never decrypts it. The mobile Notification Service Extension +// decrypts it before iOS shows the banner. +type PushRequest struct { + DeviceToken string `json:"device_token"` + EncryptedAlertB64 string `json:"encrypted_alert"` + Category string `json:"category,omitempty"` + CollapseID string `json:"collapse_id,omitempty"` +} + +// pushHandler returns an http.HandlerFunc that signs and forwards APNs pushes. +// +// Auth is intentionally minimal in v1: any client may submit, since the +// payload itself is E2E-encrypted to the recipient device. A future hardening +// pass should require a signed sender token (see plan: risk areas). +func pushHandler(sender *PushSender) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if sender == nil { + http.Error(w, "apns disabled on this relay", http.StatusServiceUnavailable) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + var req PushRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "malformed body", http.StatusBadRequest) + return + } + if req.DeviceToken == "" || req.EncryptedAlertB64 == "" { + http.Error(w, "missing fields", http.StatusBadRequest) + return + } + if _, err := base64.StdEncoding.DecodeString(req.EncryptedAlertB64); err != nil { + http.Error(w, "encrypted_alert must be base64", http.StatusBadRequest) + return + } + + // Payload structure: mutable-content=1 triggers Notification Service + // Extension on the device; the extension decrypts `enc` and rewrites + // the visible alert before display. + payload := map[string]any{ + "aps": map[string]any{ + "alert": map[string]string{ + "title": "RxCode", + "body": "Encrypted notification", + }, + "mutable-content": 1, + "sound": "default", + }, + "enc": req.EncryptedAlertB64, + } + raw, _ := json.Marshal(payload) + + notif := &apns2.Notification{ + DeviceToken: req.DeviceToken, + Topic: sender.topic, + Payload: raw, + } + if req.Category != "" { + notif.PushType = apns2.PushTypeAlert + } + if req.CollapseID != "" { + notif.CollapseID = req.CollapseID + } + + res, err := sender.client.Push(notif) + if err != nil { + log.Printf("apns push error: %v", err) + http.Error(w, "apns push failed", http.StatusBadGateway) + return + } + resp := map[string]any{ + "status_code": res.StatusCode, + "reason": res.Reason, + "apns_id": res.ApnsID, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } +} diff --git a/relay-server/relay.go b/relay-server/relay.go new file mode 100644 index 00000000..9b915bd7 --- /dev/null +++ b/relay-server/relay.go @@ -0,0 +1,233 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + maxEnvelopeBytes = 256 * 1024 + pongWait = 90 * time.Second + pingPeriod = 25 * time.Second + writeWait = 10 * time.Second +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Envelope is the relay's wire format. The relay never decrypts `ct`. +type Envelope struct { + V int `json:"v"` + To string `json:"to"` + From string `json:"from"` + Nonce string `json:"nonce"` + Ct string `json:"ct"` +} + +// Conn wraps a websocket connection identified by a hex pubkey. +type Conn struct { + pubkey string + ws *websocket.Conn + send chan []byte + hub *Hub +} + +// Hub maintains the set of live connections and routes envelopes. +type Hub struct { + mu sync.RWMutex + conns map[string]*Conn + + register chan *Conn + unregister chan *Conn + route chan *Envelope +} + +func NewHub() *Hub { + return &Hub{ + conns: make(map[string]*Conn), + register: make(chan *Conn, 64), + unregister: make(chan *Conn, 64), + route: make(chan *Envelope, 256), + } +} + +func (h *Hub) Run() { + for { + select { + case c := <-h.register: + h.mu.Lock() + if existing, ok := h.conns[c.pubkey]; ok { + close(existing.send) + } + h.conns[c.pubkey] = c + h.mu.Unlock() + log.Printf("registered %s", short(c.pubkey)) + case c := <-h.unregister: + h.mu.Lock() + if current, ok := h.conns[c.pubkey]; ok && current == c { + delete(h.conns, c.pubkey) + close(c.send) + } + h.mu.Unlock() + log.Printf("unregistered %s", short(c.pubkey)) + case env := <-h.route: + h.deliver(env) + } + } +} + +func (h *Hub) deliver(env *Envelope) { + raw, err := json.Marshal(env) + if err != nil { + log.Printf("marshal envelope: %v", err) + return + } + h.mu.RLock() + dest, ok := h.conns[env.To] + h.mu.RUnlock() + if !ok { + // Drop on offline. Optionally notify sender. + h.notifyDeliveryFailed(env.From, env.To) + return + } + select { + case dest.send <- raw: + default: + // Slow consumer; drop and disconnect. + log.Printf("send buffer full for %s — dropping", short(env.To)) + } +} + +func (h *Hub) notifyDeliveryFailed(from, to string) { + h.mu.RLock() + src, ok := h.conns[from] + h.mu.RUnlock() + if !ok { + return + } + notice := map[string]any{ + "v": 1, + "type": "delivery_failed", + "to": to, + } + raw, _ := json.Marshal(notice) + select { + case src.send <- raw: + default: + } +} + +// ConnectedCount returns the number of currently connected WebSocket peers. +func (h *Hub) ConnectedCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.conns) +} + +func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { + pubkey := r.URL.Query().Get("pubkey") + if !isValidPubkeyHex(pubkey) { + http.Error(w, "invalid pubkey", http.StatusBadRequest) + return + } + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("upgrade: %v", err) + return + } + ws.SetReadLimit(maxEnvelopeBytes) + c := &Conn{ + pubkey: pubkey, + ws: ws, + send: make(chan []byte, 32), + hub: h, + } + h.register <- c + go c.writePump() + c.readPump() +} + +func (c *Conn) readPump() { + defer func() { + c.hub.unregister <- c + _ = c.ws.Close() + }() + _ = c.ws.SetReadDeadline(time.Now().Add(pongWait)) + c.ws.SetPongHandler(func(string) error { + _ = c.ws.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, raw, err := c.ws.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("read error %s: %v", short(c.pubkey), err) + } + return + } + var env Envelope + if err := json.Unmarshal(raw, &env); err != nil { + log.Printf("malformed envelope from %s: %v", short(c.pubkey), err) + continue + } + if env.From != c.pubkey { + // Reject impersonation attempts even though E2E protects the payload. + log.Printf("from mismatch: header=%s socket=%s", short(env.From), short(c.pubkey)) + continue + } + if !isValidPubkeyHex(env.To) { + continue + } + c.hub.route <- &env + } +} + +func (c *Conn) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + _ = c.ws.Close() + }() + for { + select { + case msg, ok := <-c.send: + _ = c.ws.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + _ = c.ws.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.ws.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + _ = c.ws.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func isValidPubkeyHex(s string) bool { + if len(s) != 64 { + return false + } + _, err := hex.DecodeString(s) + return err == nil +} + +func short(pubkey string) string { + if len(pubkey) < 8 { + return pubkey + } + return pubkey[:8] +} From 9a580e5df539a0c4af168e2045c6986ca3f79266 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 20:03:01 +0800 Subject: [PATCH 2/8] fix: mobile pairing issue --- .gitignore | 5 + .../RxCodeChatKit/JSONCommentsHelper.swift | 76 ++++ .../RxCodeChatKit/SlashCommandBar.swift | 71 ---- .../RunProfile/RunTaskExecutor.swift | 15 +- .../RxCodeSync/Crypto/DeviceIdentity.swift | 64 +++ .../RxCodeSync/Transport/RelayClient.swift | 24 +- RxCode.xcodeproj/project.pbxproj | 20 +- .../xcschemes/RxCodeMobile.xcscheme | 79 ++++ RxCode/Info.plist | 5 + RxCode/Services/MobileSyncService.swift | 35 +- .../RunProfile/RunProfileDetector.swift | 26 +- .../RunProfile/RunConfigurationsView.swift | 93 ++++- RxCode/Views/Settings/MobileSettingsTab.swift | 21 +- RxCodeMobile/ContentView.swift | 61 --- RxCodeMobile/Info.plist | 41 +- RxCodeMobile/Item.swift | 18 - RxCodeMobile/RxCodeMobile.entitlements | 8 +- RxCodeMobile/RxCodeMobileApp.swift | 30 +- RxCodeMobile/SETUP.md | 131 ------ RxCodeMobile/State/MobileAppState.swift | 85 +++- RxCodeMobile/Views/MobileChatView.swift | 2 + RxCodeMobile/Views/MobileSettingsView.swift | 14 +- RxCodeMobile/Views/OnboardingView.swift | 393 ++++++++++++++++-- .../Views/PermissionApprovalSheet.swift | 6 + RxCodeMobile/Views/ProjectsSidebar.swift | 1 + RxCodeMobile/Views/QRScannerView.swift | 60 ++- RxCodeMobile/Views/RootView.swift | 1 + RxCodeMobile/Views/SessionsList.swift | 1 + .../NotificationService.swift | 4 +- ...CodeMobileNotificationService.entitlements | 2 +- relay-server/Makefile | 49 +++ relay-server/README.md | 76 +++- relay-server/go.mod | 1 + relay-server/go.sum | 2 + relay-server/main.go | 94 ++++- relay-server/push.go | 20 +- 36 files changed, 1191 insertions(+), 443 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/JSONCommentsHelper.swift create mode 100644 RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme delete mode 100644 RxCodeMobile/ContentView.swift delete mode 100644 RxCodeMobile/Item.swift delete mode 100644 RxCodeMobile/SETUP.md create mode 100644 relay-server/Makefile diff --git a/.gitignore b/.gitignore index 733f064b..2c8829cb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ release_notes.html release_notes.md relay-server/rxcode-relay + +# Relay env files and APNs auth keys (contain secrets) +relay-server/.env +relay-server/.env.* +relay-server/*.p8 diff --git a/Packages/Sources/RxCodeChatKit/JSONCommentsHelper.swift b/Packages/Sources/RxCodeChatKit/JSONCommentsHelper.swift new file mode 100644 index 00000000..ee1e4d69 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/JSONCommentsHelper.swift @@ -0,0 +1,76 @@ +import Foundation + +extension Data { + /// Strips `//` line comments and `/* … */` block comments from JSONC-style + /// data while leaving string literals and line endings intact. Used to + /// preprocess shortcut and slash-command config files before handing them + /// to `JSONDecoder`. + func removingJSONComments() -> Data { + let bytes = Array(self) + var output: [UInt8] = [] + output.reserveCapacity(bytes.count) + + var index = 0 + var isInString = false + var isEscaped = false + var isInLineComment = false + var isInBlockComment = false + + while index < bytes.count { + let byte = bytes[index] + let next = index + 1 < bytes.count ? bytes[index + 1] : nil + + if isInLineComment { + if byte == 0x0A || byte == 0x0D { + isInLineComment = false + output.append(byte) + } + index += 1 + continue + } + + if isInBlockComment { + if byte == 0x2A, next == 0x2F { + isInBlockComment = false + index += 2 + } else { + if byte == 0x0A || byte == 0x0D { + output.append(byte) + } + index += 1 + } + continue + } + + if isInString { + output.append(byte) + if isEscaped { + isEscaped = false + } else if byte == 0x5C { + isEscaped = true + } else if byte == 0x22 { + isInString = false + } + index += 1 + continue + } + + if byte == 0x22 { + isInString = true + output.append(byte) + index += 1 + } else if byte == 0x2F, next == 0x2F { + isInLineComment = true + index += 2 + } else if byte == 0x2F, next == 0x2A { + isInBlockComment = true + index += 2 + } else { + output.append(byte) + index += 1 + } + } + + return Data(output) + } +} diff --git a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift index bbd6e4b7..04953c75 100644 --- a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift +++ b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift @@ -434,77 +434,6 @@ public enum SlashCommandRegistry { } } -extension Data { - func removingJSONComments() -> Data { - let bytes = Array(self) - var output: [UInt8] = [] - output.reserveCapacity(bytes.count) - - var index = 0 - var isInString = false - var isEscaped = false - var isInLineComment = false - var isInBlockComment = false - - while index < bytes.count { - let byte = bytes[index] - let next = index + 1 < bytes.count ? bytes[index + 1] : nil - - if isInLineComment { - if byte == 0x0A || byte == 0x0D { - isInLineComment = false - output.append(byte) - } - index += 1 - continue - } - - if isInBlockComment { - if byte == 0x2A, next == 0x2F { - isInBlockComment = false - index += 2 - } else { - if byte == 0x0A || byte == 0x0D { - output.append(byte) - } - index += 1 - } - continue - } - - if isInString { - output.append(byte) - if isEscaped { - isEscaped = false - } else if byte == 0x5C { - isEscaped = true - } else if byte == 0x22 { - isInString = false - } - index += 1 - continue - } - - if byte == 0x22 { - isInString = true - output.append(byte) - index += 1 - } else if byte == 0x2F, next == 0x2F { - isInLineComment = true - index += 2 - } else if byte == 0x2F, next == 0x2A { - isInBlockComment = true - index += 2 - } else { - output.append(byte) - index += 1 - } - } - - return Data(output) - } -} - // MARK: - Slash Command Popup struct SlashCommandPopup: View { diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index 239e27f8..94d38a66 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -114,7 +114,7 @@ public enum RunTaskExecutor { lines.append("") } - let mainLines = mainCommandLines(for: profile) + let mainLines = mainCommandLines(for: profile, projectPath: projectPath) if !mainLines.isEmpty { lines.append("# --- main ---") lines.append(contentsOf: mainLines) @@ -128,7 +128,7 @@ public enum RunTaskExecutor { /// appropriate `xcodebuild` invocation (and, for `.run`, locate the built /// `.app` from build settings and `open` it); for `.make` we synthesize /// a `make` invocation against the configured Makefile and target. - static func mainCommandLines(for profile: RunProfile) -> [String] { + static func mainCommandLines(for profile: RunProfile, projectPath: String) -> [String] { switch profile.type { case .bash: let cmd = profile.bash.command.trimmingCharacters(in: .whitespaces) @@ -141,7 +141,7 @@ public enum RunTaskExecutor { return xcodeScriptLines(xcode) case .make: guard let make = profile.make else { return [] } - return makeScriptLines(make) + return makeScriptLines(make, projectPath: projectPath) } } @@ -246,7 +246,7 @@ public enum RunTaskExecutor { return lines } - private static func makeScriptLines(_ cfg: MakeRunConfig) -> [String] { + private static func makeScriptLines(_ cfg: MakeRunConfig, projectPath: String) -> [String] { let makefile = cfg.makefile.trimmingCharacters(in: .whitespaces) let target = cfg.target.trimmingCharacters(in: .whitespaces) let args = cfg.arguments.trimmingCharacters(in: .whitespaces) @@ -256,8 +256,13 @@ public enum RunTaskExecutor { var parts: [String] = ["make"] if !makefile.isEmpty { + // Resolve project-relative Makefile paths to absolute. `make -f` + // otherwise resolves them against the working directory, which + // breaks profiles whose working dir is a subfolder (e.g. Makefile + // `./relay-server/Makefile` + working dir `./relay-server`). + let resolved = resolveWorkingDirectory(makefile, projectPath: projectPath) parts.append("-f") - parts.append(shellEscape(makefile)) + parts.append(shellEscape(resolved)) } if !target.isEmpty { parts.append(shellEscape(target)) diff --git a/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift b/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift index bbb1b956..4a9acb66 100644 --- a/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift +++ b/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift @@ -42,6 +42,70 @@ public struct DeviceIdentity: Sendable { ) throws { try Keychain.delete(service: service, accessGroup: accessGroup) } + + /// Resolve the fully-qualified keychain access group at runtime. + /// + /// The entitlement is written as `$(AppIdentifierPrefix)foo.bar` and the + /// build system expands it to `TEAMID.foo.bar`. On iOS, `SecItem*` requires + /// the **prefixed** form at runtime — passing the bare suffix yields + /// `errSecMissingEntitlement` (-34018). + /// + /// We discover the team prefix by writing a probe keychain item with no + /// access group specified (the system then assigns the default, which is + /// the first entry from the entitlement's `keychain-access-groups` — fully + /// prefixed) and reading back `kSecAttrAccessGroup`. Result is cached. + public static func resolveAccessGroup(suffix: String) -> String { + if let cached = cachedPrefix { + return cached + suffix + } + if let prefix = discoverTeamPrefix() { + cachedPrefix = prefix + return prefix + suffix + } + // Unsigned macOS dev builds or sandbox-less contexts: fall back to the + // bare suffix (macOS is lenient; iOS isn't, but at that point there's + // nothing we can do). + return suffix + } + + // The prefix is deterministic for the process lifetime, so a benign race + // where two callers both discover it yields the same value. + private nonisolated(unsafe) static var cachedPrefix: String? + + private static func discoverTeamPrefix() -> String? { + let probeService = "rxcode.devid.prefixprobe" + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: probeService, + kSecAttrAccount as String: "probe", + ] + // Try to read; if not present, add it (system will assign default + // access group). Then re-read attributes. + var readQuery = baseQuery + readQuery[kSecReturnAttributes as String] = true + readQuery[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + var status = SecItemCopyMatching(readQuery as CFDictionary, &item) + if status == errSecItemNotFound { + var addAttrs = baseQuery + addAttrs[kSecValueData as String] = Data([0]) + addAttrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + status = SecItemAdd(addAttrs as CFDictionary, nil) + guard status == errSecSuccess || status == errSecDuplicateItem else { + return nil + } + status = SecItemCopyMatching(readQuery as CFDictionary, &item) + } + guard status == errSecSuccess, + let dict = item as? [String: Any], + let group = dict[kSecAttrAccessGroup as String] as? String, + let dot = group.firstIndex(of: ".") + else { + return nil + } + return String(group[.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RxCode/Info.plist b/RxCode/Info.plist index 0ae0acce..6df42d6d 100644 --- a/RxCode/Info.plist +++ b/RxCode/Info.plist @@ -24,6 +24,11 @@ $(MACOSX_DEPLOYMENT_TARGET) NSHighResolutionCapable + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSHumanReadableCopyright SUFeedURL diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 78060461..ebbc05a6 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -44,7 +44,7 @@ final class MobileSyncService: ObservableObject { private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") private let identity: DeviceIdentity - private let client: SyncClient + private var client: SyncClient private var subscribedSessions: [String: String] = [:] private var eventTask: Task? private var pairingToken: PairingToken? @@ -82,19 +82,19 @@ final class MobileSyncService: ObservableObject { /// Begin (or resume) the relay connection. Safe to call multiple times. func start() { Task { @MainActor in - do { - for device in pairedDevices { - try? await client.addPeer(device.pubkeyHex) - } - await client.start() - let events = await client.events() - eventTask?.cancel() - eventTask = Task { @MainActor in - for await event in events { - self.handle(event: event) - } + for device in pairedDevices { + try? await client.addPeer(device.pubkeyHex) + } + // Subscribe to events BEFORE calling start() so we don't miss the + // initial .connecting / .connected state transitions. + let events = await client.events() + eventTask?.cancel() + eventTask = Task { @MainActor in + for await event in events { + self.handle(event: event) } } + await client.start() } } @@ -106,11 +106,16 @@ final class MobileSyncService: ObservableObject { /// Update the configured relay URL, persist it, and reconnect. func updateRelay(url: URL) { + guard url != relayURL else { return } UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") relayURL = url - // SyncClient holds its URL at init time. The simplest restart is to - // recreate the service on next launch — for now we just reset peers. - // (A full hot-swap is left for a future refactor.) + eventTask?.cancel() + eventTask = nil + let oldClient = client + client = SyncClient(identity: identity, relayURL: url) + connectionState = .disconnected + Task { await oldClient.stop() } + start() } // MARK: - Pairing diff --git a/RxCode/Services/RunProfile/RunProfileDetector.swift b/RxCode/Services/RunProfile/RunProfileDetector.swift index 3bd88ff2..4b3a7153 100644 --- a/RxCode/Services/RunProfile/RunProfileDetector.swift +++ b/RxCode/Services/RunProfile/RunProfileDetector.swift @@ -162,7 +162,7 @@ final class RunProfileDetector { let content = try? String(contentsOfFile: path.appendingPathComponent(chosen), encoding: .utf8) else { return [] } - let targets = parseMakeTargets(content) + let targets = Self.parseMakeTargets(content) // Default Makefile lookup (`make`) finds the same file we picked here, // so leave `makefile` empty unless the user picked a non-default name. let isDefaultName = (chosen == "Makefile" || chosen == "makefile" || chosen == "GNUmakefile") @@ -178,7 +178,29 @@ final class RunProfileDetector { } } - private func parseMakeTargets(_ content: String) -> [String] { + /// Parse the `make` targets from a Makefile at `path`. Returns an empty + /// array if the file is missing or unreadable. + static func makeTargets(atPath path: String) -> [String] { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return [] } + return parseMakeTargets(content) + } + + /// Try the standard Makefile names inside `root` and parse the first one + /// that exists. Returns the resolved path alongside the targets. + static func defaultMakeTargets(inDirectory root: String) -> (path: String, targets: [String])? { + let fm = FileManager.default + let candidates = ["Makefile", "makefile", "GNUmakefile"] + let dir = root as NSString + for name in candidates { + let full = dir.appendingPathComponent(name) + if fm.fileExists(atPath: full) { + return (full, makeTargets(atPath: full)) + } + } + return nil + } + + static func parseMakeTargets(_ content: String) -> [String] { // Target lines look like `name:` or `name: deps` at column 0 (no leading // tab/space — those are recipe lines). Skip pattern rules (`%.o:`), // special targets (`.PHONY:`), variable assignments (`X := y`). diff --git a/RxCode/Views/RunProfile/RunConfigurationsView.swift b/RxCode/Views/RunProfile/RunConfigurationsView.swift index 1b940eec..fe9e2d90 100644 --- a/RxCode/Views/RunProfile/RunConfigurationsView.swift +++ b/RxCode/Views/RunProfile/RunConfigurationsView.swift @@ -349,6 +349,9 @@ private struct RunProfileDetailForm: View { @Binding var profile: RunProfile let project: Project + @State private var detectedMakeTargets: [String] = [] + private static let customTargetSentinel = "__rxcode_custom_target__" + var body: some View { Form { Section { @@ -518,8 +521,7 @@ private struct RunProfileDetailForm: View { } .help("Use default Makefile lookup") } - TextField("Target", text: make.target, prompt: Text("build")) - .font(.system(.body, design: .monospaced)) + targetField(make: make) TextField( "Arguments (optional)", text: make.arguments, @@ -545,6 +547,93 @@ private struct RunProfileDetailForm: View { } footer: { Text("Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile).") } + .task(id: makeDetectionKey) { + detectedMakeTargets = await refreshMakeTargets() + } + } + + /// Cache key for re-parsing the Makefile when its path or the working + /// directory changes. + private var makeDetectionKey: String { + "\(profile.id.uuidString)|\(profile.make?.makefile ?? "")|\(profile.bash.workingDirectory)" + } + + private func refreshMakeTargets() async -> [String] { + let projectRoot = project.path + let makefile = profile.make?.makefile ?? "" + let workingDirRaw = profile.bash.workingDirectory + return await Task.detached { () -> [String] in + let fm = FileManager.default + func resolve(_ p: String, against base: String) -> String { + if p.isEmpty { return base } + if p.hasPrefix("/") { return p } + return (base as NSString).appendingPathComponent(p) + } + let workingDir = workingDirRaw.isEmpty + ? projectRoot + : resolve(workingDirRaw, against: projectRoot) + + if !makefile.isEmpty { + // Picker produces project-relative paths; the executor resolves + // relative to working dir. Try both so the dropdown works + // regardless of which the user typed. + let candidates = [ + resolve(makefile, against: projectRoot), + resolve(makefile, against: workingDir), + ] + for path in candidates where fm.fileExists(atPath: path) { + return RunProfileDetector.makeTargets(atPath: path) + } + return [] + } + if let result = RunProfileDetector.defaultMakeTargets(inDirectory: workingDir) { + return result.targets + } + return RunProfileDetector.defaultMakeTargets(inDirectory: projectRoot)?.targets ?? [] + }.value + } + + @ViewBuilder + private func targetField(make: Binding) -> some View { + let current = make.wrappedValue.target + let targets = detectedMakeTargets + let isCustom = !targets.isEmpty && !current.isEmpty && !targets.contains(current) + + if targets.isEmpty { + TextField("Target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } else { + Picker("Target", selection: Binding( + get: { + if current.isEmpty { return "" } + return isCustom ? Self.customTargetSentinel : current + }, + set: { newValue in + var c = make.wrappedValue + if newValue == Self.customTargetSentinel { + if targets.contains(c.target) { c.target = "" } + } else { + c.target = newValue + } + make.wrappedValue = c + } + )) { + if current.isEmpty { + Text("Select a target…").tag("") + } + ForEach(targets, id: \.self) { t in + Text(t).tag(t) + } + Divider() + Text("Custom…").tag(Self.customTargetSentinel) + } + .font(.system(.body, design: .monospaced)) + + if isCustom { + TextField("Custom target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } + } } private func pickXcodeContainer(onPick: @escaping (String, Bool) -> Void) { diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 02c48d01..d5888563 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -83,6 +83,8 @@ struct MobileSettingsTab: View { Label("Pair new device", systemImage: "plus.circle.fill") } .buttonStyle(.borderedProminent) + .disabled(sync.connectionState != .connected) + .help(sync.connectionState == .connected ? "" : "Connect to the relay before pairing a device.") } if sync.pairedDevices.isEmpty { @@ -191,7 +193,24 @@ private struct PairingSheet: View { .font(.callout) .multilineTextAlignment(.center) .foregroundStyle(.secondary) - if let token, token.isExpired { + if let token { + expirationLabel(for: token) + } + } + } + + @ViewBuilder + private func expirationLabel(for token: PairingToken) -> some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let remaining = Int(token.expiresAt.timeIntervalSince(context.date).rounded(.down)) + if remaining > 0 { + Label( + String(format: "Expires in %d:%02d", remaining / 60, remaining % 60), + systemImage: "clock" + ) + .font(.caption) + .foregroundStyle(remaining < 30 ? Color.red : Color.secondary) + } else { Text("Expired — close and reopen to regenerate") .font(.caption) .foregroundStyle(.red) diff --git a/RxCodeMobile/ContentView.swift b/RxCodeMobile/ContentView.swift deleted file mode 100644 index 865ca64f..00000000 --- a/RxCodeMobile/ContentView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ContentView.swift -// RxCodeMobile -// -// Created by Qiwei Li on 5/19/26. -// - -import SwiftUI -import SwiftData - -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/RxCodeMobile/Info.plist b/RxCodeMobile/Info.plist index 074a6d12..e34d1525 100644 --- a/RxCodeMobile/Info.plist +++ b/RxCodeMobile/Info.plist @@ -2,12 +2,39 @@ - NSAppClip - - NSAppClipRequestEphemeralUserNotification - - NSAppClipRequestLocationConfirmation - - + NSCameraUsageDescription + Scan a QR code shown by RxCode on your Mac to pair this device. + NSLocalNetworkUsageDescription + RxCode connects to a relay running on your local network to sync with your Mac. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + remote-notification + processing + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + diff --git a/RxCodeMobile/Item.swift b/RxCodeMobile/Item.swift deleted file mode 100644 index d0a36d9f..00000000 --- a/RxCodeMobile/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// RxCodeMobile -// -// Created by Qiwei Li on 5/19/26. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements index 72a2d0bf..ea621e2c 100644 --- a/RxCodeMobile/RxCodeMobile.entitlements +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -2,9 +2,11 @@ - com.apple.developer.parent-application-identifiers + aps-environment + development + keychain-access-groups - $(AppIdentifierPrefix) + $(AppIdentifierPrefix)app.rxlab.rxcodemobile.shared - \ No newline at end of file + diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index df4c4333..1a1e7ee6 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -1,32 +1,18 @@ -// -// RxCodeMobileApp.swift -// RxCodeMobile -// -// Created by Qiwei Li on 5/19/26. -// - import SwiftUI -import SwiftData @main struct RxCodeMobileApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var state = MobileAppState() var body: some Scene { WindowGroup { - ContentView() + RootView() + .environmentObject(state) + .onAppear { + appDelegate.mobileState = state + state.start() + } } - .modelContainer(sharedModelContainer) } } diff --git a/RxCodeMobile/SETUP.md b/RxCodeMobile/SETUP.md deleted file mode 100644 index b205f20e..00000000 --- a/RxCodeMobile/SETUP.md +++ /dev/null @@ -1,131 +0,0 @@ -# RxCodeMobile — Xcode setup - -This directory holds the source files for the **iOS / iPadOS companion app** -and its Notification Service Extension. Adding new app + extension targets -safely is best done in the Xcode UI rather than by editing -`project.pbxproj` by hand. This file walks you through the steps once. - -## Prerequisites - -- Xcode 26+ -- Apple Developer account -- The desktop app (`RxCode` scheme) already builds cleanly. - -## Step 1 — Add the iOS app target - -1. Open `RxCode.xcodeproj` in Xcode. -2. File → New → Target… → **App** (iOS). -3. Product Name: **`RxCodeMobile`** -4. Bundle Identifier: **`com.idealapp.RxCode.Mobile`** -5. Interface: SwiftUI · Language: Swift · Storage: None · Tests: optional -6. Deployment target: **iOS 26.0** (or whatever you confirmed in the plan) -7. After creation, **delete** the generated `ContentView.swift`, - `RxCodeMobileApp.swift` and `Assets.xcassets` stub — we ship our own. - -## Step 2 — Add the source files - -In Project Navigator, right-click the new `RxCodeMobile` group → -**Add Files to "RxCode"…** and select: - -``` -RxCodeMobile/RxCodeMobileApp.swift -RxCodeMobile/AppDelegate.swift -RxCodeMobile/Info.plist (replace the auto-generated one) -RxCodeMobile/RxCodeMobile.entitlements -RxCodeMobile/State/MobileAppState.swift -RxCodeMobile/Views/RootView.swift -RxCodeMobile/Views/OnboardingView.swift -RxCodeMobile/Views/QRScannerView.swift -RxCodeMobile/Views/ProjectsSidebar.swift -RxCodeMobile/Views/SessionsList.swift -RxCodeMobile/Views/MobileChatView.swift -RxCodeMobile/Views/MobileMessageBubble.swift -RxCodeMobile/Views/MobileInputBar.swift -RxCodeMobile/Views/PermissionApprovalSheet.swift -RxCodeMobile/Views/MobileSettingsView.swift -``` - -Make sure **"Add to targets"** has **only** `RxCodeMobile` checked, not -`RxCode` (macOS). - -## Step 3 — Link Swift package products - -`RxCodeMobile` → **General** → **Frameworks, Libraries, and Embedded -Content** → **+** → add: - -- `RxCodeCore` -- `RxCodeChatKit` -- `RxCodeSync` - -The local `Packages` reference should already be visible — these are the -same products the macOS app links. - -## Step 4 — Add the Notification Service Extension - -1. File → New → Target… → **Notification Service Extension**. -2. Product Name: **`RxCodeMobileNotificationService`** -3. Bundle Identifier: **`com.idealapp.RxCode.Mobile.NotificationService`** - (must be `.` for iOS to load it) -4. Embed in Application: **`RxCodeMobile`** -5. After creation, delete the auto-generated `NotificationService.swift` and - add ours from `RxCodeMobileNotificationService/`. Same for the Info.plist - and entitlements files. Link `RxCodeSync` to this target as well (no - need for `RxCodeCore` / `RxCodeChatKit`). - -## Step 5 — Configure capabilities - -For **`RxCodeMobile`**: - -- Signing & Capabilities → **+ Capability** → **Push Notifications** -- **+ Capability** → **Background Modes** — check - *Remote notifications* and *Background processing* -- **+ Capability** → **Keychain Sharing** — add group - `com.idealapp.RxCode.Mobile.shared` (Xcode prepends the team prefix - at sign time) - -For **`RxCodeMobileNotificationService`**: - -- **+ Capability** → **Keychain Sharing** — add the same group - `com.idealapp.RxCode.Mobile.shared` - -The Keychain group lets the extension read the device's long-term -Curve25519 private key written by the main app, so it can decrypt the -`enc` blob carried in incoming APNs payloads. - -## Step 6 — APNs key on the relay server - -Configure the relay server with an APNs auth key (`.p8`) for the topic -`com.idealapp.RxCode.Mobile`. See `relay-server/README.md`. - -## Step 7 — First run - -1. Build + run `RxCode` (macOS) on your Mac. -2. Run the relay: `cd relay-server && go run . -addr :8787 …` (see relay - README for APNs flags). -3. In the Mac app, **Settings → Mobile**, set the relay URL (e.g. - `ws://your-mac.local:8787` for LAN dev), then tap **Pair new device** - to show the QR. -4. Build + run `RxCodeMobile` on a device or iPad simulator. On the - onboarding screen, tap **Scan QR** and point at the Mac's screen. -5. The Mac shows a confirmation modal — tap **Accept** — and the mobile - transitions to the root split view. -6. Open a project on Mac and start a session — the mobile mirrors it. - -## Known limitations (v1) - -- The relay drops envelopes on offline by design; mobile re-syncs via - `requestSnapshot` on every reconnect. -- The main app currently only fans notifications out over the live - WebSocket channel. The desktop-side APNs `POST /push` integration that - packages encrypted alerts and submits them to the relay is wired into - the protocol and NSE but the desktop submitter is left as a follow-up - patch — see `MobileSyncService.broadcastNotification`. -- The desktop `MobileSyncService` listens for inbound `userMessage` / - `newSessionRequest` / `requestSnapshot` events but the AppState bridge - that actually routes those into `ClaudeService.send()` and emits - `snapshot` payloads is left as a follow-up patch — the - `NotificationCenter` posts (`.mobileSync*`) are the seam to wire up. -- iOS app's snapshot rendering covers projects, sessions, and chat - messages, but session-level streaming-state mirroring (token usage, - status chips) is not surfaced yet — those land via `sessionUpdate` - payloads but the iOS UI doesn't display them. diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index d57a23fc..9acc0525 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -9,11 +9,18 @@ import RxCodeSync /// the pairing flow. @MainActor final class MobileAppState: ObservableObject { + enum PairingStatus: Equatable { + case idle + case inProgress + case failed(String) + } + @Published var isPaired: Bool = false @Published var pairedDesktopName: String = "" @Published var pairedDesktopPubkey: String = "" @Published var connectionState: RelayClient.ConnectionState = .disconnected @Published var relayURL: URL + @Published var pairingStatus: PairingStatus = .idle @Published var projects: [Project] = [] @Published var sessions: [SessionSummary] = [] @@ -24,18 +31,25 @@ final class MobileAppState: ObservableObject { private let identity: DeviceIdentity private let client: SyncClient private var eventTask: Task? + private var pairingTimeoutTask: Task? private var apnsTokenHex: String? private var apnsEnvironment: String? + static let pairingTimeoutSeconds: UInt64 = 25 + init() { let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") - let initial = URL(string: stored ?? "wss://example.invalid") ?? URL(string: "wss://example.invalid")! + let initial = URL(string: stored ?? Self.defaultRelayURLString) + ?? URL(string: Self.defaultRelayURLString)! self.relayURL = initial do { // Shared access group lets the Notification Service Extension - // read the private key for decrypting APNs alerts. + // read the private key for decrypting APNs alerts. The bare group + // suffix is matched against the (already-expanded) entitlement — + // never pass the literal `$(AppIdentifierPrefix)…` here, that's a + // build-time substitution and is meaningless at runtime. self.identity = try DeviceIdentity.loadOrCreate( - accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + accessGroup: Self.keychainAccessGroup ) } catch { fatalError("Failed to load mobile device identity: \(error)") @@ -44,6 +58,25 @@ final class MobileAppState: ObservableObject { loadPairedDesktop() } + /// Bare suffix as declared (post-`$(AppIdentifierPrefix)`) in + /// `RxCodeMobile.entitlements` and the NSE entitlements file. + static let keychainAccessGroupSuffix = "app.rxlab.rxcodemobile.shared" + + /// Fully-qualified access group resolved at runtime (e.g. + /// `T7GYB573Y6.app.rxlab.rxcodemobile.shared`). iOS requires the prefixed + /// form when calling `SecItem*`. + static var keychainAccessGroup: String { + DeviceIdentity.resolveAccessGroup(suffix: keychainAccessGroupSuffix) + } + + static var defaultRelayURLString: String { + #if DEBUG + return "ws://localhost:8787/ws" + #else + return "wss://relay.rxlab.app/ws" + #endif + } + var localPublicKeyHex: String { identity.publicKeyHex } func start() { @@ -73,7 +106,11 @@ final class MobileAppState: ObservableObject { func pair(with token: PairingToken, displayName: String) async { guard !token.isExpired, - let desktopKey = token.desktopPublicKey else { return } + let desktopKey = token.desktopPublicKey else { + pairingStatus = .failed("Invalid or expired pairing code.") + return + } + pairingStatus = .inProgress let desktopHex = token.desktopPubkeyHex try? await client.addPeer(desktopHex) // Persist the relay URL we just learned from the QR. @@ -87,8 +124,40 @@ final class MobileAppState: ObservableObject { platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" ) - try? await client.send(.pairRequest(req), toHex: desktopHex) + do { + try await client.send(.pairRequest(req), toHex: desktopHex) + } catch { + pairingStatus = .failed("Couldn't reach the relay. Check your network and try again.") + return + } _ = desktopKey // silence unused + startPairingTimeout() + } + + func cancelPairing() { + pairingTimeoutTask?.cancel() + pairingTimeoutTask = nil + if !isPaired { pairingStatus = .idle } + } + + func dismissPairingError() { + if case .failed = pairingStatus { pairingStatus = .idle } + } + + private func startPairingTimeout() { + pairingTimeoutTask?.cancel() + pairingTimeoutTask = Task { [weak self] in + let seconds = Self.pairingTimeoutSeconds + try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + guard !self.isPaired else { return } + self.pairingStatus = .failed( + "Your Mac didn't respond. Make sure RxCode is open and connected, then try again." + ) + } + } } // MARK: - User intents @@ -138,7 +207,7 @@ final class MobileAppState: ObservableObject { isPaired = false savePairedDesktop() try? DeviceIdentity.reset( - accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + accessGroup: Self.keychainAccessGroup ) } @@ -161,10 +230,13 @@ final class MobileAppState: ObservableObject { private func handleInbound(_ inbound: RelayClient.Inbound) { switch inbound.payload { case .pairAck(let ack): + pairingTimeoutTask?.cancel() + pairingTimeoutTask = nil if ack.accepted { pairedDesktopPubkey = inbound.fromHex pairedDesktopName = ack.desktopName isPaired = true + pairingStatus = .idle savePairedDesktop() Task { await self.requestSnapshot() @@ -172,6 +244,7 @@ final class MobileAppState: ObservableObject { } } else { isPaired = false + pairingStatus = .failed("Your Mac declined the pairing request.") } case .snapshot(let snap): projects = snap.projects diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 21b28866..076fcac0 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import RxCodeSync /// Read-write chat view. User messages are forwarded to the desktop and the /// desktop agent's stream is mirrored back as `session_update` payloads. @@ -21,6 +22,7 @@ struct MobileChatView: View { .padding(.horizontal, 16) .padding(.vertical, 8) } + .scrollDismissesKeyboard(.interactively) .onChange(of: messages.last?.id) { _, _ in if let last = messages.last?.id { withAnimation { proxy.scrollTo(last, anchor: .bottom) } diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 47617341..8192bcbf 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeSync struct MobileSettingsView: View { @EnvironmentObject private var state: MobileAppState @@ -18,15 +19,6 @@ struct MobileSettingsView: View { Spacer() connectionLabel } - HStack { - Text("Relay") - Spacer() - Text(state.relayURL.absoluteString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } } Section { @@ -65,8 +57,8 @@ struct MobileSettingsView: View { Label("Connecting…", systemImage: "circle.dotted") case .disconnected: Label("Disconnected", systemImage: "circle.slash").foregroundStyle(.secondary) - case .reconnecting: - Label("Reconnecting…", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) + case .reconnecting(let seconds): + Label("Reconnecting in \(seconds)s", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) } } } diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift index a7603748..c118d0d0 100644 --- a/RxCodeMobile/Views/OnboardingView.swift +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -1,72 +1,397 @@ import SwiftUI +import PhotosUI import RxCodeSync +import os + +private let onboardingLogger = Logger(subsystem: "com.claudework", category: "Onboarding") struct OnboardingView: View { @EnvironmentObject private var state: MobileAppState @State private var showScanner = false + @State private var showScanOptions = false + @State private var photoPickerShown = false + @State private var photoItem: PhotosPickerItem? @State private var pairingError: String? @State private var displayName: String = UIDevice.current.name + @FocusState private var nameFocused: Bool var body: some View { - VStack(spacing: 24) { - Image(systemName: "iphone.gen3.badge.checkmark") - .font(.system(size: 72)) - .foregroundStyle(.tint) - Text("Pair with your Mac") - .font(.title2) - .fontWeight(.semibold) - Text("Open RxCode on your Mac, go to Settings → Mobile, and tap “Pair new device”. Then scan the QR code with this app.") - .font(.callout) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal, 32) + ZStack { + BackdropOrbs() - VStack(alignment: .leading, spacing: 6) { - Text("Device name") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Device name", text: $displayName) - .textFieldStyle(.roundedBorder) + ScrollView { + VStack(spacing: 28) { + hero + instructions + deviceNameField + scanButton + if let err = combinedError { + errorBanner(err) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 40) + .scrollBounceBehavior(.basedOnSize) + .disabled(state.pairingStatus == .inProgress) + .blur(radius: state.pairingStatus == .inProgress ? 6 : 0) + if state.pairingStatus == .inProgress { + pairingOverlay + .transition(.opacity) + } + } + .animation(.smooth(duration: 0.25), value: pairingError) + .animation(.smooth(duration: 0.25), value: state.pairingStatus) + .confirmationDialog( + "Add a QR code", + isPresented: $showScanOptions, + titleVisibility: .visible + ) { Button { showScanner = true } label: { - Label("Scan QR", systemImage: "qrcode.viewfinder") - .frame(maxWidth: 240) - .padding(.vertical, 6) + Label("Scan with Camera", systemImage: "camera.viewfinder") } - .buttonStyle(.borderedProminent) - if let err = pairingError { - Text(err) - .font(.caption) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) + Button { + // Trigger photo picker by toggling state in next runloop tick + // so the dialog can dismiss first. + DispatchQueue.main.async { presentPhotoPicker() } + } label: { + Label("Choose from Photos", systemImage: "photo.on.rectangle") } + + Button("Cancel", role: .cancel) {} + } message: { + Text("Scan the QR code shown on your Mac, or pick a screenshot from your library.") } - .padding(.vertical, 40) .sheet(isPresented: $showScanner) { - QRScannerView { result in - showScanner = false - handleScan(result) + NavigationStack { + QRScannerView { result in + showScanner = false + handleScan(result) + } + .ignoresSafeArea() + .background(Color.black) + .navigationTitle("Scan QR") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.black, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showScanner = false + } label: { + Image(systemName: "xmark") + .font(.body.weight(.semibold)) + } + .tint(.white) + } + } } + .preferredColorScheme(.dark) + } + .photosPicker( + isPresented: $photoPickerShown, + selection: $photoItem, + matching: .images, + photoLibrary: .shared() + ) + .onChange(of: photoItem) { _, newItem in + guard let newItem else { return } + Task { await loadAndDecode(item: newItem) } } } + private func presentPhotoPicker() { photoPickerShown = true } + + // MARK: - Sections + + private var hero: some View { + VStack(spacing: 18) { + ZStack { + Circle() + .fill(LinearGradient( + colors: [ + Color.accentColor.opacity(0.22), + Color.accentColor.opacity(0.08) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 112, height: 112) + .glassEffect(.regular.interactive(), in: .circle) + + Image(systemName: "laptopcomputer.and.iphone") + .font(.system(size: 46, weight: .bold)) + .foregroundStyle(Color.accentColor) + } + .shadow(color: Color.accentColor.opacity(0.25), radius: 18, y: 10) + + VStack(spacing: 6) { + Text("Pair with your Mac") + .font(.title.weight(.semibold)) + Text("Securely link this device to RxCode in seconds.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(.top, 12) + } + + private var instructions: some View { + VStack(alignment: .leading, spacing: 14) { + stepRow(number: 1, text: "Open **RxCode** on your Mac.") + stepRow(number: 2, text: "Go to **Settings → Mobile** and tap *Pair new device*.") + stepRow(number: 3, text: "Scan the QR code shown on your Mac with this app.") + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular, in: .rect(cornerRadius: 22)) + } + + private func stepRow(number: Int, text: LocalizedStringKey) -> some View { + HStack(alignment: .top, spacing: 14) { + Text("\(number)") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26, height: 26) + .glassEffect(.regular, in: .circle) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var deviceNameField: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Device name", systemImage: "tag") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Image(systemName: "iphone") + .foregroundStyle(.secondary) + TextField("Device name", text: $displayName) + .textFieldStyle(.plain) + .submitLabel(.done) + .focused($nameFocused) + if !displayName.isEmpty { + Button { + displayName = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .glassEffect( + .regular.interactive(), + in: .rect(cornerRadius: 16) + ) + } + } + + private var scanButton: some View { + Button { + showScanOptions = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.glassProminent) + .controlSize(.large) + .tint(.accentColor) + } + + private func errorBanner(_ message: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(message) + .font(.footnote) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + pairingError = nil + state.dismissPairingError() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(14) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + } + + private var combinedError: String? { + if case .failed(let message) = state.pairingStatus { return message } + return pairingError + } + + private var pairingOverlay: some View { + ZStack { + Rectangle() + .fill(Color.black.opacity(0.25)) + .ignoresSafeArea() + + VStack(spacing: 18) { + ProgressView() + .controlSize(.large) + .tint(.accentColor) + VStack(spacing: 4) { + Text("Pairing with your Mac…") + .font(.headline) + Text("Make sure RxCode is open on your Mac.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Button { + onboardingLogger.info("user cancelled pairing") + state.cancelPairing() + } label: { + Text("Cancel") + .frame(maxWidth: 180) + .padding(.vertical, 4) + } + .buttonStyle(.glass) + .controlSize(.regular) + .padding(.top, 4) + } + .padding(28) + .frame(maxWidth: 320) + .glassEffect(.regular, in: .rect(cornerRadius: 22)) + .shadow(color: .black.opacity(0.2), radius: 24, y: 12) + } + } + + // MARK: - Scan handling + private func handleScan(_ value: String) { + onboardingLogger.info("handleScan received payload length=\(value.count, privacy: .public)") do { let token = try PairingToken.parse(value) + onboardingLogger.info("parsed token expired=\(token.isExpired, privacy: .public)") guard !token.isExpired else { pairingError = "This QR has expired. Generate a new one on your Mac." return } pairingError = nil - Task { await state.pair(with: token, displayName: displayName) } + Task { + onboardingLogger.info("starting pair task displayName=\(displayName, privacy: .public)") + await state.pair(with: token, displayName: displayName) + onboardingLogger.info("pair task finished isPaired=\(state.isPaired, privacy: .public)") + } } catch { + onboardingLogger.error("token parse failed: \(error.localizedDescription, privacy: .public)") pairingError = "Unrecognized QR. Try scanning the one from the Mac app." } } + + private func loadAndDecode(item: PhotosPickerItem) async { + onboardingLogger.info("loadAndDecode start") + defer { photoItem = nil } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + onboardingLogger.error("photo picker returned nil data") + pairingError = "Couldn't read the selected image." + return + } + guard let image = UIImage(data: data) else { + onboardingLogger.error("UIImage init failed bytes=\(data.count, privacy: .public)") + pairingError = "Couldn't read the selected image." + return + } + onboardingLogger.info("decoded image size=\(image.size.debugDescription, privacy: .public)") + if let payload = QRImageDecoder.decode(image) { + onboardingLogger.info("QR decoded from photo length=\(payload.count, privacy: .public)") + handleScan(payload) + } else { + onboardingLogger.error("no QR found in photo") + pairingError = "No QR code found in that image." + } + } catch { + onboardingLogger.error("loadTransferable error: \(error.localizedDescription, privacy: .public)") + pairingError = "Couldn't load the selected image." + } + } +} + +// MARK: - Image QR decoding + +private enum QRImageDecoder { + static func decode(_ image: UIImage) -> String? { + guard let cgImage = image.cgImage else { return nil } + let ciImage = CIImage(cgImage: cgImage) + let context = CIContext() + let detector = CIDetector( + ofType: CIDetectorTypeQRCode, + context: context, + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] + ) + let features = detector?.features(in: ciImage) ?? [] + for feature in features { + if let qr = feature as? CIQRCodeFeature, let value = qr.messageString { + return value + } + } + return nil + } +} + +// MARK: - Background + +private struct BackdropOrbs: View { + @State private var animate = false + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(.systemBackground), + Color.accentColor.opacity(0.06) + ], + startPoint: .top, + endPoint: .bottom + ) + + Circle() + .fill(Color.accentColor.opacity(0.35)) + .frame(width: 320, height: 320) + .blur(radius: 80) + .offset(x: animate ? -120 : -160, y: animate ? -220 : -260) + + Circle() + .fill(Color.blue.opacity(0.25)) + .frame(width: 280, height: 280) + .blur(radius: 90) + .offset(x: animate ? 140 : 180, y: animate ? 260 : 220) + + Circle() + .fill(Color.purple.opacity(0.18)) + .frame(width: 220, height: 220) + .blur(radius: 90) + .offset(x: animate ? -100 : -60, y: animate ? 180 : 220) + } + .ignoresSafeArea() + .onAppear { + withAnimation(.easeInOut(duration: 8).repeatForever(autoreverses: true)) { + animate = true + } + } + } } diff --git a/RxCodeMobile/Views/PermissionApprovalSheet.swift b/RxCodeMobile/Views/PermissionApprovalSheet.swift index 2d416964..72498ab1 100644 --- a/RxCodeMobile/Views/PermissionApprovalSheet.swift +++ b/RxCodeMobile/Views/PermissionApprovalSheet.swift @@ -1,6 +1,12 @@ import SwiftUI import RxCodeSync +/// Makes the wire payload usable with SwiftUI's `.sheet(item:)`. The relay +/// guarantees `requestID` is unique per outstanding permission prompt. +extension PermissionRequestPayload: Identifiable { + public var id: String { requestID } +} + struct PermissionApprovalSheet: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index e7aa2dfb..35d4b4bf 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeCore struct ProjectsSidebar: View { @EnvironmentObject private var state: MobileAppState diff --git a/RxCodeMobile/Views/QRScannerView.swift b/RxCodeMobile/Views/QRScannerView.swift index b0ed65f5..151829c2 100644 --- a/RxCodeMobile/Views/QRScannerView.swift +++ b/RxCodeMobile/Views/QRScannerView.swift @@ -1,11 +1,15 @@ import SwiftUI import AVFoundation +import os + +private let scannerLogger = Logger(subsystem: "com.claudework", category: "QRScanner") /// Presents the rear camera and reports the first detected QR string. struct QRScannerView: UIViewControllerRepresentable { let onResult: (String) -> Void func makeUIViewController(context: Context) -> ScannerVC { + scannerLogger.info("makeUIViewController") let vc = ScannerVC() vc.onResult = onResult return vc @@ -21,15 +25,52 @@ struct QRScannerView: UIViewControllerRepresentable { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } + + let authStatus = AVCaptureDevice.authorizationStatus(for: .video) + scannerLogger.info("viewDidLoad authStatus=\(authStatus.rawValue, privacy: .public)") + + switch authStatus { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + scannerLogger.info("camera access granted=\(granted, privacy: .public)") + DispatchQueue.main.async { + if granted { self?.configureSession() } + } + } + case .denied, .restricted: + scannerLogger.error("camera access denied/restricted") + @unknown default: + scannerLogger.error("camera access unknown status") + } + } + + private func configureSession() { + guard let device = AVCaptureDevice.default(for: .video) else { + scannerLogger.error("no default video device") + return + } + let input: AVCaptureDeviceInput + do { + input = try AVCaptureDeviceInput(device: device) + } catch { + scannerLogger.error("device input error: \(error.localizedDescription, privacy: .public)") + return + } + guard session.canAddInput(input) else { + scannerLogger.error("cannot add camera input") + return + } session.addInput(input) let output = AVCaptureMetadataOutput() if session.canAddOutput(output) { session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] + scannerLogger.info("metadata output configured for .qr") + } else { + scannerLogger.error("cannot add metadata output") } let preview = AVCaptureVideoPreviewLayer(session: session) preview.videoGravity = .resizeAspectFill @@ -38,6 +79,7 @@ struct QRScannerView: UIViewControllerRepresentable { previewLayer = preview DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() + scannerLogger.info("capture session started") } } @@ -48,14 +90,22 @@ struct QRScannerView: UIViewControllerRepresentable { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if session.isRunning { session.stopRunning() } + if session.isRunning { + session.stopRunning() + scannerLogger.info("capture session stopped (viewWillDisappear)") + } } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + scannerLogger.debug("metadataOutput count=\(metadataObjects.count, privacy: .public)") guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - let value = obj.stringValue else { return } + let value = obj.stringValue else { + scannerLogger.debug("first metadata object had no string value") + return + } + scannerLogger.info("QR detected length=\(value.count, privacy: .public)") session.stopRunning() onResult?(value) } diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index f73cf226..6bfa42b4 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeSync /// Mobile app root. On iPad / wide screens this uses NavigationSplitView; on /// iPhone it auto-collapses to a stack. diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index a0dff85e..4ebcc487 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeSync struct SessionsList: View { @EnvironmentObject private var state: MobileAppState diff --git a/RxCodeMobileNotificationService/NotificationService.swift b/RxCodeMobileNotificationService/NotificationService.swift index 0e37ea01..b93a8871 100644 --- a/RxCodeMobileNotificationService/NotificationService.swift +++ b/RxCodeMobileNotificationService/NotificationService.swift @@ -22,7 +22,9 @@ final class NotificationService: UNNotificationServiceExtension { let raw = Data(base64Encoded: encB64), let envelope = try? JSONDecoder().decode(EncryptedAlert.self, from: raw), let identity = try? DeviceIdentity.loadOrCreate( - accessGroup: "$(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared" + accessGroup: DeviceIdentity.resolveAccessGroup( + suffix: "app.rxlab.rxcodemobile.shared" + ) ), let senderRaw = Data(hexString: envelope.from), let senderKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: senderRaw), diff --git a/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements b/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements index 1051617f..926403cf 100644 --- a/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements +++ b/RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements @@ -4,7 +4,7 @@ keychain-access-groups - $(AppIdentifierPrefix)com.idealapp.RxCode.Mobile.shared + $(AppIdentifierPrefix)app.rxlab.rxcodemobile.shared diff --git a/relay-server/Makefile b/relay-server/Makefile new file mode 100644 index 00000000..eca711c1 --- /dev/null +++ b/relay-server/Makefile @@ -0,0 +1,49 @@ +ADDR ?= :8787 +APNS_KEY ?= +APNS_KEY_ID ?= +APNS_TEAM_ID ?= +APNS_TOPIC ?= app.rxlab.rxcodemobile +APNS_PRODUCTION ?= false +ENV_FILE ?= .env + +.PHONY: run run-apns run-env encode-key tidy build docker-build docker-run clean + +tidy: + go mod tidy + +run: tidy + go run . -addr $(ADDR) + +# Pass APNs config via CLI flags (key on disk). +run-apns: tidy + go run . \ + -addr $(ADDR) \ + -apns-key $(APNS_KEY) \ + -apns-key-id $(APNS_KEY_ID) \ + -apns-team-id $(APNS_TEAM_ID) \ + -apns-topic $(APNS_TOPIC) \ + -apns-production=$(APNS_PRODUCTION) + +# Pass everything (including the .p8) via env vars from $(ENV_FILE). +# The binary itself loads .env via godotenv — no shell-side sourcing needed. +# Expected variables: RELAY_ADDR, APNS_KEY_B64, APNS_KEY_ID, APNS_TEAM_ID, +# APNS_TOPIC, APNS_PRODUCTION. +run-env: tidy + RELAY_ENV_FILE=$(ENV_FILE) go run . + +# Helper: base64-encode a .p8 file for pasting into APNS_KEY_B64. +# make encode-key KEY=./AuthKey_ABCDE12345.p8 +encode-key: + @base64 < $(KEY) | tr -d '\n'; echo + +build: + go build -o bin/rxcode-relay . + +docker-build: + docker build -t rxcode-relay . + +docker-run: + docker run --rm -p 8787:8787 rxcode-relay -addr :8787 + +clean: + rm -rf bin diff --git a/relay-server/README.md b/relay-server/README.md index 8ac00518..de1a4311 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -34,33 +34,77 @@ go mod tidy go run . -addr :8787 ``` -To also enable APNs: +## Configuration + +Every option can be set via CLI flag **or** environment variable. At startup +the binary loads `.env` (via [`godotenv`](https://github.com/joho/godotenv)) +from the current directory, then reads the process environment, then applies +any explicit CLI flags. So precedence is: flag > env > `.env` file. + +Override the env file path with `RELAY_ENV_FILE=/path/to/file go run .`. A +missing file is non-fatal — the relay just uses whatever's in the process env. + + +| Flag | Env var | Purpose | +| ------------------- | ------------------ | ---------------------------------------------------- | +| `-addr` | `RELAY_ADDR` | Listen address (default `:8787`). | +| `-apns-key` | `APNS_KEY_PATH` | Path to a `.p8` auth key file on disk. | +| *(none)* | `APNS_KEY_B64` | `.p8` contents base64-encoded — preferred for env. | +| `-apns-key-id` | `APNS_KEY_ID` | 10-char Key ID from the Apple developer portal. | +| `-apns-team-id` | `APNS_TEAM_ID` | 10-char Team ID. | +| `-apns-topic` | `APNS_TOPIC` | iOS app bundle identifier (e.g. `app.rxlab.rxcodemobile`). | +| `-apns-production` | `APNS_PRODUCTION` | `true` for production endpoint, else sandbox. | + +`APNS_KEY_B64` wins over `APNS_KEY_PATH` when both are set. Both standard and +URL-safe base64 are accepted, and embedded whitespace/newlines are stripped — +so `cat AuthKey.p8 | base64` works as-is. + +### Run with a `.env` file + +Create `relay-server/.env` (or anywhere — point to it with `RELAY_ENV_FILE`): + +```dotenv +RELAY_ADDR=:8787 +APNS_KEY_B64=MIGTAgEAMBM... # base64 of your .p8 file +APNS_KEY_ID=ABCDE12345 +APNS_TEAM_ID=YYYYYYYYYY +APNS_TOPIC=app.rxlab.rxcodemobile +APNS_PRODUCTION=false +``` + +Encode the `.p8` ready for the file: + +```bash +make encode-key KEY=./AuthKey_ABCDE12345.p8 +``` + +Then run: ```bash -go run . \ - -addr :8787 \ - -apns-key ./AuthKey_ABCDE12345.p8 \ - -apns-key-id ABCDE12345 \ - -apns-team-id YYYYYYYYYY \ - -apns-topic com.idealapp.RxCode.Mobile \ - -apns-production=false +make run-env # uses ./.env +make run-env ENV_FILE=staging.env +# …or directly: +go run . # auto-loads ./.env if present +RELAY_ENV_FILE=staging.env go run . ``` +`.env` should be `.gitignore`d. + ## Docker ```bash docker build -t rxcode-relay . docker run -p 8787:8787 \ - -v $(pwd)/AuthKey_ABCDE12345.p8:/keys/apns.p8:ro \ - rxcode-relay \ - -addr :8787 \ - -apns-key /keys/apns.p8 \ - -apns-key-id ABCDE12345 \ - -apns-team-id YYYYYYYYYY \ - -apns-topic com.idealapp.RxCode.Mobile \ - -apns-production=true + -e APNS_KEY_B64="$(base64 < ./AuthKey_ABCDE12345.p8 | tr -d '\n')" \ + -e APNS_KEY_ID=ABCDE12345 \ + -e APNS_TEAM_ID=YYYYYYYYYY \ + -e APNS_TOPIC=app.rxlab.rxcodemobile \ + -e APNS_PRODUCTION=true \ + rxcode-relay ``` +No bind-mount needed — the key lives only in the container environment. + ## APNs setup notes - Generate a `.p8` auth key in https://developer.apple.com/account/resources/authkeys/list. diff --git a/relay-server/go.mod b/relay-server/go.mod index 418bc336..61147108 100644 --- a/relay-server/go.mod +++ b/relay-server/go.mod @@ -4,6 +4,7 @@ go 1.22 require ( github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 github.com/sideshow/apns2 v0.25.0 ) diff --git a/relay-server/go.sum b/relay-server/go.sum index 98706fa2..ab5d0afa 100644 --- a/relay-server/go.sum +++ b/relay-server/go.sum @@ -7,6 +7,8 @@ github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQg github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4= diff --git a/relay-server/main.go b/relay-server/main.go index 0cb63eca..d068ad8f 100644 --- a/relay-server/main.go +++ b/relay-server/main.go @@ -2,37 +2,69 @@ package main import ( "context" + "encoding/base64" + "errors" "flag" + "io/fs" "log" "net/http" "os" "os/signal" + "strconv" + "strings" "syscall" "time" + + "github.com/joho/godotenv" ) func main() { - addr := flag.String("addr", ":8787", "listen address") - apnsKeyPath := flag.String("apns-key", "", "path to APNs .p8 auth key") - apnsKeyID := flag.String("apns-key-id", "", "APNs Key ID") - apnsTeamID := flag.String("apns-team-id", "", "APNs Team ID") - apnsTopic := flag.String("apns-topic", "", "APNs topic (mobile app bundle ID)") - apnsProduction := flag.Bool("apns-production", false, "use production APNs endpoint instead of sandbox") + // Load .env before reading any env vars so flag defaults see them. + // Path overridable via RELAY_ENV_FILE; default ".env" in CWD. Missing + // file is non-fatal — env may be supplied entirely by the host. + envFile := envOr("RELAY_ENV_FILE", ".env") + if err := godotenv.Load(envFile); err != nil { + if errors.Is(err, fs.ErrNotExist) { + log.Printf("no env file at %s (ok, using process env only)", envFile) + } else { + log.Fatalf("load env file %s: %v", envFile, err) + } + } else { + log.Printf("loaded env from %s", envFile) + } + + addr := flag.String("addr", envOr("RELAY_ADDR", ":8787"), "listen address (env: RELAY_ADDR)") + apnsKeyPath := flag.String("apns-key", os.Getenv("APNS_KEY_PATH"), "path to APNs .p8 auth key (env: APNS_KEY_PATH)") + apnsKeyID := flag.String("apns-key-id", os.Getenv("APNS_KEY_ID"), "APNs Key ID (env: APNS_KEY_ID)") + apnsTeamID := flag.String("apns-team-id", os.Getenv("APNS_TEAM_ID"), "APNs Team ID (env: APNS_TEAM_ID)") + apnsTopic := flag.String("apns-topic", os.Getenv("APNS_TOPIC"), "APNs topic / iOS bundle ID (env: APNS_TOPIC)") + apnsProduction := flag.Bool("apns-production", envBool("APNS_PRODUCTION", false), "use production APNs endpoint instead of sandbox (env: APNS_PRODUCTION)") flag.Parse() + // APNs .p8 key can come from a base64-encoded env var (preferred for + // containerized deployment — no key file on disk) or a path on disk. + keyPEM, err := decodeAPNsKeyB64(os.Getenv("APNS_KEY_B64")) + if err != nil { + log.Fatalf("APNS_KEY_B64: %v", err) + } + hub := NewHub() go hub.Run() var pushSender *PushSender - if *apnsKeyPath != "" { - p, err := NewPushSender(*apnsKeyPath, *apnsKeyID, *apnsTeamID, *apnsTopic, *apnsProduction) + if *apnsKeyPath != "" || len(keyPEM) > 0 { + p, err := NewPushSender(*apnsKeyPath, keyPEM, *apnsKeyID, *apnsTeamID, *apnsTopic, *apnsProduction) if err != nil { log.Fatalf("init APNs sender: %v", err) } pushSender = p - log.Printf("APNs sender enabled (topic=%s production=%v)", *apnsTopic, *apnsProduction) + source := "file" + if len(keyPEM) > 0 && *apnsKeyPath == "" { + source = "env(APNS_KEY_B64)" + } + log.Printf("APNs sender enabled (topic=%s production=%v key=%s)", *apnsTopic, *apnsProduction, source) } else { - log.Printf("APNs sender disabled (no -apns-key provided)") + log.Printf("APNs sender disabled (set APNS_KEY_B64 or -apns-key to enable)") } mux := http.NewServeMux() @@ -62,3 +94,45 @@ func main() { defer cancel() _ = srv.Shutdown(ctx) } + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + log.Printf("env %s: not a bool (%q), using %v", key, v, fallback) + return fallback + } + return b +} + +// decodeAPNsKeyB64 accepts either standard or URL-safe base64. Whitespace +// (newlines from shell heredocs, etc.) is stripped before decoding. +func decodeAPNsKeyB64(s string) ([]byte, error) { + if s == "" { + return nil, nil + } + cleaned := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, s) + if b, err := base64.StdEncoding.DecodeString(cleaned); err == nil { + return b, nil + } + b, err := base64.URLEncoding.DecodeString(cleaned) + if err != nil { + return nil, err + } + return b, nil +} diff --git a/relay-server/push.go b/relay-server/push.go index 38cc6f15..bfbe8e6d 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -20,13 +20,25 @@ type PushSender struct { } // NewPushSender loads the APNs auth key and prepares the HTTP/2 client. -func NewPushSender(keyPath, keyID, teamID, topic string, production bool) (*PushSender, error) { +// +// Exactly one of `keyPath` or `keyPEM` must be non-empty. `keyPEM` is the raw +// `.p8` contents (already base64-decoded if it was wrapped for env transport). +func NewPushSender(keyPath string, keyPEM []byte, keyID, teamID, topic string, production bool) (*PushSender, error) { if keyID == "" || teamID == "" || topic == "" { return nil, fmt.Errorf("apns key id, team id, and topic are required") } - keyBytes, err := os.ReadFile(keyPath) - if err != nil { - return nil, fmt.Errorf("read apns key: %w", err) + var keyBytes []byte + switch { + case len(keyPEM) > 0: + keyBytes = keyPEM + case keyPath != "": + b, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("read apns key: %w", err) + } + keyBytes = b + default: + return nil, fmt.Errorf("apns key not provided (set APNS_KEY_B64 or -apns-key)") } authKey, err := token.AuthKeyFromBytes(keyBytes) if err != nil { From 738e83a94ddbb04d2b8dc86bc503793f03817e5a Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 20:47:43 +0800 Subject: [PATCH 3/8] fix: paring using deep url --- Packages/Package.swift | 5 + .../AttachmentAutoPreviewSettings.swift | 2 +- .../Sources/RxCodeSync/Pairing/Pairing.swift | 49 +++- .../Sources/RxCodeSync/Protocol/Payload.swift | 156 +++++++++++++ .../RxCodeSync/Transport/RelayClient.swift | 10 +- .../RxCodeSyncTests/PairingTokenTests.swift | 52 +++++ .../Tests/RxCodeSyncTests/PayloadTests.swift | 88 +++++++ RxCode/App/AppState.swift | 215 +++++++++++++++++ RxCode/Services/MobileSyncService.swift | 40 +++- RxCode/Views/Settings/MobileSettingsTab.swift | 26 ++- RxCodeMobile/RxCodeMobile.entitlements | 4 + RxCodeMobile/RxCodeMobileApp.swift | 14 ++ RxCodeMobile/State/MobileAppState.swift | 220 +++++++++++++++--- RxCodeMobile/Views/MobileBriefingView.swift | 144 ++++++++++++ RxCodeMobile/Views/MobileSettingsView.swift | 181 ++++++++++++++ RxCodeMobile/Views/OnboardingView.swift | 8 + RxCodeMobile/Views/ProjectsSidebar.swift | 30 ++- RxCodeMobile/Views/RootView.swift | 14 +- .../apple-app-site-association/route.ts | 19 ++ website/app/pair/page.tsx | 105 +++++++++ 20 files changed, 1330 insertions(+), 52 deletions(-) create mode 100644 Packages/Tests/RxCodeSyncTests/PairingTokenTests.swift create mode 100644 Packages/Tests/RxCodeSyncTests/PayloadTests.swift create mode 100644 RxCodeMobile/Views/MobileBriefingView.swift create mode 100644 website/app/.well-known/apple-app-site-association/route.ts create mode 100644 website/app/pair/page.tsx diff --git a/Packages/Package.swift b/Packages/Package.swift index 3982af60..3c388297 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -48,5 +48,10 @@ let package = Package( ], path: "Tests/RxCodeChatKitTests" ), + .testTarget( + name: "RxCodeSyncTests", + dependencies: ["RxCodeSync"], + path: "Tests/RxCodeSyncTests" + ), ] ) diff --git a/Packages/Sources/RxCodeCore/Models/AttachmentAutoPreviewSettings.swift b/Packages/Sources/RxCodeCore/Models/AttachmentAutoPreviewSettings.swift index f0ce345c..fde2fc2d 100644 --- a/Packages/Sources/RxCodeCore/Models/AttachmentAutoPreviewSettings.swift +++ b/Packages/Sources/RxCodeCore/Models/AttachmentAutoPreviewSettings.swift @@ -1,7 +1,7 @@ import Foundation /// Per-type flags that control whether pasted content is auto-converted to an attachment preview chip. -public struct AttachmentAutoPreviewSettings: Codable, Sendable { +public struct AttachmentAutoPreviewSettings: Codable, Sendable, Equatable { public var url: Bool = true public var filePath: Bool = true public var image: Bool = true diff --git a/Packages/Sources/RxCodeSync/Pairing/Pairing.swift b/Packages/Sources/RxCodeSync/Pairing/Pairing.swift index 3eccd7ce..4a97e5dc 100644 --- a/Packages/Sources/RxCodeSync/Pairing/Pairing.swift +++ b/Packages/Sources/RxCodeSync/Pairing/Pairing.swift @@ -49,22 +49,65 @@ public struct PairingToken: Codable, Sendable { // MARK: - QR encoding + public static let deeplinkHost = "code.rxlab.app" + public static let deeplinkPath = "/pair" + public static let legacySchemePrefix = "rxcode-pair:" + public func qrString() throws -> String { + try deeplinkString() + } + + public func deeplinkString() throws -> String { + let token = try encodedToken() + var components = URLComponents() + components.scheme = "https" + components.host = Self.deeplinkHost + components.path = Self.deeplinkPath + components.queryItems = [ + URLQueryItem(name: "token", value: token), + URLQueryItem(name: "relay", value: relayURL), + ] + guard let url = components.url else { throw PairingError.malformed } + return url.absoluteString + } + + public func legacyQRString() throws -> String { + Self.legacySchemePrefix + (try encodedToken()) + } + + private func encodedToken() throws -> String { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(self) - return "rxcode-pair:" + data.base64EncodedString(options: .init()) + return data.base64EncodedString(options: .init()) } public static func parse(_ qrString: String) throws -> PairingToken { - guard qrString.hasPrefix("rxcode-pair:") else { throw PairingError.malformed } - let b64 = String(qrString.dropFirst("rxcode-pair:".count)) + let b64: String + if qrString.hasPrefix(Self.legacySchemePrefix) { + b64 = String(qrString.dropFirst(Self.legacySchemePrefix.count)) + } else if let token = deeplinkToken(from: qrString) { + b64 = token + } else { + throw PairingError.malformed + } guard let data = Data(base64Encoded: b64) else { throw PairingError.malformed } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(PairingToken.self, from: data) } + private static func deeplinkToken(from qrString: String) -> String? { + guard let url = URL(string: qrString), + url.scheme == "https", + url.host?.lowercased() == deeplinkHost, + url.path == deeplinkPath, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + return components.queryItems?.first(where: { $0.name == "token" })?.value + } + public static func makeOneTimeSecret() -> String { var bytes = [UInt8](repeating: 0, count: 16) let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index cb393212..7a2d942c 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -9,9 +9,11 @@ import RxCodeCore public enum Payload: Sendable { case pairRequest(PairRequestPayload) case pairAck(PairAckPayload) + case unpair(UnpairPayload) case apnsToken(APNsTokenPayload) case requestSnapshot(RequestSnapshotPayload) case snapshot(SnapshotPayload) + case settingsUpdate(MobileSettingsUpdatePayload) case sessionUpdate(SessionUpdatePayload) case subscribeSession(SubscribeSessionPayload) case userMessage(UserMessagePayload) @@ -50,6 +52,13 @@ public struct PairAckPayload: Codable, Sendable { } } +public struct UnpairPayload: Codable, Sendable { + public let reason: String? + public init(reason: String? = nil) { + self.reason = reason + } +} + public struct APNsTokenPayload: Codable, Sendable { public let tokenHex: String public let environment: String @@ -69,21 +78,162 @@ public struct RequestSnapshotPayload: Codable, Sendable { public struct SnapshotPayload: Codable, Sendable { public let projects: [Project] public let sessions: [SessionSummary] + public let branchBriefings: [MobileBranchBriefing]? + public let threadSummaries: [MobileThreadSummary]? + public let settings: MobileSettingsSnapshot? public let activeSessionID: String? public let activeSessionMessages: [ChatMessage]? public init( projects: [Project], sessions: [SessionSummary], + branchBriefings: [MobileBranchBriefing]? = nil, + threadSummaries: [MobileThreadSummary]? = nil, + settings: MobileSettingsSnapshot? = nil, activeSessionID: String? = nil, activeSessionMessages: [ChatMessage]? = nil ) { self.projects = projects self.sessions = sessions + self.branchBriefings = branchBriefings + self.threadSummaries = threadSummaries + self.settings = settings self.activeSessionID = activeSessionID self.activeSessionMessages = activeSessionMessages } } +public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { + public var id: String { "\(projectId.uuidString)::\(branch)" } + + public let projectId: UUID + public let branch: String + public let briefing: String + public let updatedAt: Date + + public init(projectId: UUID, branch: String, briefing: String, updatedAt: Date) { + self.projectId = projectId + self.branch = branch + self.briefing = briefing + self.updatedAt = updatedAt + } +} + +public struct MobileThreadSummary: Codable, Sendable, Identifiable, Equatable { + public var id: String { sessionId } + + public let sessionId: String + public let projectId: UUID + public let branch: String + public let title: String + public let summary: String + public let updatedAt: Date + + public init( + sessionId: String, + projectId: UUID, + branch: String, + title: String, + summary: String, + updatedAt: Date + ) { + self.sessionId = sessionId + self.projectId = projectId + self.branch = branch + self.title = title + self.summary = summary + self.updatedAt = updatedAt + } +} + +public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { + public let selectedAgentProvider: AgentProvider + public let selectedModel: String + public let selectedACPClientId: String + public let selectedEffort: String + public let permissionMode: PermissionMode + public let summarizationProvider: String + public let summarizationProviderDisplayName: String + public let openAISummarizationEndpoint: String + public let openAISummarizationModel: String + public let notificationsEnabled: Bool + public let focusMode: Bool + public let autoArchiveEnabled: Bool + public let archiveRetentionDays: Int + public let autoPreviewSettings: AttachmentAutoPreviewSettings + public let availableEfforts: [String] + + public init( + selectedAgentProvider: AgentProvider, + selectedModel: String, + selectedACPClientId: String, + selectedEffort: String, + permissionMode: PermissionMode, + summarizationProvider: String, + summarizationProviderDisplayName: String, + openAISummarizationEndpoint: String, + openAISummarizationModel: String, + notificationsEnabled: Bool, + focusMode: Bool, + autoArchiveEnabled: Bool, + archiveRetentionDays: Int, + autoPreviewSettings: AttachmentAutoPreviewSettings, + availableEfforts: [String] + ) { + self.selectedAgentProvider = selectedAgentProvider + self.selectedModel = selectedModel + self.selectedACPClientId = selectedACPClientId + self.selectedEffort = selectedEffort + self.permissionMode = permissionMode + self.summarizationProvider = summarizationProvider + self.summarizationProviderDisplayName = summarizationProviderDisplayName + self.openAISummarizationEndpoint = openAISummarizationEndpoint + self.openAISummarizationModel = openAISummarizationModel + self.notificationsEnabled = notificationsEnabled + self.focusMode = focusMode + self.autoArchiveEnabled = autoArchiveEnabled + self.archiveRetentionDays = archiveRetentionDays + self.autoPreviewSettings = autoPreviewSettings + self.availableEfforts = availableEfforts + } +} + +public struct MobileSettingsUpdatePayload: Codable, Sendable { + public let selectedAgentProvider: AgentProvider? + public let selectedModel: String? + public let selectedACPClientId: String? + public let selectedEffort: String? + public let permissionMode: PermissionMode? + public let notificationsEnabled: Bool? + public let focusMode: Bool? + public let autoArchiveEnabled: Bool? + public let archiveRetentionDays: Int? + public let autoPreviewSettings: AttachmentAutoPreviewSettings? + + public init( + selectedAgentProvider: AgentProvider? = nil, + selectedModel: String? = nil, + selectedACPClientId: String? = nil, + selectedEffort: String? = nil, + permissionMode: PermissionMode? = nil, + notificationsEnabled: Bool? = nil, + focusMode: Bool? = nil, + autoArchiveEnabled: Bool? = nil, + archiveRetentionDays: Int? = nil, + autoPreviewSettings: AttachmentAutoPreviewSettings? = nil + ) { + self.selectedAgentProvider = selectedAgentProvider + self.selectedModel = selectedModel + self.selectedACPClientId = selectedACPClientId + self.selectedEffort = selectedEffort + self.permissionMode = permissionMode + self.notificationsEnabled = notificationsEnabled + self.focusMode = focusMode + self.autoArchiveEnabled = autoArchiveEnabled + self.archiveRetentionDays = archiveRetentionDays + self.autoPreviewSettings = autoPreviewSettings + } +} + public struct SessionSummary: Codable, Sendable, Identifiable { public let id: String public let projectId: UUID @@ -233,9 +383,11 @@ extension Payload: Codable { enum TypeKey: String { case pairRequest = "pair_request" case pairAck = "pair_ack" + case unpair case apnsToken = "apns_token" case requestSnapshot = "request_snapshot" case snapshot + case settingsUpdate = "settings_update" case sessionUpdate = "session_update" case subscribeSession = "subscribe_session" case userMessage = "user_message" @@ -257,9 +409,11 @@ extension Payload: Codable { switch kind { case .pairRequest: self = .pairRequest(try container.decode(PairRequestPayload.self, forKey: .data)) case .pairAck: self = .pairAck(try container.decode(PairAckPayload.self, forKey: .data)) + case .unpair: self = .unpair(try container.decode(UnpairPayload.self, forKey: .data)) case .apnsToken: self = .apnsToken(try container.decode(APNsTokenPayload.self, forKey: .data)) case .requestSnapshot: self = .requestSnapshot(try container.decode(RequestSnapshotPayload.self, forKey: .data)) case .snapshot: self = .snapshot(try container.decode(SnapshotPayload.self, forKey: .data)) + case .settingsUpdate: self = .settingsUpdate(try container.decode(MobileSettingsUpdatePayload.self, forKey: .data)) case .sessionUpdate: self = .sessionUpdate(try container.decode(SessionUpdatePayload.self, forKey: .data)) case .subscribeSession: self = .subscribeSession(try container.decode(SubscribeSessionPayload.self, forKey: .data)) case .userMessage: self = .userMessage(try container.decode(UserMessagePayload.self, forKey: .data)) @@ -277,9 +431,11 @@ extension Payload: Codable { switch self { case .pairRequest(let p): try container.encode(TypeKey.pairRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .pairAck(let p): try container.encode(TypeKey.pairAck.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .unpair(let p): try container.encode(TypeKey.unpair.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .apnsToken(let p): try container.encode(TypeKey.apnsToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .requestSnapshot(let p): try container.encode(TypeKey.requestSnapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .snapshot(let p): try container.encode(TypeKey.snapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .settingsUpdate(let p): try container.encode(TypeKey.settingsUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .sessionUpdate(let p): try container.encode(TypeKey.sessionUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .subscribeSession(let p): try container.encode(TypeKey.subscribeSession.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .userMessage(let p): try container.encode(TypeKey.userMessage.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift index 6867791a..26d99e78 100644 --- a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -114,8 +114,7 @@ public actor RelayClient { handleSocketFailure(error: RelayError.invalidURL) return } - let basePath = components.path - components.path = basePath.isEmpty ? "/ws" : basePath + "/ws" + components.path = Self.webSocketPath(from: components.path) components.queryItems = [URLQueryItem(name: "pubkey", value: identity.publicKeyHex)] guard let url = components.url else { handleSocketFailure(error: RelayError.invalidURL) @@ -134,6 +133,13 @@ public actor RelayClient { startPingLoop() } + private static func webSocketPath(from basePath: String) -> String { + let trimmed = basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmed.isEmpty else { return "/ws" } + guard trimmed.split(separator: "/").last != "ws" else { return "/" + trimmed } + return "/" + trimmed + "/ws" + } + private func markConnected() { if state != .connected { reconnectAttempt = 0 diff --git a/Packages/Tests/RxCodeSyncTests/PairingTokenTests.swift b/Packages/Tests/RxCodeSyncTests/PairingTokenTests.swift new file mode 100644 index 00000000..aff5e6ef --- /dev/null +++ b/Packages/Tests/RxCodeSyncTests/PairingTokenTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import RxCodeSync + +@Suite("Pairing token links") +struct PairingTokenTests { + @Test("QR string is an HTTPS pairing deeplink with token and relay") + func qrStringUsesPairingDeeplink() throws { + let token = makeToken(relayURL: "wss://relay.rxlab.app/ws") + let qrString = try token.qrString() + let url = try #require(URL(string: qrString)) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let queryItems = Dictionary( + uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + + #expect(url.scheme == "https") + #expect(url.host == PairingToken.deeplinkHost) + #expect(url.path == PairingToken.deeplinkPath) + #expect(queryItems["token"] != nil) + #expect(queryItems["relay"] == "wss://relay.rxlab.app/ws") + } + + @Test("Parser accepts HTTPS deeplinks and legacy QR payloads") + func parseAcceptsNewAndLegacyFormats() throws { + let token = makeToken(relayURL: "ws://localhost:8787/ws") + + let deeplink = try token.qrString() + let parsedDeeplink = try PairingToken.parse(deeplink) + #expect(parsedDeeplink.sessionID == token.sessionID) + #expect(parsedDeeplink.relayURL == token.relayURL) + + let legacy = try token.legacyQRString() + let parsedLegacy = try PairingToken.parse(legacy) + #expect(parsedLegacy.sessionID == token.sessionID) + #expect(parsedLegacy.relayURL == token.relayURL) + } + + private func makeToken(relayURL: String) -> PairingToken { + PairingToken( + relayURL: relayURL, + desktopPubkeyHex: String(repeating: "a", count: 64), + sessionID: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, + oneTimeSecretHex: String(repeating: "b", count: 32), + issuedAt: Date(timeIntervalSince1970: 0), + expiresAt: Date(timeIntervalSince1970: 3_600), + desktopName: "Mac" + ) + } +} diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift new file mode 100644 index 00000000..43d8b214 --- /dev/null +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +import RxCodeCore +@testable import RxCodeSync + +@Suite("Mobile sync payloads") +struct PayloadTests { + @Test("snapshot carries briefing and settings data") + func snapshotCarriesBriefingAndSettingsData() throws { + let projectId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let settings = MobileSettingsSnapshot( + selectedAgentProvider: .codex, + selectedModel: "gpt-5.4", + selectedACPClientId: "", + selectedEffort: "high", + permissionMode: .acceptEdits, + summarizationProvider: "selectedClient", + summarizationProviderDisplayName: "Thread Model", + openAISummarizationEndpoint: "https://api.openai.com/v1", + openAISummarizationModel: "", + notificationsEnabled: true, + focusMode: false, + autoArchiveEnabled: true, + archiveRetentionDays: 7, + autoPreviewSettings: AttachmentAutoPreviewSettings(), + availableEfforts: ["auto", "low", "medium", "high"] + ) + let payload = Payload.snapshot( + SnapshotPayload( + projects: [], + sessions: [], + branchBriefings: [ + MobileBranchBriefing( + projectId: projectId, + branch: "main", + briefing: "Current work summary", + updatedAt: Date(timeIntervalSince1970: 10) + ) + ], + threadSummaries: [ + MobileThreadSummary( + sessionId: "thread-1", + projectId: projectId, + branch: "main", + title: "Fix sync", + summary: "Added mobile sync fields", + updatedAt: Date(timeIntervalSince1970: 11) + ) + ], + settings: settings + ) + ) + + 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.branchBriefings?.first?.briefing == "Current work summary") + #expect(snapshot.threadSummaries?.first?.title == "Fix sync") + #expect(snapshot.settings?.selectedAgentProvider == .codex) + #expect(snapshot.settings?.permissionMode == .acceptEdits) + } + + @Test("settings update round trips") + func settingsUpdateRoundTrips() throws { + let payload = Payload.settingsUpdate( + MobileSettingsUpdatePayload( + selectedEffort: "medium", + permissionMode: .auto, + focusMode: true + ) + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .settingsUpdate(let update) = decoded else { + Issue.record("Expected settings update payload") + return + } + + #expect(update.selectedEffort == "medium") + #expect(update.permissionMode == .auto) + #expect(update.focusMode == true) + } +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index b7640e52..8ab9aa59 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -2,6 +2,7 @@ import Foundation import os import RxCodeChatKit import RxCodeCore +import RxCodeSync import SwiftUI // MARK: - Per-Session Stream State @@ -938,6 +939,8 @@ final class AppState { var reindexProgress: (done: Int, total: Int)? = nil let runService = RunService() let ideMCPServer = IDEMCPServer() + private var mobileSyncObservers: [NSObjectProtocol] = [] + private var mobileSnapshotBroadcastTask: Task? // MARK: - Run Profiles @@ -1031,6 +1034,218 @@ final class AppState { } ) } + + setupMobileSyncBridge() + } + + private func setupMobileSyncBridge() { + let center = NotificationCenter.default + let snapshotObserver = center.addObserver( + forName: .mobileSyncSnapshotRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String else { return } + let activeSessionID = notification.userInfo?["activeSessionID"] as? String + Task { @MainActor [weak self] in + await self?.sendMobileSnapshot(toHex: fromHex, activeSessionID: activeSessionID) + } + } + mobileSyncObservers.append(snapshotObserver) + + let settingsObserver = center.addObserver( + forName: .mobileSyncSettingsUpdateReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let update = notification.userInfo?["payload"] as? MobileSettingsUpdatePayload + else { return } + Task { @MainActor [weak self] in + self?.applyMobileSettingsUpdate(update) + await self?.sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + } + } + mobileSyncObservers.append(settingsObserver) + + observeMobileSnapshotInputs() + } + + private func observeMobileSnapshotInputs() { + withObservationTracking { + _ = selectedAgentProvider + _ = selectedModel + _ = selectedACPClientId + _ = selectedEffort + _ = permissionMode + _ = notificationsEnabled + _ = focusMode + _ = autoArchiveEnabled + _ = archiveRetentionDays + _ = autoPreviewSettings + _ = branchBriefingRevision + _ = threadSummaryRevision + _ = projects.count + _ = allSessionSummaries.count + } onChange: { + Task { @MainActor [weak self] in + self?.scheduleMobileSnapshotBroadcast() + self?.observeMobileSnapshotInputs() + } + } + } + + private func scheduleMobileSnapshotBroadcast() { + mobileSnapshotBroadcastTask?.cancel() + mobileSnapshotBroadcastTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + await self?.broadcastMobileSnapshots() + } + } + + private func broadcastMobileSnapshots() async { + for device in MobileSyncService.shared.pairedDevices { + await sendMobileSnapshot(toHex: device.pubkeyHex, activeSessionID: nil) + } + } + + private func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { + let active = await mobileActiveSessionPayload(for: activeSessionID) + let payload = SnapshotPayload( + projects: projects, + sessions: mobileSessionSummaries(), + branchBriefings: mobileBranchBriefings(), + threadSummaries: mobileThreadSummaries(), + settings: mobileSettingsSnapshot(), + activeSessionID: active.id, + activeSessionMessages: active.messages + ) + await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) + logger.info( + "[MobileSync] sent snapshot projects=\(self.projects.count, privacy: .public) sessions=\(payload.sessions.count, privacy: .public) active=\(active.id ?? "", privacy: .public)" + ) + } + + private func mobileSessionSummaries() -> [RxCodeSync.SessionSummary] { + let knownProjectIds = Set(projects.map(\.id)) + return allSessionSummaries + .filter { knownProjectIds.contains($0.projectId) } + .sorted { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } + return lhs.updatedAt > rhs.updatedAt + } + .map { + RxCodeSync.SessionSummary( + id: $0.id, + projectId: $0.projectId, + title: $0.title, + updatedAt: $0.updatedAt, + isPinned: $0.isPinned, + isArchived: $0.isArchived + ) + } + } + + private func mobileBranchBriefings() -> [MobileBranchBriefing] { + let knownProjectIds = Set(projects.map(\.id)) + return threadStore.allBranchBriefingItems() + .filter { knownProjectIds.contains($0.projectId) } + .map { + MobileBranchBriefing( + projectId: $0.projectId, + branch: $0.branch, + briefing: $0.briefing, + updatedAt: $0.updatedAt + ) + } + } + + private func mobileThreadSummaries() -> [MobileThreadSummary] { + let knownProjectIds = Set(projects.map(\.id)) + return threadStore.allThreadSummaryItems() + .filter { knownProjectIds.contains($0.projectId) } + .map { + MobileThreadSummary( + sessionId: $0.sessionId, + projectId: $0.projectId, + branch: $0.branch, + title: $0.title, + summary: $0.summary, + updatedAt: $0.updatedAt + ) + } + } + + private func mobileSettingsSnapshot() -> MobileSettingsSnapshot { + MobileSettingsSnapshot( + selectedAgentProvider: selectedAgentProvider, + selectedModel: selectedModel, + selectedACPClientId: selectedACPClientId, + selectedEffort: selectedEffort, + permissionMode: permissionMode, + summarizationProvider: summarizationProvider.rawValue, + summarizationProviderDisplayName: summarizationProvider.displayName, + openAISummarizationEndpoint: openAISummarizationEndpoint, + openAISummarizationModel: openAISummarizationModel, + notificationsEnabled: notificationsEnabled, + focusMode: focusMode, + autoArchiveEnabled: autoArchiveEnabled, + archiveRetentionDays: archiveRetentionDays, + autoPreviewSettings: autoPreviewSettings, + availableEfforts: ["auto"] + Self.availableEfforts + ) + } + + private func applyMobileSettingsUpdate(_ update: MobileSettingsUpdatePayload) { + if let provider = update.selectedAgentProvider { + selectedAgentProvider = provider + } + if let model = update.selectedModel { + selectedModel = model + } + if let clientId = update.selectedACPClientId { + selectedACPClientId = clientId + } + if let effort = update.selectedEffort, effort == "auto" || Self.availableEfforts.contains(effort) { + selectedEffort = effort + } + if let mode = update.permissionMode { + permissionMode = mode + } + if let enabled = update.notificationsEnabled { + notificationsEnabled = enabled + } + if let enabled = update.focusMode { + focusMode = enabled + } + if let enabled = update.autoArchiveEnabled { + autoArchiveEnabled = enabled + } + if let days = update.archiveRetentionDays { + archiveRetentionDays = max(1, min(365, days)) + } + if let previews = update.autoPreviewSettings { + autoPreviewSettings = previews + } + } + + private func mobileActiveSessionPayload(for requestedID: String?) async -> (id: String?, messages: [ChatMessage]?) { + guard let requestedID else { return (nil, nil) } + let resolvedID = resolveCurrentSessionId(requestedID) + + if let state = sessionStates[resolvedID] { + return (resolvedID, cleanLoadedMessages(state.messages)) + } + + guard let summary = allSessionSummaries.first(where: { $0.id == resolvedID }), + let project = projects.first(where: { $0.id == summary.projectId }), + let full = await persistence.loadFullSession(summary: summary, cwd: project.path) + else { + return (nil, nil) + } + + return (resolvedID, cleanLoadedMessages(full.messages)) } /// User-triggered full reindex of every thread. Wipes cached embeddings, diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index ebbc05a6..0b4f1ae8 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -184,11 +184,12 @@ final class MobileSyncService: ObservableObject { pairingContinuation = nil } - /// Remove a paired device. The mobile loses access immediately on the - /// next message — desktop simply forgets its pubkey. + /// Remove a paired device and notify it before forgetting its pubkey. func unpair(_ device: PairedDevice) async { + try? await client.send(.unpair(UnpairPayload(reason: "desktop")), toHex: device.pubkeyHex) pairedDevices.removeAll { $0.pubkeyHex == device.pubkeyHex } savePairedDevices() + subscribedSessions.removeValue(forKey: device.pubkeyHex) await client.removePeer(device.pubkeyHex) } @@ -241,6 +242,8 @@ final class MobileSyncService: ObservableObject { case .pairRequest(let req): pendingPairing = req Task { try? await client.addPeer(req.mobilePubkeyHex) } + case .unpair: + handleRemoteUnpair(pubkeyHex: inbound.fromHex) case .apnsToken(let t): if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { pairedDevices[idx].apnsToken = t.tokenHex @@ -248,13 +251,21 @@ final class MobileSyncService: ObservableObject { pairedDevices[idx].lastSeen = .now savePairedDevices() } - case .requestSnapshot: + case .requestSnapshot(let req): // AppState owns the data; it observes pendingSnapshotRequests // and replies. Stub for now — wired up by AppState bridge. + var userInfo: [String: Any] = ["from": inbound.fromHex] + userInfo["activeSessionID"] = req.activeSessionID NotificationCenter.default.post( name: .mobileSyncSnapshotRequested, object: nil, - userInfo: ["from": inbound.fromHex] + userInfo: userInfo + ) + case .settingsUpdate(let update): + NotificationCenter.default.post( + name: .mobileSyncSettingsUpdateReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": update] ) case .userMessage(let msg): NotificationCenter.default.post( @@ -270,6 +281,13 @@ final class MobileSyncService: ObservableObject { ) case .subscribeSession(let sub): subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" + var userInfo: [String: Any] = ["from": inbound.fromHex] + userInfo["activeSessionID"] = sub.sessionID + NotificationCenter.default.post( + name: .mobileSyncSnapshotRequested, + object: nil, + userInfo: userInfo + ) case .permissionResponse(let resp): NotificationCenter.default.post( name: .mobileSyncPermissionResponse, @@ -283,6 +301,19 @@ final class MobileSyncService: ObservableObject { } } + private func handleRemoteUnpair(pubkeyHex: String) { + guard pairedDevices.contains(where: { $0.pubkeyHex == pubkeyHex }) else { + Task { await client.removePeer(pubkeyHex) } + return + } + + pairedDevices.removeAll { $0.pubkeyHex == pubkeyHex } + savePairedDevices() + subscribedSessions.removeValue(forKey: pubkeyHex) + logger.info("[MobileSync] removed paired device after remote unpair") + Task { await client.removePeer(pubkeyHex) } + } + /// Send a payload to a single peer (used by AppState when replying to /// `request_snapshot` etc). func send(_ payload: Payload, toHex hex: String) async { @@ -319,5 +350,6 @@ extension Notification.Name { static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") static let mobileSyncNewSessionRequested = Notification.Name("mobileSync.newSessionRequested") + static let mobileSyncSettingsUpdateReceived = Notification.Name("mobileSync.settingsUpdateReceived") static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") } diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index d5888563..24f8864d 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -145,6 +145,7 @@ private struct PairingSheet: View { @ObservedObject var sync: MobileSyncService @State private var token: PairingToken? @State private var qrImage: NSImage? + @State private var autoReloadTask: Task? var body: some View { VStack(spacing: 18) { @@ -172,14 +173,31 @@ private struct PairingSheet: View { .frame(width: 360) .padding(24) .onAppear { startPairing() } + .onDisappear { + autoReloadTask?.cancel() + autoReloadTask = nil + } } private func startPairing() { + autoReloadTask?.cancel() let fresh = sync.beginPairing() token = fresh + qrImage = nil if let qrString = try? fresh.qrString() { qrImage = generateQRCode(from: qrString) } + scheduleAutoReload(for: fresh) + } + + private func scheduleAutoReload(for token: PairingToken) { + autoReloadTask = Task { @MainActor in + let delay = max(0, token.expiresAt.timeIntervalSinceNow) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + guard sync.pendingPairing == nil else { return } + startPairing() + } } @ViewBuilder @@ -196,6 +214,12 @@ private struct PairingSheet: View { if let token { expirationLabel(for: token) } + Button { + startPairing() + } label: { + Label("Force reload", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) } } @@ -211,7 +235,7 @@ private struct PairingSheet: View { .font(.caption) .foregroundStyle(remaining < 30 ? Color.red : Color.secondary) } else { - Text("Expired — close and reopen to regenerate") + Text("Expired — generating a new QR code") .font(.caption) .foregroundStyle(.red) } diff --git a/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements index ea621e2c..3a4a5a49 100644 --- a/RxCodeMobile/RxCodeMobile.entitlements +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:code.rxlab.app + keychain-access-groups $(AppIdentifierPrefix)app.rxlab.rxcodemobile.shared diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index 1a1e7ee6..4e86c57c 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -13,6 +13,20 @@ struct RxCodeMobileApp: App { appDelegate.mobileState = state state.start() } + .onOpenURL { url in + handlePairingURL(url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + guard let url = activity.webpageURL else { return } + handlePairingURL(url) + } + } + } + + private func handlePairingURL(_ url: URL) { + MobileHaptics.buttonTap() + Task { + await state.pair(from: url, displayName: UIDevice.current.name) } } } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 9acc0525..e5ca99a4 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -3,6 +3,8 @@ import Combine import CryptoKit import RxCodeCore import RxCodeSync +import UIKit +import os.log /// Single source of truth for the mobile app. Owns the `SyncClient`, the /// decoded projects/sessions/messages mirrored from the paired desktop, and @@ -24,12 +26,16 @@ final class MobileAppState: ObservableObject { @Published var projects: [Project] = [] @Published var sessions: [SessionSummary] = [] + @Published var branchBriefings: [MobileBranchBriefing] = [] + @Published var threadSummaries: [MobileThreadSummary] = [] + @Published var desktopSettings: MobileSettingsSnapshot? @Published var messagesBySession: [String: [ChatMessage]] = [:] @Published var activeSessionID: String? @Published var pendingPermission: PermissionRequestPayload? private let identity: DeviceIdentity - private let client: SyncClient + private var client: SyncClient + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") private var eventTask: Task? private var pairingTimeoutTask: Task? private var apnsTokenHex: String? @@ -81,25 +87,29 @@ final class MobileAppState: ObservableObject { func start() { Task { @MainActor in - if !pairedDesktopPubkey.isEmpty { - try? await client.addPeer(pairedDesktopPubkey) - } - await client.start() - let events = await client.events() - eventTask?.cancel() - eventTask = Task { @MainActor [weak self] in - guard let self else { return } - for await event in events { - self.handle(event) - } - } - // 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 reportAPNsTokenIfPending() + await startClient() + } + } + + private func startClient() async { + if !pairedDesktopPubkey.isEmpty { + try? await client.addPeer(pairedDesktopPubkey) + } + let events = await client.events() + eventTask?.cancel() + eventTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in events { + self.handle(event) } } + 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 reportAPNsTokenIfPending() + } } // MARK: - Pairing @@ -107,16 +117,23 @@ final class MobileAppState: ObservableObject { func pair(with token: PairingToken, displayName: String) async { guard !token.isExpired, let desktopKey = token.desktopPublicKey else { - pairingStatus = .failed("Invalid or expired pairing code.") + failPairing("Invalid or expired pairing code.") return } pairingStatus = .inProgress let desktopHex = token.desktopPubkeyHex - try? await client.addPeer(desktopHex) + logger.info("pairing with relayURL=\(token.relayURL, privacy: .public)") // Persist the relay URL we just learned from the QR. if let url = URL(string: token.relayURL) { - UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") - relayURL = url + await updateRelayForPairingIfNeeded(url) + } else { + logger.error("pairing token has invalid relayURL=\(token.relayURL, privacy: .public)") + } + try? await client.addPeer(desktopHex) + guard await waitForRelayConnection() else { + logger.error("pairing relay connection timed out relay=\(self.relayURL.absoluteString, privacy: .public)") + failPairing("Couldn't connect to the relay from the QR code. Check the relay address and try again.") + return } let req = PairRequestPayload( mobilePubkeyHex: identity.publicKeyHex, @@ -125,15 +142,56 @@ final class MobileAppState: ObservableObject { appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" ) do { + logger.info("sending pair request via relay=\(self.relayURL.absoluteString, privacy: .public)") try await client.send(.pairRequest(req), toHex: desktopHex) } catch { - pairingStatus = .failed("Couldn't reach the relay. Check your network and try again.") + logger.error("pair request send failed: \(error.localizedDescription, privacy: .public)") + failPairing("Couldn't reach the relay. Check your network and try again.") return } _ = desktopKey // silence unused startPairingTimeout() } + func pair(from url: URL, displayName: String) async { + do { + let token = try PairingToken.parse(url.absoluteString) + await pair(with: token, displayName: displayName) + } catch { + logger.error("pairing deeplink parse failed: \(error.localizedDescription, privacy: .public)") + failPairing("Unrecognized pairing link. Generate a new QR code on your Mac.") + } + } + + private func updateRelayForPairingIfNeeded(_ url: URL) async { + UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") + guard url != relayURL else { + logger.info("pairing relay already configured as \(url.absoluteString, privacy: .public)") + return + } + + logger.info("switching pairing relay to \(url.absoluteString, privacy: .public)") + let oldClient = client + eventTask?.cancel() + eventTask = nil + client = SyncClient(identity: identity, relayURL: url) + relayURL = url + connectionState = .disconnected + await oldClient.stop() + await startClient() + } + + private func waitForRelayConnection(timeoutSeconds: Double = 8) async -> Bool { + logger.info("waiting for relay connection state=\(String(describing: self.connectionState), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + if connectionState == .connected { return true } + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + if connectionState == .connected { return true } + } + return false + } + func cancelPairing() { pairingTimeoutTask?.cancel() pairingTimeoutTask = nil @@ -153,7 +211,7 @@ final class MobileAppState: ObservableObject { await MainActor.run { guard let self else { return } guard !self.isPaired else { return } - self.pairingStatus = .failed( + self.failPairing( "Your Mac didn't respond. Make sure RxCode is open and connected, then try again." ) } @@ -192,6 +250,16 @@ final class MobileAppState: ObservableObject { pendingPermission = nil } + func updateDesktopSettings(_ update: MobileSettingsUpdatePayload) async { + guard isPaired else { return } + applySettingsUpdateLocally(update) + try? await client.send(.settingsUpdate(update), toHex: pairedDesktopPubkey) + } + + func refreshSnapshot() async { + await requestSnapshot() + } + func reportAPNsToken(hex: String, environment: String) { apnsTokenHex = hex apnsEnvironment = environment @@ -199,12 +267,28 @@ final class MobileAppState: ObservableObject { } func unpair() async { - if !pairedDesktopPubkey.isEmpty { - await client.removePeer(pairedDesktopPubkey) + let desktopHex = pairedDesktopPubkey + if !desktopHex.isEmpty { + try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: desktopHex) + } + await clearPairing(removePeerHex: desktopHex) + } + + private func clearPairing(removePeerHex hex: String?) async { + if let hex, !hex.isEmpty { + await client.removePeer(hex) } pairedDesktopPubkey = "" pairedDesktopName = "" isPaired = false + projects = [] + sessions = [] + branchBriefings = [] + threadSummaries = [] + desktopSettings = nil + messagesBySession = [:] + activeSessionID = nil + pendingPermission = nil savePairedDesktop() try? DeviceIdentity.reset( accessGroup: Self.keychainAccessGroup @@ -216,7 +300,10 @@ 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)") + let previous = connectionState connectionState = state + triggerConnectionFeedback(from: previous, to: state) if case .connected = state, isPaired { Task { await self.requestSnapshot() } } @@ -237,6 +324,7 @@ final class MobileAppState: ObservableObject { pairedDesktopName = ack.desktopName isPaired = true pairingStatus = .idle + MobileHaptics.connected() savePairedDesktop() Task { await self.requestSnapshot() @@ -244,11 +332,17 @@ final class MobileAppState: ObservableObject { } } else { isPaired = false - pairingStatus = .failed("Your Mac declined the pairing request.") + failPairing("Your Mac declined the pairing request.") } + case .unpair: + guard inbound.fromHex == pairedDesktopPubkey else { return } + Task { await self.clearPairing(removePeerHex: inbound.fromHex) } case .snapshot(let snap): projects = snap.projects sessions = snap.sessions + branchBriefings = snap.branchBriefings ?? [] + threadSummaries = snap.threadSummaries ?? [] + desktopSettings = snap.settings if let active = snap.activeSessionID, let messages = snap.activeSessionMessages { messagesBySession[active] = messages activeSessionID = active @@ -293,6 +387,36 @@ final class MobileAppState: ObservableObject { try? await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) } + private func failPairing(_ message: String) { + pairingStatus = .failed(message) + MobileHaptics.connectionError() + } + + private func triggerConnectionFeedback( + from previous: RelayClient.ConnectionState, + to next: RelayClient.ConnectionState + ) { + guard previous != next else { return } + guard isPaired, pairingStatus != .inProgress else { return } + + switch next { + case .connected: + if case .reconnecting = previous { + MobileHaptics.connected() + } + case .reconnecting: + if previous == .connected { + MobileHaptics.connectionError() + } + case .disconnected: + if previous == .connected { + MobileHaptics.connectionError() + } + case .connecting: + break + } + } + private func reportAPNsTokenIfPending() async { guard isPaired, let tokenHex = apnsTokenHex, @@ -301,6 +425,27 @@ final class MobileAppState: ObservableObject { try? await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) } + private func applySettingsUpdateLocally(_ update: MobileSettingsUpdatePayload) { + guard let current = desktopSettings else { return } + desktopSettings = MobileSettingsSnapshot( + selectedAgentProvider: update.selectedAgentProvider ?? current.selectedAgentProvider, + selectedModel: update.selectedModel ?? current.selectedModel, + selectedACPClientId: update.selectedACPClientId ?? current.selectedACPClientId, + selectedEffort: update.selectedEffort ?? current.selectedEffort, + permissionMode: update.permissionMode ?? current.permissionMode, + summarizationProvider: current.summarizationProvider, + summarizationProviderDisplayName: current.summarizationProviderDisplayName, + openAISummarizationEndpoint: current.openAISummarizationEndpoint, + openAISummarizationModel: current.openAISummarizationModel, + notificationsEnabled: update.notificationsEnabled ?? current.notificationsEnabled, + focusMode: update.focusMode ?? current.focusMode, + autoArchiveEnabled: update.autoArchiveEnabled ?? current.autoArchiveEnabled, + archiveRetentionDays: update.archiveRetentionDays ?? current.archiveRetentionDays, + autoPreviewSettings: update.autoPreviewSettings ?? current.autoPreviewSettings, + availableEfforts: current.availableEfforts + ) + } + // MARK: - Persistence private func loadPairedDesktop() { @@ -315,6 +460,21 @@ final class MobileAppState: ObservableObject { } } -#if canImport(UIKit) -import UIKit -#endif +@MainActor +enum MobileHaptics { + static func buttonTap() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + static func qrScanned() { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + + static func connected() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + static func connectionError() { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } +} diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift new file mode 100644 index 00000000..3b8f5dcb --- /dev/null +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -0,0 +1,144 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +struct MobileBriefingView: View { + @EnvironmentObject private var state: MobileAppState + + private struct GroupedBriefing: Identifiable { + let projectId: UUID + let branch: String + let briefing: MobileBranchBriefing? + let threads: [MobileThreadSummary] + let updatedAt: Date + + var id: String { "\(projectId.uuidString)::\(branch)" } + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + if groups.isEmpty { + ContentUnavailableView( + "No Briefings Yet", + systemImage: "doc.text.magnifyingglass", + description: Text("Briefings appear here after threads finish on your Mac.") + ) + .frame(maxWidth: .infinity, minHeight: 280) + } else { + ForEach(groups) { group in + briefingCard(group) + } + } + } + .padding() + } + .navigationTitle("Briefing") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await state.refreshSnapshot() } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + } + + private var projectsById: [UUID: Project] { + Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) + } + + private var groups: [GroupedBriefing] { + var buckets: [String: GroupedBriefing] = [:] + + for briefing in state.branchBriefings { + let key = "\(briefing.projectId.uuidString)::\(briefing.branch)" + buckets[key] = GroupedBriefing( + projectId: briefing.projectId, + branch: briefing.branch, + briefing: briefing, + threads: buckets[key]?.threads ?? [], + updatedAt: max(briefing.updatedAt, buckets[key]?.updatedAt ?? .distantPast) + ) + } + + for thread in state.threadSummaries { + let key = "\(thread.projectId.uuidString)::\(thread.branch)" + let existing = buckets[key] + var threads = existing?.threads ?? [] + threads.append(thread) + buckets[key] = GroupedBriefing( + projectId: thread.projectId, + branch: thread.branch, + briefing: existing?.briefing, + threads: threads.sorted { $0.updatedAt > $1.updatedAt }, + updatedAt: max(thread.updatedAt, existing?.updatedAt ?? .distantPast) + ) + } + + return buckets.values.sorted { $0.updatedAt > $1.updatedAt } + } + + private func briefingCard(_ group: GroupedBriefing) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(projectsById[group.projectId]?.name ?? "Unknown Project") + .font(.headline) + HStack(spacing: 8) { + Label(group.branch, systemImage: "arrow.triangle.branch") + Text(group.updatedAt.formatted(.relative(presentation: .named))) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + if let briefing = group.briefing?.briefing, !briefing.isEmpty { + Text(briefing) + .font(.body) + .textSelection(.enabled) + } + + if !group.threads.isEmpty { + Divider() + VStack(alignment: .leading, spacing: 8) { + Text("Threads") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(group.threads) { thread in + threadRow(thread) + } + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } + + private func threadRow(_ thread: MobileThreadSummary) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(thread.title.isEmpty ? "Untitled" : thread.title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Spacer() + Text(thread.updatedAt.formatted(.relative(presentation: .named))) + .font(.caption2) + .foregroundStyle(.secondary) + } + if !thread.summary.isEmpty { + Text(thread.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + .textSelection(.enabled) + } +} diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 8192bcbf..c3cc0fc2 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -1,10 +1,13 @@ import SwiftUI +import RxCodeCore import RxCodeSync struct MobileSettingsView: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss @State private var showUnpairConfirm = false + @State private var modelDraft = "" + @State private var acpClientDraft = "" var body: some View { NavigationStack { @@ -21,6 +24,17 @@ struct MobileSettingsView: View { } } + if let settings = state.desktopSettings { + desktopRuntimeSection(settings) + desktopBehaviorSection(settings) + desktopAutoPreviewSection(settings) + desktopSummarizationSection(settings) + } else { + Section("Desktop Settings") { + ProgressView("Syncing settings…") + } + } + Section { Button(role: .destructive) { showUnpairConfirm = true @@ -45,6 +59,173 @@ struct MobileSettingsView: View { dismiss() } } + .onAppear { + modelDraft = state.desktopSettings?.selectedModel ?? "" + acpClientDraft = state.desktopSettings?.selectedACPClientId ?? "" + } + .onChange(of: state.desktopSettings?.selectedModel) { _, value in + modelDraft = value ?? "" + } + .onChange(of: state.desktopSettings?.selectedACPClientId) { _, value in + acpClientDraft = value ?? "" + } + } + } + + private func desktopRuntimeSection(_ settings: MobileSettingsSnapshot) -> some View { + Section("Desktop Runtime") { + Picker("Agent", selection: settingBinding(settings.selectedAgentProvider) { value in + MobileSettingsUpdatePayload(selectedAgentProvider: value) + }) { + ForEach(AgentProvider.allCases, id: \.self) { provider in + Text(provider.displayName).tag(provider) + } + } + + HStack { + Text("Model") + TextField("Model", text: $modelDraft) + .multilineTextAlignment(.trailing) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { applyModelDraft() } + } + + Button("Apply Model") { + applyModelDraft() + } + .disabled(modelDraft.trimmingCharacters(in: .whitespacesAndNewlines) == settings.selectedModel) + + if settings.selectedAgentProvider == .acp { + HStack { + Text("ACP Client") + TextField("Client ID", text: $acpClientDraft) + .multilineTextAlignment(.trailing) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { applyACPClientDraft() } + } + + Button("Apply ACP Client") { + applyACPClientDraft() + } + .disabled(acpClientDraft.trimmingCharacters(in: .whitespacesAndNewlines) == settings.selectedACPClientId) + } + + Picker("Effort", selection: settingBinding(settings.selectedEffort) { value in + MobileSettingsUpdatePayload(selectedEffort: value) + }) { + ForEach(settings.availableEfforts, id: \.self) { effort in + Text(effort == "auto" ? "Auto" : effortDisplayName(effort)).tag(effort) + } + } + + Picker("Permission", selection: settingBinding(settings.permissionMode) { value in + MobileSettingsUpdatePayload(permissionMode: value) + }) { + ForEach(PermissionMode.allCases, id: \.self) { mode in + Label(mode.displayName, systemImage: mode.systemImage).tag(mode) + } + } + } + } + + private func desktopBehaviorSection(_ settings: MobileSettingsSnapshot) -> some View { + Section("Desktop Behavior") { + Toggle("Response notifications", isOn: settingBinding(settings.notificationsEnabled) { value in + MobileSettingsUpdatePayload(notificationsEnabled: value) + }) + Toggle("Focus mode", isOn: settingBinding(settings.focusMode) { value in + MobileSettingsUpdatePayload(focusMode: value) + }) + Toggle("Auto-archive", isOn: settingBinding(settings.autoArchiveEnabled) { value in + MobileSettingsUpdatePayload(autoArchiveEnabled: value) + }) + Stepper( + "Archive after \(settings.archiveRetentionDays) day\(settings.archiveRetentionDays == 1 ? "" : "s")", + value: settingBinding(settings.archiveRetentionDays) { value in + MobileSettingsUpdatePayload(archiveRetentionDays: value) + }, + in: 1...365 + ) + .disabled(!settings.autoArchiveEnabled) + } + } + + private func desktopAutoPreviewSection(_ settings: MobileSettingsSnapshot) -> some View { + Section("Attachment Auto-Preview") { + Toggle("URLs", isOn: autoPreviewBinding(settings, \.url)) + Toggle("File paths", isOn: autoPreviewBinding(settings, \.filePath)) + Toggle("Images", isOn: autoPreviewBinding(settings, \.image)) + Toggle("Long text", isOn: autoPreviewBinding(settings, \.longText)) + } + } + + private func desktopSummarizationSection(_ settings: MobileSettingsSnapshot) -> some View { + Section { + LabeledContent("Provider", value: settings.summarizationProviderDisplayName) + if !settings.openAISummarizationEndpoint.isEmpty { + LabeledContent("Endpoint", value: settings.openAISummarizationEndpoint) + } + if !settings.openAISummarizationModel.isEmpty { + LabeledContent("Model", value: settings.openAISummarizationModel) + } + } header: { + Text("Summarization") + } footer: { + Text("API keys stay on the Mac and are not synced to this device.") + } + } + + private func settingBinding( + _ value: Value, + update: @escaping (Value) -> MobileSettingsUpdatePayload + ) -> Binding { + Binding( + get: { value }, + set: { newValue in + Task { await state.updateDesktopSettings(update(newValue)) } + } + ) + } + + private func autoPreviewBinding( + _ settings: MobileSettingsSnapshot, + _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { settings.autoPreviewSettings[keyPath: keyPath] }, + set: { newValue in + var next = settings.autoPreviewSettings + next[keyPath: keyPath] = newValue + Task { + await state.updateDesktopSettings( + MobileSettingsUpdatePayload(autoPreviewSettings: next) + ) + } + } + ) + } + + private func applyModelDraft() { + let trimmed = modelDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + Task { await state.updateDesktopSettings(MobileSettingsUpdatePayload(selectedModel: trimmed)) } + } + + private func applyACPClientDraft() { + let trimmed = acpClientDraft.trimmingCharacters(in: .whitespacesAndNewlines) + Task { await state.updateDesktopSettings(MobileSettingsUpdatePayload(selectedACPClientId: trimmed)) } + } + + private func effortDisplayName(_ effort: String) -> String { + switch effort { + case "low": return "Low" + case "medium": return "Medium" + case "high": return "High" + case "xhigh": return "Extra High" + case "max": return "Max" + default: return effort.capitalized } } diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift index c118d0d0..d28346ef 100644 --- a/RxCodeMobile/Views/OnboardingView.swift +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -52,12 +52,14 @@ struct OnboardingView: View { titleVisibility: .visible ) { Button { + MobileHaptics.buttonTap() showScanner = true } label: { Label("Scan with Camera", systemImage: "camera.viewfinder") } Button { + MobileHaptics.buttonTap() // Trigger photo picker by toggling state in next runloop tick // so the dialog can dismiss first. DispatchQueue.main.async { presentPhotoPicker() } @@ -85,6 +87,7 @@ struct OnboardingView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button { + MobileHaptics.buttonTap() showScanner = false } label: { Image(systemName: "xmark") @@ -204,6 +207,7 @@ struct OnboardingView: View { private var scanButton: some View { Button { + MobileHaptics.buttonTap() showScanOptions = true } label: { Label("Scan QR Code", systemImage: "qrcode.viewfinder") @@ -225,6 +229,7 @@ struct OnboardingView: View { .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) Button { + MobileHaptics.buttonTap() pairingError = nil state.dismissPairingError() } label: { @@ -261,6 +266,7 @@ struct OnboardingView: View { .multilineTextAlignment(.center) } Button { + MobileHaptics.buttonTap() onboardingLogger.info("user cancelled pairing") state.cancelPairing() } label: { @@ -282,10 +288,12 @@ struct OnboardingView: View { // MARK: - Scan handling private func handleScan(_ value: String) { + MobileHaptics.qrScanned() onboardingLogger.info("handleScan received payload length=\(value.count, privacy: .public)") do { let token = try PairingToken.parse(value) onboardingLogger.info("parsed token expired=\(token.isExpired, privacy: .public)") + onboardingLogger.info("parsed token relayURL=\(token.relayURL, privacy: .public)") guard !token.isExpired else { pairingError = "This QR has expired. Generate a new one on your Mac." return diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 35d4b4bf..e5a4fc64 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -4,16 +4,30 @@ import RxCodeCore struct ProjectsSidebar: View { @EnvironmentObject private var state: MobileAppState @Binding var selected: UUID? + @Binding var showingBriefing: Bool var body: some View { - List(state.projects, selection: $selected) { project in - NavigationLink(value: project.id) { - VStack(alignment: .leading, spacing: 2) { - Text(project.name).font(.headline) - Text(project.path) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + List(selection: $selected) { + Button { + selected = nil + showingBriefing = true + } label: { + Label("Briefing", systemImage: "doc.text") + .font(.headline) + .foregroundStyle(showingBriefing ? Color.accentColor : Color.primary) + } + + Section("Projects") { + ForEach(state.projects) { project in + NavigationLink(value: project.id) { + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.headline) + Text(project.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } } } } diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 6bfa42b4..84f8f645 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -7,6 +7,7 @@ struct RootView: View { @EnvironmentObject private var state: MobileAppState @State private var selectedProject: UUID? @State private var selectedSession: String? + @State private var showingBriefing = true @State private var showSettings = false var body: some View { @@ -25,7 +26,7 @@ struct RootView: View { private var paired: some View { NavigationSplitView { - ProjectsSidebar(selected: $selectedProject) + ProjectsSidebar(selected: $selectedProject, showingBriefing: $showingBriefing) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showSettings = true } label: { @@ -34,14 +35,16 @@ struct RootView: View { } } } content: { - if let projectID = selectedProject { + if showingBriefing { + MobileBriefingView() + } else if let projectID = selectedProject { SessionsList(projectID: projectID, selected: $selectedSession) } else { Text("Select a project") .foregroundStyle(.secondary) } } detail: { - if let sessionID = selectedSession { + if !showingBriefing, let sessionID = selectedSession { MobileChatView(sessionID: sessionID) .id(sessionID) } else { @@ -57,5 +60,10 @@ struct RootView: View { .onChange(of: selectedSession) { _, newValue in Task { await state.subscribe(to: newValue) } } + .onChange(of: selectedProject) { _, newValue in + if newValue != nil { + showingBriefing = false + } + } } } diff --git a/website/app/.well-known/apple-app-site-association/route.ts b/website/app/.well-known/apple-app-site-association/route.ts new file mode 100644 index 00000000..739d1140 --- /dev/null +++ b/website/app/.well-known/apple-app-site-association/route.ts @@ -0,0 +1,19 @@ +const ASSOCIATION = { + applinks: { + apps: [], + details: [ + { + appID: "T7GYB573Y6.app.rxlab.rxcodemobile", + paths: ["/pair", "/pair/*"], + }, + ], + }, +}; + +export function GET() { + return Response.json(ASSOCIATION, { + headers: { + "content-type": "application/json", + }, + }); +} diff --git a/website/app/pair/page.tsx b/website/app/pair/page.tsx new file mode 100644 index 00000000..f73ff659 --- /dev/null +++ b/website/app/pair/page.tsx @@ -0,0 +1,105 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://code.rxlab.app"; +const MOBILE_APP_STORE_ID = + process.env.RXCODE_MOBILE_APP_STORE_ID ?? + process.env.NEXT_PUBLIC_RXCODE_MOBILE_APP_STORE_ID; + +type PairPageProps = { + searchParams: Promise>; +}; + +export async function generateMetadata({ + searchParams, +}: PairPageProps): Promise { + const params = await searchParams; + const pairURL = pairURLFromSearchParams(params); + + return { + title: "Pair RxCode Mobile", + description: "Open RxCode Mobile to pair this device with your Mac.", + robots: { + index: false, + follow: false, + }, + itunes: MOBILE_APP_STORE_ID + ? { + appId: MOBILE_APP_STORE_ID, + appArgument: pairURL, + } + : undefined, + }; +} + +export default async function PairPage({ searchParams }: PairPageProps) { + const params = await searchParams; + const pairURL = pairURLFromSearchParams(params); + const relayURL = first(params.relay); + + return ( +
+
+ + RxCode + + +
+

+ Pairing link +

+

+ Open RxCode Mobile +

+

+ If RxCode Mobile is installed, this link opens the app and starts + the existing pairing flow. If it is not installed, Safari can show + the App Store banner for RxCode Mobile. +

+ + {relayURL ? ( +
+

+ Relay URL +

+

+ {relayURL} +

+
+ ) : null} + + + Open RxCode Mobile + +
+
+
+ ); +} + +function pairURLFromSearchParams( + params: Record +) { + const url = new URL("/pair", SITE_URL); + const token = first(params.token); + const relay = first(params.relay); + + if (token) { + url.searchParams.set("token", token); + } + if (relay) { + url.searchParams.set("relay", relay); + } + + return url.toString(); +} + +function first(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} From a5b09d56089409f23dc79a3f72bd478970548acf Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 21:02:05 +0800 Subject: [PATCH 4/8] feat(git): refactor RxCode/Services/MobileSyncService.swift, RxCode/Views/Inspector/RightInspectorPanel.swift, RxCode/Views/Settings/MobileSettingsTab.swift, RxCodeMobile/AppDelegate.swift, RxCodeMobile/State/MobileAppState.swift, RxCodeMobileNotificationService/NotificationService.swift - modernize the service structures - improve code readability and maintainability - streamline notifications handling --- RxCode/Services/MobileSyncService.swift | 144 ++++++++++++++++++ .../Views/Inspector/RightInspectorPanel.swift | 80 +++++++++- RxCode/Views/Settings/MobileSettingsTab.swift | 64 ++++++++ RxCodeMobile/AppDelegate.swift | 13 +- RxCodeMobile/State/MobileAppState.swift | 27 +++- .../NotificationService.swift | 69 +++++++-- relay-server/push.go | 21 ++- 7 files changed, 392 insertions(+), 26 deletions(-) diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 0b4f1ae8..b4806d6f 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -28,6 +28,29 @@ enum PairingOutcome: Sendable { case cancelled } +enum MobilePushError: LocalizedError { + case missingDeviceToken + case unknownPeer + case invalidRelayURL + case relayRejected(status: Int, body: String) + case apnsRejected(status: Int, reason: String) + + var errorDescription: String? { + switch self { + case .missingDeviceToken: + "This device has not registered a push token yet." + case .unknownPeer: + "This device is not available in the encrypted peer list." + case .invalidRelayURL: + "The relay URL cannot be converted to a push endpoint." + case .relayRejected(let status, let body): + "Relay rejected the push request (\(status)): \(body)" + case .apnsRejected(let status, let reason): + "APNs rejected the notification (\(status)): \(reason)" + } + } +} + /// Bridges the desktop app to paired mobile devices over the E2E-encrypted /// relay channel. Owns the long-term `DeviceIdentity`, the `SyncClient`, and /// the persistent paired-device list. @@ -206,6 +229,61 @@ final class MobileSyncService: ObservableObject { } } + /// Send one APNs-backed test notification to a paired device. + func sendTestNotification(to device: PairedDevice) async throws { + guard let token = device.apnsToken, !token.isEmpty else { + throw MobilePushError.missingDeviceToken + } + guard let peer = await client.peer(forHex: device.pubkeyHex) else { + throw MobilePushError.unknownPeer + } + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + throw MobilePushError.invalidRelayURL + } + + let plaintext = AlertPlaintext( + title: "RxCode test notification", + body: "Notifications are working for \(device.displayName).", + kind: NotificationPayload.Kind.generic.rawValue + ) + let encrypted = try APNsCrypto.seal( + plaintext: plaintext, + sender: identity.privateKey, + recipient: peer + ) + let encryptedAlertData = try JSONEncoder().encode(encrypted) + let body = APNsPushRequest( + deviceToken: token, + encryptedAlert: encryptedAlertData.base64EncodedString(), + category: "test_notification", + collapseID: Self.testNotificationCollapseID(for: device) + ) + + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MobilePushError.relayRejected(status: -1, body: "No HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw MobilePushError.relayRejected( + status: http.statusCode, + body: Self.responseBodyString(data) + ) + } + + let pushResponse = try JSONDecoder().decode(APNsPushResponse.self, from: data) + guard (200..<300).contains(pushResponse.statusCode) else { + throw MobilePushError.apnsRejected( + status: pushResponse.statusCode, + reason: pushResponse.reason + ) + } + } + // MARK: - Streaming hooks /// Called by AppState's streaming loop after each StreamEvent is folded @@ -344,6 +422,72 @@ final class MobileSyncService: ObservableObject { logger.error("save paired_devices.json: \(error.localizedDescription)") } } + + private static func pushEndpointURL(from relayURL: URL) -> URL? { + guard var components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) else { + return nil + } + switch components.scheme?.lowercased() { + case "ws": + components.scheme = "http" + case "wss": + components.scheme = "https" + case "http", "https": + break + default: + return nil + } + + var path = components.path + if path.isEmpty || path == "/" { + path = "/push" + } else { + let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let base = trimmed.split(separator: "/").last == "ws" + ? trimmed.split(separator: "/").dropLast().joined(separator: "/") + : trimmed + path = "/" + ([base, "push"].filter { !$0.isEmpty }.joined(separator: "/")) + } + components.path = path + components.queryItems = nil + return components.url + } + + private static func responseBodyString(_ data: Data) -> String { + let raw = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? "Empty response" : raw + } + + private static func testNotificationCollapseID(for device: PairedDevice) -> String { + "rxcode-test-\(device.pubkeyHex.prefix(32))" + } +} + +private struct APNsPushRequest: Codable { + let deviceToken: String + let encryptedAlert: String + let category: String? + let collapseID: String? + + enum CodingKeys: String, CodingKey { + case deviceToken = "device_token" + case encryptedAlert = "encrypted_alert" + case category + case collapseID = "collapse_id" + } +} + +private struct APNsPushResponse: Codable { + let statusCode: Int + let reason: String + let apnsID: String? + + enum CodingKeys: String, CodingKey { + case statusCode = "status_code" + case reason + case apnsID = "apns_id" + } } extension Notification.Name { diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index 5f45b5b6..65751386 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import RxCodeCore @@ -1052,9 +1053,17 @@ private struct ChangeSection: View { ChangeFileRow( file: file, isSelected: selection.contains(file.displayPath), - onToggle: { + onPrimarySelect: { onFocus() - toggle(file) + selectOnly(file) + }, + onSecondarySelect: { isCommandPressed in + onFocus() + if isCommandPressed { + toggle(file) + } else { + selectOnly(file) + } } ) } @@ -1115,6 +1124,10 @@ private struct ChangeSection: View { selection.insert(file.displayPath) } } + + private func selectOnly(_ file: GitChangeFile) { + selection = [file.displayPath] + } } // MARK: - ChangeFileRow @@ -1122,7 +1135,8 @@ private struct ChangeSection: View { private struct ChangeFileRow: View { let file: GitChangeFile let isSelected: Bool - let onToggle: () -> Void + let onPrimarySelect: () -> Void + let onSecondarySelect: (_ isCommandPressed: Bool) -> Void @Environment(WindowState.self) private var windowState @State private var isHovering = false @@ -1169,12 +1183,16 @@ private struct ChangeFileRow: View { .fill(rowFill) ) .contentShape(Rectangle()) - .onTapGesture { onToggle() } - .contextMenu { - Button("Show Diff") { openDiff() } + .onTapGesture { onPrimarySelect() } + .overlay { + ChangeFileRightClickOverlay( + onRightClick: onSecondarySelect, + onShowDiff: openDiff + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .onHover { isHovering = $0 } - .help("Click to select · Right-click for Show Diff") + .help("Click to select · Command-right-click to add or remove · Right-click for Show Diff") } private func openDiff() { @@ -1219,6 +1237,54 @@ private struct ChangeFileRow: View { } } +private struct ChangeFileRightClickOverlay: NSViewRepresentable { + let onRightClick: (_ isCommandPressed: Bool) -> Void + let onShowDiff: () -> Void + + func makeNSView(context: Context) -> RightClickSelectionView { + let view = RightClickSelectionView() + view.onRightClick = onRightClick + view.onShowDiff = onShowDiff + return view + } + + func updateNSView(_ nsView: RightClickSelectionView, context: Context) { + nsView.onRightClick = onRightClick + nsView.onShowDiff = onShowDiff + } +} + +private final class RightClickSelectionView: NSView { + var onRightClick: ((_ isCommandPressed: Bool) -> Void)? + var onShowDiff: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let event = window?.currentEvent, + event.type == .rightMouseDown else { + return nil + } + return self + } + + override func rightMouseDown(with event: NSEvent) { + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + onRightClick?(modifiers.contains(.command)) + showMenu(for: event) + } + + private func showMenu(for event: NSEvent) { + let menu = NSMenu() + let showDiffItem = NSMenuItem(title: "Show Diff", action: #selector(showDiff), keyEquivalent: "") + showDiffItem.target = self + menu.addItem(showDiffItem) + menu.popUp(positioning: showDiffItem, at: convert(event.locationInWindow, from: nil), in: self) + } + + @objc private func showDiff() { + onShowDiff?() + } +} + // MARK: - Loading struct GitChangeFile: Identifiable, Hashable { diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 24f8864d..0408b209 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -9,6 +9,8 @@ struct MobileSettingsTab: View { @StateObject private var sync = MobileSyncService.shared @State private var showPairingSheet = false @State private var relayURLText: String = MobileSyncService.shared.relayURL.absoluteString + @State private var testNotificationDeviceID: String? + @State private var testNotificationAlert: TestNotificationAlert? var body: some View { ScrollView { @@ -23,6 +25,13 @@ struct MobileSettingsTab: View { .sheet(isPresented: $showPairingSheet) { PairingSheet(sync: sync) } + .alert(item: $testNotificationAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) + } } private var headerSection: some View { @@ -126,6 +135,7 @@ struct MobileSettingsTab: View { } } Spacer() + testNotificationButton(for: device) Button(role: .destructive) { Task { await sync.unpair(device) } } label: { @@ -136,6 +146,60 @@ struct MobileSettingsTab: View { .padding(12) .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) } + + @ViewBuilder + private func testNotificationButton(for device: PairedDevice) -> some View { + if testNotificationDeviceID == device.id { + ProgressView() + .controlSize(.small) + .frame(width: 28, height: 28) + } else { + Button { + sendTestNotification(to: device) + } label: { + Label("Send test notification", systemImage: "bell.badge") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(device.apnsToken?.isEmpty ?? true || sync.connectionState != .connected) + .help(testNotificationHelp(for: device)) + } + } + + private func sendTestNotification(to device: PairedDevice) { + testNotificationDeviceID = device.id + Task { @MainActor in + defer { testNotificationDeviceID = nil } + do { + try await sync.sendTestNotification(to: device) + testNotificationAlert = TestNotificationAlert( + title: "Test notification sent", + message: "Check \(device.displayName) for the RxCode notification." + ) + } catch { + testNotificationAlert = TestNotificationAlert( + title: "Test notification failed", + message: error.localizedDescription + ) + } + } + } + + private func testNotificationHelp(for device: PairedDevice) -> String { + if device.apnsToken?.isEmpty ?? true { + return "Open RxCode Mobile on this device once so it can register for push notifications." + } + if sync.connectionState != .connected { + return "Connect to the relay before sending a test notification." + } + return "Send a push notification to \(device.displayName)." + } +} + +private struct TestNotificationAlert: Identifiable { + let id = UUID() + let title: String + let message: String } // MARK: - Pairing sheet diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 09fb6fd2..9f4f3aa2 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -1,21 +1,27 @@ import UIKit import UserNotifications +import os.log /// Bridges UIKit's APNs registration into `MobileAppState`. The state object /// forwards the device token to the paired desktop over the encrypted relay. final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { weak var mobileState: MobileAppState? + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "AppDelegate") func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self + logger.info("[APNs] notification delegate installed") Task { let granted = (try? await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .badge, .sound])) ?? false + self.logger.info("[APNs] authorization granted=\(granted, privacy: .public)") if granted { await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } + } else { + self.logger.warning("[APNs] authorization denied; remote notification registration skipped") } } return true @@ -29,16 +35,19 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent #else let environment = "production" #endif + logger.info("[APNs] registered tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(environment, privacy: .public)") mobileState?.reportAPNsToken(hex: tokenHex, environment: environment) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - // Best-effort only; relay channel still works. + logger.error("[APNs] registration failed: \(error.localizedDescription, privacy: .public)") } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { - [.banner, .sound, .list] + let content = notification.request.content + logger.info("[APNs] willPresent title=\(content.title, privacy: .public) body=\(content.body, privacy: .public) category=\(content.categoryIdentifier, privacy: .public) keys=\(content.userInfo.keys.map { "\($0)" }.sorted().joined(separator: ","), privacy: .public)") + return [.banner, .sound, .list] } } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index e5ca99a4..f417b994 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -58,9 +58,12 @@ final class MobileAppState: ObservableObject { accessGroup: Self.keychainAccessGroup ) } catch { + Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") + .error("[MobileIdentity] load failed accessGroup=\(Self.keychainAccessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") fatalError("Failed to load mobile device identity: \(error)") } self.client = SyncClient(identity: identity, relayURL: initial) + logger.info("[MobileIdentity] loaded publicKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public) accessGroup=\(Self.keychainAccessGroup, privacy: .public)") loadPairedDesktop() } @@ -261,6 +264,7 @@ final class MobileAppState: ObservableObject { } func reportAPNsToken(hex: String, environment: String) { + logger.info("[APNs] received token from app delegate tokenPrefix=\(String(hex.prefix(12)), privacy: .public) environment=\(environment, privacy: .public)") apnsTokenHex = hex apnsEnvironment = environment Task { await reportAPNsTokenIfPending() } @@ -324,6 +328,7 @@ final class MobileAppState: ObservableObject { pairedDesktopName = ack.desktopName isPaired = true pairingStatus = .idle + logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") MobileHaptics.connected() savePairedDesktop() Task { @@ -418,11 +423,25 @@ final class MobileAppState: ObservableObject { } private func reportAPNsTokenIfPending() async { - guard isPaired, - let tokenHex = apnsTokenHex, - let env = apnsEnvironment else { return } + guard isPaired else { + logger.info("[APNs] token report pending: mobile is not paired") + return + } + guard let tokenHex = apnsTokenHex else { + logger.info("[APNs] token report pending: no APNs token yet") + return + } + guard let env = apnsEnvironment else { + logger.info("[APNs] token report pending: no APNs environment yet") + return + } let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) - try? await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) + do { + try await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) + logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(pairedDesktopPubkey.prefix(12)), privacy: .public)") + } catch { + logger.error("[APNs] token report failed desktopKey=\(String(pairedDesktopPubkey.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } } private func applySettingsUpdateLocally(_ update: MobileSettingsUpdatePayload) { diff --git a/RxCodeMobileNotificationService/NotificationService.swift b/RxCodeMobileNotificationService/NotificationService.swift index b93a8871..d682fc2a 100644 --- a/RxCodeMobileNotificationService/NotificationService.swift +++ b/RxCodeMobileNotificationService/NotificationService.swift @@ -1,6 +1,7 @@ import UserNotifications import CryptoKit import RxCodeSync +import os.log /// Notification Service Extension: decrypts the `enc` payload that the desktop /// sealed for this device and rewrites the visible alert before iOS displays @@ -9,27 +10,69 @@ import RxCodeSync final class NotificationService: UNNotificationServiceExtension { private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttempt: UNMutableNotificationContent? + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "NotificationService") override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler self.bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent) guard let attempt = bestAttempt else { + logger.error("[NSE] unable to copy notification content request=\(request.identifier, privacy: .public)") contentHandler(request.content); return } - guard let encB64 = request.content.userInfo["enc"] as? String, - let raw = Data(base64Encoded: encB64), - let envelope = try? JSONDecoder().decode(EncryptedAlert.self, from: raw), - let identity = try? DeviceIdentity.loadOrCreate( - accessGroup: DeviceIdentity.resolveAccessGroup( - suffix: "app.rxlab.rxcodemobile.shared" - ) - ), - let senderRaw = Data(hexString: envelope.from), - let senderKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: senderRaw), - let plaintext = try? APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) - else { + logger.info("[NSE] received request=\(request.identifier, privacy: .public) category=\(request.content.categoryIdentifier, privacy: .public) userInfoKeys=\(request.content.userInfo.keys.map { "\($0)" }.sorted().joined(separator: ","), privacy: .public)") + + guard let encB64 = request.content.userInfo["enc"] as? String else { + logger.error("[NSE] missing enc payload request=\(request.identifier, privacy: .public)") + contentHandler(attempt) + return + } + guard let raw = Data(base64Encoded: encB64) else { + logger.error("[NSE] enc payload is not valid base64 request=\(request.identifier, privacy: .public) length=\(encB64.count, privacy: .public)") + contentHandler(attempt) + return + } + let envelope: EncryptedAlert + do { + envelope = try JSONDecoder().decode(EncryptedAlert.self, from: raw) + } catch { + logger.error("[NSE] encrypted alert decode failed request=\(request.identifier, privacy: .public): \(error.localizedDescription, privacy: .public)") + contentHandler(attempt) + return + } + let accessGroup = DeviceIdentity.resolveAccessGroup( + suffix: "app.rxlab.rxcodemobile.shared" + ) + let identity: DeviceIdentity + do { + identity = try DeviceIdentity.loadOrCreate( + accessGroup: accessGroup + ) + logger.info("[NSE] loaded identity accessGroup=\(accessGroup, privacy: .public) publicKey=\(String(identity.publicKeyHex.prefix(12)), privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public)") + } catch { + logger.error("[NSE] identity load failed accessGroup=\(accessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") + contentHandler(attempt) + return + } + guard let senderRaw = Data(hexString: envelope.from) else { + logger.error("[NSE] sender public key is not hex sender=\(String(envelope.from.prefix(12)), privacy: .public)") + contentHandler(attempt) + return + } + let senderKey: Curve25519.KeyAgreement.PublicKey + do { + senderKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: senderRaw) + } catch { + logger.error("[NSE] sender public key parse failed sender=\(String(envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + contentHandler(attempt) + return + } + let plaintext: AlertPlaintext + do { + plaintext = try APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) + } catch { + logger.error("[NSE] decrypt failed request=\(request.identifier, privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public) identity=\(String(identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") contentHandler(attempt) return } @@ -41,11 +84,13 @@ final class NotificationService: UNNotificationServiceExtension { if let projectID = plaintext.projectID { userInfo["projectId"] = projectID.uuidString } if let kind = plaintext.kind { userInfo["kind"] = kind } attempt.userInfo = userInfo + logger.info("[NSE] decrypted notification request=\(request.identifier, privacy: .public) kind=\(plaintext.kind ?? "", privacy: .public) sessionID=\(plaintext.sessionID ?? "", privacy: .public)") contentHandler(attempt) } override func serviceExtensionTimeWillExpire() { if let handler = contentHandler, let attempt = bestAttempt { + logger.error("[NSE] service extension time will expire; returning best attempt title=\(attempt.title, privacy: .public)") handler(attempt) } } diff --git a/relay-server/push.go b/relay-server/push.go index bfbe8e6d..e39c034c 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -134,10 +134,29 @@ func pushHandler(sender *PushSender) http.HandlerFunc { res, err := sender.client.Push(notif) if err != nil { - log.Printf("apns push error: %v", err) + log.Printf( + "apns push transport error: %v device=%s category=%q collapse_id=%q collapse_len=%d", + err, + short(req.DeviceToken), + req.Category, + req.CollapseID, + len(req.CollapseID), + ) http.Error(w, "apns push failed", http.StatusBadGateway) return } + if !res.Sent() { + log.Printf( + "apns push rejected: status=%d reason=%q apns_id=%s device=%s category=%q collapse_id=%q collapse_len=%d", + res.StatusCode, + res.Reason, + res.ApnsID, + short(req.DeviceToken), + req.Category, + req.CollapseID, + len(req.CollapseID), + ) + } resp := map[string]any{ "status_code": res.StatusCode, "reason": res.Reason, From 8417d41cfe121f83ac483eb8e569110760c5edfe Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 22:01:04 +0800 Subject: [PATCH 5/8] fix: list rendering --- .../RxCodeChatKit/ChatMessageBubble.swift | 215 +++++++++++++ .../RxCodeChatKit/ChatMessageGrouping.swift | 119 +++++++ .../RxCodeChatKit/ChatMessageListView.swift | 92 ++++++ .../RxCodeChatKit/ChatTextContentView.swift | 167 ++++++++++ .../Sources/RxCodeChatKit/MarkdownView.swift | 150 ++++++++- .../Sources/RxCodeChatKit/MessageBubble.swift | 58 ++-- .../RxCodeChatKit/MessageListView.swift | 295 +++--------------- .../RxCodeChatKit/ToolResultView.swift | 76 +++-- .../Sources/RxCodeSync/Protocol/Payload.swift | 55 +++- RxCode.xcodeproj/project.pbxproj | 174 +++++++++++ RxCode/App/AppState.swift | 244 ++++++++++++++- RxCode/Services/MobileSyncService.swift | 47 ++- RxCodeMobile/AppDelegate.swift | 116 ++++++- RxCodeMobile/State/MobileAppState.swift | 112 ++++++- RxCodeMobile/Views/MobileBriefingView.swift | 75 +++-- RxCodeMobile/Views/MobileChatView.swift | 33 +- .../Views/MobileKeyboardDismissModifier.swift | 9 + RxCodeMobile/Views/MobileMessageBubble.swift | 60 ---- RxCodeMobile/Views/MobileSettingsView.swift | 14 +- RxCodeMobile/Views/ProjectsSidebar.swift | 49 ++- RxCodeMobile/Views/RootView.swift | 171 ++++++++-- RxCodeMobile/Views/SessionsList.swift | 140 +++++++-- RxCodeMobileNotificationService/Info.plist | 2 +- .../NotificationService.swift | 20 +- .../RxCodeMobile/Views/MobileChatView.swift | 13 +- .../Views/MobileMessageBubble.swift | 60 ---- 26 files changed, 2012 insertions(+), 554 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift create mode 100644 Packages/Sources/RxCodeChatKit/ChatMessageGrouping.swift create mode 100644 Packages/Sources/RxCodeChatKit/ChatMessageListView.swift create mode 100644 Packages/Sources/RxCodeChatKit/ChatTextContentView.swift create mode 100644 RxCodeMobile/Views/MobileKeyboardDismissModifier.swift delete mode 100644 RxCodeMobile/Views/MobileMessageBubble.swift delete mode 100644 RxCodeTests/RxCodeMobile/Views/MobileMessageBubble.swift diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift new file mode 100644 index 00000000..3b67b6ef --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift @@ -0,0 +1,215 @@ +import SwiftUI +import RxCodeCore + +public struct ChatMessageBubble: View { + let message: ChatMessage + + public init(message: ChatMessage) { + self.message = message + } + + public var body: some View { + #if os(macOS) + MessageBubble(message: message) + #else + CompactChatMessageBubble(message: message) + #endif + } +} + +#if !os(macOS) +private struct CompactChatMessageBubble: View { + let message: ChatMessage + + private enum RenderBlock: Identifiable { + case text(MessageBlock) + case tool(ToolCall) + case transientTools(id: String, tools: [ToolCall]) + + var id: String { + switch self { + case .text(let block): + return block.id + case .tool(let toolCall): + return toolCall.id + case .transientTools(let id, _): + return id + } + } + } + + var body: some View { + HStack(alignment: .top) { + if message.role == .user { Spacer(minLength: 40) } + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 8) { + if message.role == .user { + let displayed = ChatSession.extractDisplayedContent(from: message.content) + if !displayed.text.isEmpty { + userTextBubble(displayed.text) + } + } else if message.isCompactBoundary { + compactBoundaryBubble + } else if message.isError { + errorBubble + } else { + ForEach(renderBlocks) { block in + blockView(block) + } + + if !message.isStreaming, let duration = message.duration { + HStack(spacing: 4) { + if message.isResponseComplete { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: ClaudeTheme.messageSize(11))) + .foregroundStyle(ClaudeTheme.statusSuccess) + } + Text(duration.formattedDuration) + .font(.system(size: ClaudeTheme.messageSize(11), design: .monospaced)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + } + } + if message.role == .assistant { Spacer(minLength: 40) } + } + } + + @ViewBuilder + private func blockView(_ block: RenderBlock) -> some View { + switch block { + case .text(let messageBlock): + if let text = messageBlock.text, !text.isEmpty { + assistantText(text) + } + case .tool(let toolCall): + ToolResultView(toolCall: toolCall, isMessageStreaming: message.isStreaming) + case .transientTools(_, let tools): + ChatTransientToolSummaryView(tools: tools) + } + } + + private func userTextBubble(_ text: String) -> some View { + ChatTextContentView( + text, + size: ClaudeTheme.messageSize(14), + color: ClaudeTheme.userBubbleText, + lineSpacing: 2 + ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .bubbleStyle(.user) + .frame(maxWidth: 500, alignment: .trailing) + } + + private func assistantText(_ text: String) -> some View { + ChatTextContentView( + markdown: text, + size: ClaudeTheme.messageSize(14), + color: ClaudeTheme.textPrimary, + lineSpacing: 2 + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + } + + private var compactBoundaryBubble: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.trianglehead.2.clockwise") + .font(.system(size: ClaudeTheme.messageSize(12), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + ChatTextContentView( + message.content, + size: ClaudeTheme.messageSize(13), + weight: .medium, + color: ClaudeTheme.textTertiary + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(ClaudeTheme.border, lineWidth: BubbleStyle.borderWidth) + ) + } + + private var errorBubble: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: ClaudeTheme.messageSize(13))) + .foregroundStyle(ClaudeTheme.statusWarning) + ChatTextContentView( + message.content, + size: ClaudeTheme.messageSize(14), + color: ClaudeTheme.textPrimary + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .bubbleStyle(.error) + } + + private var renderBlocks: [RenderBlock] { + var result: [RenderBlock] = [] + var pendingTransientTools: [ToolCall] = [] + var pendingTransientGroupStartId: String? + + func appendText(_ block: MessageBlock) { + guard let text = block.text, !text.isEmpty else { return } + if let lastIndex = result.indices.last, + case .text(let previousBlock) = result[lastIndex] { + let previous = previousBlock.text ?? "" + let needsSpace = !(previous.last?.isWhitespace ?? true) && !(text.first?.isWhitespace ?? true) + let joined = needsSpace ? previous + " " + text : previous + text + result[lastIndex] = .text(.text(joined, id: previousBlock.id)) + } else { + result.append(.text(block)) + } + } + + func flushTransientTools() { + guard !pendingTransientTools.isEmpty else { return } + let startId = pendingTransientGroupStartId ?? pendingTransientTools[0].id + result.append(.transientTools( + id: "transient-tools-\(startId)-\(pendingTransientTools.count)", + tools: pendingTransientTools + )) + pendingTransientTools = [] + pendingTransientGroupStartId = nil + } + + for block in message.blocks { + if block.isText { + flushTransientTools() + appendText(block) + continue + } + + guard let toolCall = block.toolCall else { continue } + if message.isStreaming { + flushTransientTools() + result.append(.tool(toolCall)) + continue + } + + if ToolCategory(toolName: toolCall.name).isTransient { + guard toolCall.result != nil || toolCall.isError else { continue } + if pendingTransientGroupStartId == nil { + pendingTransientGroupStartId = toolCall.id + } + pendingTransientTools.append(toolCall) + } else if toolCall.isKeepAlways || toolCall.result != nil || toolCall.isError { + flushTransientTools() + result.append(.tool(toolCall)) + } + } + + flushTransientTools() + return result + } +} +#endif diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageGrouping.swift b/Packages/Sources/RxCodeChatKit/ChatMessageGrouping.swift new file mode 100644 index 00000000..b85d5240 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatMessageGrouping.swift @@ -0,0 +1,119 @@ +import Foundation +import RxCodeCore + +struct ChatMessageGroup: Identifiable { + let id: UUID + let messages: [ChatMessage] + let isTransientGroup: Bool +} + +func chatPartitionByStreaming(_ messages: [ChatMessage]) -> (settled: [ChatMessage], streaming: [ChatMessage]) { + var settled: [ChatMessage] = [] + var streaming: [ChatMessage] = [] + for message in messages { + if message.isStreaming { + streaming.append(message) + } else { + settled.append(message) + } + } + return (settled, streaming) +} + +func chatStreamingBoundaryIndex(in messages: [ChatMessage]) -> Int { + var index = messages.count - 1 + while index >= 0 && messages[index].role == .assistant && !messages[index].isError { + index -= 1 + } + return index + 1 +} + +func chatSuppressPlanReadyFollowups(in messages: [ChatMessage]) -> [ChatMessage] { + var result: [ChatMessage] = [] + var assistantRun: [ChatMessage] = [] + + func flushAssistantRun() { + guard !assistantRun.isEmpty else { return } + let hasExitPlan = assistantRun.contains { PlanLogic.containsExitPlanMode($0) } + var hasRecentPlanCard = false + + for message in assistantRun { + if hasExitPlan && PlanLogic.isPurePlanFileWriteMessage(message) { + continue + } + + _ = hasRecentPlanCard + + if PlanLogic.containsExitPlanMode(message) { + hasRecentPlanCard = true + } + result.append(message) + } + + assistantRun.removeAll(keepingCapacity: true) + } + + for message in messages { + if message.role == .user { + flushAssistantRun() + result.append(message) + continue + } + + assistantRun.append(message) + } + flushAssistantRun() + + return result +} + +func chatMessageGroups(_ messages: [ChatMessage], minGroupSize: Int = 2) -> [ChatMessageGroup] { + var result: [ChatMessageGroup] = [] + var accumulator: [ChatMessage] = [] + + func flushAccumulator() { + guard !accumulator.isEmpty else { return } + if accumulator.count >= minGroupSize { + result.append(ChatMessageGroup(id: accumulator[0].id, messages: accumulator, isTransientGroup: true)) + } else { + for message in accumulator { + result.append(ChatMessageGroup(id: message.id, messages: [message], isTransientGroup: false)) + } + } + accumulator = [] + } + + for message in messages { + if chatIsPureTransientMessage(message) { + accumulator.append(message) + } else if chatIsInvisibleMessage(message) { + continue + } else { + flushAccumulator() + result.append(ChatMessageGroup(id: message.id, messages: [message], isTransientGroup: false)) + } + } + flushAccumulator() + + return result +} + +private func chatIsPureTransientMessage(_ message: ChatMessage) -> Bool { + guard message.role == .assistant, !message.isError, !message.isCompactBoundary else { return false } + let hasVisibleText = message.blocks.contains { + guard let text = $0.text else { return false } + return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + if hasVisibleText { return false } + + let toolCalls = message.blocks.compactMap(\.toolCall) + guard !toolCalls.isEmpty else { return false } + + let hasNonTransient = toolCalls.contains { !ToolCategory(toolName: $0.name).isTransient } + return !hasNonTransient +} + +private func chatIsInvisibleMessage(_ message: ChatMessage) -> Bool { + guard message.role == .assistant, !message.isError, !message.isCompactBoundary, !message.isStreaming else { return false } + return message.blocks.isEmpty +} diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift new file mode 100644 index 00000000..06981633 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import RxCodeCore + +public struct ChatMessageListView: View { + private let messages: [ChatMessage] + private let transientGroupMinSize: Int + + public init(messages: [ChatMessage], transientGroupMinSize: Int = 2) { + self.messages = messages + self.transientGroupMinSize = transientGroupMinSize + } + + public var body: some View { + ForEach(chatMessageGroups(messages, minGroupSize: transientGroupMinSize)) { group in + if group.isTransientGroup { + ChatTransientGroupSummaryView(messages: group.messages) + .id(group.id) + .transition(messageFadeTransition(role: .assistant)) + .chatMessageListRowStyle() + } else if let message = group.messages.first { + ChatMessageBubble(message: message) + .id(message.id) + .transition(messageFadeTransition(role: message.role)) + .chatMessageListRowStyle() + } + } + } + + private func messageFadeTransition(role: Role) -> AnyTransition { + let anchor: UnitPoint = role == .user ? .bottomTrailing : .bottomLeading + return .opacity.combined(with: .scale(scale: 0.97, anchor: anchor)) + } +} + +extension View { + func chatMessageListRowStyle() -> some View { + listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 16, trailing: 20)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } +} + +struct ChatTransientGroupSummaryView: View { + let messages: [ChatMessage] + + private var allToolCalls: [ToolCall] { + messages.flatMap { $0.blocks.compactMap(\.toolCall) } + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + ChatTransientToolSummaryView(tools: allToolCalls) + Spacer(minLength: 40) + } + } +} + +struct ChatTransientToolSummaryView: View { + let tools: [ToolCall] + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "eye.slash") + .font(.system(size: ClaudeTheme.messageSize(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + Text(String(format: String(localized: "%lld tools executed", bundle: .module), tools.count)) + .font(.system(size: ClaudeTheme.messageSize(12))) + .foregroundStyle(ClaudeTheme.textTertiary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: ClaudeTheme.messageSize(9))) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + ForEach(tools, id: \.id) { toolCall in + ToolResultView(toolCall: toolCall, isMessageStreaming: false) + } + } + } + } +} diff --git a/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift b/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift new file mode 100644 index 00000000..1b407fd1 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift @@ -0,0 +1,167 @@ +import SwiftUI +import RxCodeCore +#if os(macOS) +import AppKit +#endif + +public struct ChatTextContentView: View { + enum Content { + case plain(String) + case attributed(AttributedString) + } + + let content: Content + var size: CGFloat + var weight: Font.Weight + var design: Font.Design + var color: Color + var lineSpacing: CGFloat + var maximumNumberOfLines: Int? + var openURL: ((URL) -> Bool)? + + public init( + _ text: String, + size: CGFloat = ClaudeTheme.messageSize(14), + weight: Font.Weight = .regular, + design: Font.Design = .default, + color: Color = ClaudeTheme.textPrimary, + lineSpacing: CGFloat = 0, + maximumNumberOfLines: Int? = nil, + openURL: ((URL) -> Bool)? = nil + ) { + self.content = .plain(text) + self.size = size + self.weight = weight + self.design = design + self.color = color + self.lineSpacing = lineSpacing + self.maximumNumberOfLines = maximumNumberOfLines + self.openURL = openURL + } + + public init( + attributed content: AttributedString, + size: CGFloat = ClaudeTheme.messageSize(14), + weight: Font.Weight = .regular, + design: Font.Design = .default, + color: Color = ClaudeTheme.textPrimary, + lineSpacing: CGFloat = 0, + maximumNumberOfLines: Int? = nil, + openURL: ((URL) -> Bool)? = nil + ) { + self.content = .attributed(content) + self.size = size + self.weight = weight + self.design = design + self.color = color + self.lineSpacing = lineSpacing + self.maximumNumberOfLines = maximumNumberOfLines + self.openURL = openURL + } + + public init( + markdown text: String, + size: CGFloat = ClaudeTheme.messageSize(14), + weight: Font.Weight = .regular, + design: Font.Design = .default, + color: Color = ClaudeTheme.textPrimary, + lineSpacing: CGFloat = 0, + maximumNumberOfLines: Int? = nil, + openURL: ((URL) -> Bool)? = nil + ) { + self.content = .attributed(Self.markdownAttributedString(text)) + self.size = size + self.weight = weight + self.design = design + self.color = color + self.lineSpacing = lineSpacing + self.maximumNumberOfLines = maximumNumberOfLines + self.openURL = openURL + } + + public var body: some View { + #if os(macOS) + nativeBody + #else + swiftUIBody + #endif + } + + private static func markdownAttributedString(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(text) + } + + @ViewBuilder + private var swiftUIBody: some View { + let text = { + switch content { + case .plain(let value): + return Text(value) + case .attributed(let value): + return Text(value) + } + }() + + text + .font(.system(size: size, weight: weight, design: design)) + .foregroundStyle(color) + .lineSpacing(lineSpacing) + .lineLimit(maximumNumberOfLines) + .textSelection(.enabled) + .environment(\.openURL, OpenURLAction { url in + guard let openURL else { return .systemAction } + return openURL(url) ? .handled : .systemAction + }) + } + + #if os(macOS) + @ViewBuilder + private var nativeBody: some View { + switch content { + case .plain(let text): + NativeChatTextView( + text, + font: nativeFont, + color: NSColor(color), + lineSpacing: lineSpacing, + maximumNumberOfLines: maximumNumberOfLines, + openURL: openURL + ) + case .attributed(let attributed): + NativeChatTextView( + attributed: attributed, + font: nativeFont, + color: NSColor(color), + lineSpacing: lineSpacing, + maximumNumberOfLines: maximumNumberOfLines, + openURL: openURL + ) + } + } + + private var nativeFont: NSFont { + if design == .monospaced { + return NSFont.monospacedSystemFont(ofSize: size, weight: nativeWeight) + } + return NSFont.systemFont(ofSize: size, weight: nativeWeight) + } + + private var nativeWeight: NSFont.Weight { + switch weight { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + default: return .regular + } + } + #endif +} diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 9aaeec1c..6e17d02b 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -545,6 +545,154 @@ private func parseInlineMarkdown(_ content: String) -> AttributedString { nonisolated(unsafe) private let inlineCodeBackgroundAttribute = NSAttributedString.Key("rxcode.inlineCodeBackground") +struct NativeChatTextView: NSViewRepresentable { + let content: Content + var font: NSFont + var color: NSColor + var lineSpacing: CGFloat + var maximumNumberOfLines: Int? + var openURL: ((URL) -> Bool)? + + enum Content: Equatable { + case plain(String) + case attributed(AttributedString) + } + + init( + _ text: String, + font: NSFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(14)), + color: NSColor = NSColor(ClaudeTheme.textPrimary), + lineSpacing: CGFloat = 0, + maximumNumberOfLines: Int? = nil, + openURL: ((URL) -> Bool)? = nil + ) { + self.content = .plain(text) + self.font = font + self.color = color + self.lineSpacing = lineSpacing + self.maximumNumberOfLines = maximumNumberOfLines + self.openURL = openURL + } + + init( + attributed content: AttributedString, + font: NSFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(14)), + color: NSColor = NSColor(ClaudeTheme.textPrimary), + lineSpacing: CGFloat = 0, + maximumNumberOfLines: Int? = nil, + openURL: ((URL) -> Bool)? = nil + ) { + self.content = .attributed(content) + self.font = font + self.color = color + self.lineSpacing = lineSpacing + self.maximumNumberOfLines = maximumNumberOfLines + self.openURL = openURL + } + + func makeCoordinator() -> Coordinator { + Coordinator(openURL: openURL) + } + + func makeNSView(context: Context) -> InlineCodeTextView { + let textView = InlineCodeTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.delegate = context.coordinator + textView.linkTextAttributes = [ + .foregroundColor: NSColor(ClaudeTheme.accent), + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return textView + } + + func updateNSView(_ textView: InlineCodeTextView, context: Context) { + context.coordinator.openURL = openURL + textView.textContainer?.maximumNumberOfLines = maximumNumberOfLines ?? 0 + textView.textContainer?.lineBreakMode = maximumNumberOfLines == nil ? .byWordWrapping : .byTruncatingTail + textView.textStorage?.setAttributedString(Self.nsAttributedString( + from: content, + font: font, + color: color, + lineSpacing: lineSpacing + )) + textView.invalidateIntrinsicContentSize() + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: InlineCodeTextView, context: Context) -> CGSize? { + let width = proposal.width ?? 500 + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return CGSize(width: width, height: 0) + } + textContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + return CGSize(width: width, height: ceil(usedRect.height)) + } + + private static func nsAttributedString( + from content: Content, + font: NSFont, + color: NSColor, + lineSpacing: CGFloat + ) -> NSAttributedString { + let result: NSMutableAttributedString + switch content { + case .plain(let text): + result = NSMutableAttributedString(string: text) + case .attributed(let attributed): + result = NSMutableAttributedString(attributed) + } + guard result.length > 0 else { return result } + + let fullRange = NSRange(location: 0, length: result.length) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + paragraphStyle.lineBreakMode = .byWordWrapping + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + result.enumerateAttribute(.font, in: fullRange) { value, range, _ in + guard value == nil else { return } + result.addAttribute(.font, value: font, range: range) + } + result.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in + guard value == nil else { return } + result.addAttribute(.foregroundColor, value: color, range: range) + } + return result + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var openURL: ((URL) -> Bool)? + + init(openURL: ((URL) -> Bool)?) { + self.openURL = openURL + } + + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + let url: URL? + if let linkedURL = link as? URL { + url = linkedURL + } else if let raw = link as? String { + url = URL(string: raw) + } else { + url = nil + } + guard let url else { return false } + return openURL?(url) ?? false + } + } +} + private struct MarkdownAttributedTextView: NSViewRepresentable { let content: AttributedString var showsTrailingCursor: Bool = false @@ -693,7 +841,7 @@ private final class InlineCodeLayoutManager: NSLayoutManager, @unchecked Sendabl } } -private final class InlineCodeTextView: NSTextView { +final class InlineCodeTextView: NSTextView { init() { let storage = NSTextStorage() let layoutManager = InlineCodeLayoutManager() diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index b63d7961..6e817932 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -143,9 +143,13 @@ struct MessageBubble: View { Image(systemName: "arrow.trianglehead.2.clockwise") .font(.system(size: ClaudeTheme.messageSize(12), weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) - Text(message.content) - .font(.system(size: ClaudeTheme.messageSize(13), weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) + ChatTextContentView( + message.content, + size: ClaudeTheme.messageSize(13), + weight: .medium, + color: ClaudeTheme.textTertiary + ) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity) .padding(.vertical, 10) @@ -167,10 +171,12 @@ struct MessageBubble: View { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: ClaudeTheme.messageSize(13))) .foregroundStyle(ClaudeTheme.statusWarning) - Text(message.content) - .font(.system(size: ClaudeTheme.messageSize(14))) - .foregroundStyle(ClaudeTheme.textPrimary) - .textSelection(.enabled) + ChatTextContentView( + message.content, + size: ClaudeTheme.messageSize(14), + color: ClaudeTheme.textPrimary + ) + .frame(maxWidth: .infinity, alignment: .leading) } .bubbleStyle(.error) } @@ -222,25 +228,25 @@ struct MessageBubble: View { } else { VStack(alignment: .leading, spacing: 6) { let isLong = displayText.count > Self.longTextThreshold - Text(chipifiedAttributedString(displayText)) - .font(.system(size: ClaudeTheme.messageSize(14))) - .foregroundStyle(ClaudeTheme.userBubbleText) - .textSelection(.enabled) - .multilineTextAlignment(.leading) - .lineLimit(isLong && !isLongTextExpanded ? 5 : nil) - .fixedSize(horizontal: false, vertical: true) - .environment(\.openURL, OpenURLAction { url in - // Intercept the synthetic `rxcode-image://` link - // emitted by chipifiedAttributedString — open the matching - // image in the preview sheet rather than the system browser. - guard url.scheme == "rxcode-image", - let index = Int(url.host ?? ""), - let path = imagePath(forChipIndex: index) else { - return .systemAction - } - previewImagePath = path - return .handled - }) + ChatTextContentView( + attributed: chipifiedAttributedString(displayText), + size: ClaudeTheme.messageSize(14), + color: ClaudeTheme.userBubbleText, + maximumNumberOfLines: isLong && !isLongTextExpanded ? 5 : nil + ) { url in + // Intercept the synthetic `rxcode-image://` link emitted + // by chipifiedAttributedString and open the matching image in the + // preview sheet rather than the system browser. + guard url.scheme == "rxcode-image", + let index = Int(url.host ?? ""), + let path = imagePath(forChipIndex: index) else { + return false + } + previewImagePath = path + return true + } + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) if isLong { Button { withAnimation(.easeInOut(duration: 0.2)) { diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 8e06365c..0fe4b1e9 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -22,50 +22,50 @@ struct MessageListView: View { private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") var body: some View { - ScrollView { - VStack(spacing: 16) { - messageRows(settledItems[...]) - } - .padding(.horizontal, 20) - .padding(.top, 16) + List { + messageRows(settledItems[...]) // Streaming view is outside VStack — text deltas don't affect settled layout - VStack(spacing: 16) { - if !windowState.focusMode { - StreamingMessageView { - rebuildSettledItems() - if anchor.isNearBottom { scrollToBottomDebounced() } - } + if !windowState.focusMode { + StreamingMessageView { + rebuildSettledItems() + if anchor.isNearBottom { scrollToBottomDebounced() } } + // 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) - } + 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() + } - if !chatBridge.isStreaming && !settledItems.isEmpty { - WebPreviewButton(messages: settledItems) - .id("web-preview") - } + if !chatBridge.isStreaming && !settledItems.isEmpty { + WebPreviewButton(messages: settledItems) + .id("web-preview") + .chatMessageListRowStyle() } - .padding(.horizontal, 20) - // Suppress layout animations when switching sessions so the pulse indicator - // doesn't visually jump as StreamingMessageView changes height. - .animation(.none, value: windowState.currentSessionId) Color.clear.frame(height: 1) - .padding(.bottom, 16) + .chatMessageListRowStyle() } + .listStyle(.plain) + .contentMargins(.top, 16, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) .opacity(isSessionReady ? 1 : 0) .scrollPosition($scrollPosition) .defaultScrollAnchor(.bottom) @@ -175,23 +175,7 @@ struct MessageListView: View { @ViewBuilder private func messageRows(_ messages: some RandomAccessCollection) -> some View { - let groups = groupMessages(Array(messages)) - ForEach(groups) { group in - if group.isTransientGroup { - TransientGroupSummaryView(messages: group.messages) - .id(group.id) - .transition(messageFadeTransition(role: .assistant)) - } else if let message = group.messages.first { - MessageBubble(message: message) - .id(message.id) - .transition(messageFadeTransition(role: message.role)) - } - } - } - - private func messageFadeTransition(role: Role) -> AnyTransition { - let anchor: UnitPoint = role == .user ? .bottomTrailing : .bottomLeading - return .opacity.combined(with: .scale(scale: 0.97, anchor: anchor)) + ChatMessageListView(messages: Array(messages)) } // MARK: - Message Grouping @@ -211,7 +195,7 @@ struct MessageListView: View { private func settledOnlyMessages(from messages: [ChatMessage]) -> [ChatMessage] { var settled: [ChatMessage] if messages.last?.isStreaming == true { - let boundary = streamingBoundaryIndex(in: messages) + let boundary = chatStreamingBoundaryIndex(in: messages) settled = Array(messages[.. (settled: [ChatMessage], streaming: [ChatMessage]) { - var settled: [ChatMessage] = [] - var streaming: [ChatMessage] = [] - for m in messages { if m.isStreaming { streaming.append(m) } else { settled.append(m) } } - return (settled, streaming) -} - - -fileprivate struct MessageGroup: Identifiable { - let id: UUID - let messages: [ChatMessage] - let isTransientGroup: Bool -} - -/// Returns true if the message would render only a transient tool summary (no visible text or non-transient tools). -fileprivate func isPureTransientMessage(_ message: ChatMessage) -> Bool { - guard message.role == .assistant, !message.isError, !message.isCompactBoundary else { return false } - // Whitespace-only text is treated as invisible so it doesn't break transient grouping. - let hasVisibleText = message.blocks.contains { - guard let text = $0.text else { return false } - return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - if hasVisibleText { return false } - let toolCalls = message.blocks.compactMap(\.toolCall) - guard !toolCalls.isEmpty else { return false } - let hasNonTransient = toolCalls.contains { !ToolCategory(toolName: $0.name).isTransient } - if hasNonTransient { return false } - return true -} - -/// Returns true if the message has no renderable content — all tool calls were removed -/// (e.g. empty bash output stripped by setToolResult) and there is no text. -/// These messages are invisible in the UI and should not break transient-tool grouping. -fileprivate func isInvisibleMessage(_ message: ChatMessage) -> Bool { - guard message.role == .assistant, !message.isError, !message.isCompactBoundary, !message.isStreaming else { return false } - return message.blocks.isEmpty -} - -fileprivate func suppressPlanReadyFollowups(in messages: [ChatMessage]) -> [ChatMessage] { - var result: [ChatMessage] = [] - var assistantRun: [ChatMessage] = [] - - func flushAssistantRun() { - guard !assistantRun.isEmpty else { return } - let hasExitPlan = assistantRun.contains { PlanLogic.containsExitPlanMode($0) } - var hasRecentPlanCard = false - - for message in assistantRun { - if hasExitPlan && PlanLogic.isPurePlanFileWriteMessage(message) { - continue - } - - // Plan-ready follow-up messages (e.g. "Plan is ready at /…/plans/foo.md") - // are intentionally kept visible alongside the plan card so the user - // sees the summary while approval is pending. - _ = hasRecentPlanCard - - if PlanLogic.containsExitPlanMode(message) { - hasRecentPlanCard = true - } - result.append(message) - } - - assistantRun.removeAll(keepingCapacity: true) - } - - for message in messages { - if message.role == .user { - flushAssistantRun() - result.append(message) - continue - } - - assistantRun.append(message) - } - flushAssistantRun() - - return result -} - -fileprivate func isPlanReadyFollowupMessage(_ message: ChatMessage) -> Bool { - guard message.role == .assistant, - !message.isError, - !message.isCompactBoundary, - !message.isStreaming, - !message.blocks.isEmpty else { - return false - } - return message.blocks.allSatisfy { block in - guard let text = block.text else { return false } - return PlanLogic.isPlanReadyFollowup(text) - } -} - -/// Groups consecutive pure-transient assistant messages into combined groups. -/// - Parameter minGroupSize: Minimum number of transient messages required to collapse into a group. -/// Pass 1 (streaming context) to hide even a single completed tool call the moment the next message starts. -/// Pass 2 (settled list) to keep lone tool calls visible after streaming ends. -fileprivate func groupMessages(_ messages: [ChatMessage], minGroupSize: Int = 2) -> [MessageGroup] { - var result: [MessageGroup] = [] - var accumulator: [ChatMessage] = [] - - func flushAccumulator() { - guard !accumulator.isEmpty else { return } - if accumulator.count >= minGroupSize { - result.append(MessageGroup(id: accumulator[0].id, messages: accumulator, isTransientGroup: true)) - } else { - for m in accumulator { - result.append(MessageGroup(id: m.id, messages: [m], isTransientGroup: false)) - } - } - accumulator = [] - } - - for message in messages { - if isPureTransientMessage(message) { - accumulator.append(message) - } else if isInvisibleMessage(message) { - // Skip invisible messages (e.g. all tool calls removed due to empty results). - // They render nothing in the UI and must not break consecutive transient grouping. - continue - } else { - flushAccumulator() - result.append(MessageGroup(id: message.id, messages: [message], isTransientGroup: false)) - } - } - flushAccumulator() - - return result -} - -// MARK: - Shared Helper - -/// Returns the start index of the last consecutive non-error assistant sequence. -/// Used to distinguish the settled (previous) / active (streaming) boundary. -private func streamingBoundaryIndex(in messages: [ChatMessage]) -> Int { - var idx = messages.count - 1 - while idx >= 0 && messages[idx].role == .assistant && !messages[idx].isError { - idx -= 1 - } - return idx + 1 -} - // MARK: - Streaming Message (isolated view — chatBridge.messages dependency confined to this view) struct StreamingMessageView: View { @@ -388,21 +226,21 @@ struct StreamingMessageView: View { var body: some View { let messages = chatBridge.messages let activeMessages = activeResponseMessages(from: messages) - let (settledActive, streamingActive) = partitionByStreaming(activeMessages) + let (settledActive, streamingActive) = chatPartitionByStreaming(activeMessages) Group { if !activeMessages.isEmpty { if !streamingActive.isEmpty { // Collapse completed transient tool calls (even a single one) the moment // the next streaming message begins, so only the current message stays visible. - let groups = groupMessages(settledActive, minGroupSize: 1) + let groups = chatMessageGroups(settledActive, minGroupSize: 1) ForEach(groups) { group in if group.isTransientGroup { - TransientGroupSummaryView(messages: group.messages) + ChatTransientGroupSummaryView(messages: group.messages) .id(group.id) .transition(streamFadeTransition(role: .assistant)) } else if let message = group.messages.first { - MessageBubble(message: message) + ChatMessageBubble(message: message) .id(message.id) .transition(streamFadeTransition(role: message.role)) } @@ -410,14 +248,14 @@ struct StreamingMessageView: View { } else { // Nothing streaming yet — show each settled message individually. ForEach(settledActive, id: \.id) { message in - MessageBubble(message: message) + ChatMessageBubble(message: message) .id(message.id) .transition(streamFadeTransition(role: message.role)) } } ForEach(streamingActive, id: \.id) { message in - MessageBubble(message: message) + ChatMessageBubble(message: message) .id(message.id) .transition(streamFadeTransition(role: .assistant)) } @@ -439,52 +277,7 @@ struct StreamingMessageView: View { /// Returns an empty array when not streaming so StreamingMessageView renders nothing. private func activeResponseMessages(from messages: [ChatMessage]) -> [ChatMessage] { guard messages.last?.isStreaming == true else { return [] } - return suppressPlanReadyFollowups(in: Array(messages[streamingBoundaryIndex(in: messages)...])) - } -} - -// MARK: - Transient Group Summary - -struct TransientGroupSummaryView: View { - let messages: [ChatMessage] - @State private var isExpanded = false - - private var allToolCalls: [ToolCall] { - messages.flatMap { $0.blocks.compactMap(\.toolCall) } - } - - var body: some View { - HStack(alignment: .top, spacing: 0) { - VStack(alignment: .leading, spacing: 6) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } label: { - HStack(spacing: 6) { - Image(systemName: "eye.slash") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) - Text(String(format: String(localized: "%lld tools executed", bundle: .module), allToolCalls.count)) - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(ClaudeTheme.textTertiary) - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: ClaudeTheme.size(9))) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if isExpanded { - ForEach(allToolCalls, id: \.id) { toolCall in - ToolResultView(toolCall: toolCall, isMessageStreaming: false) - } - } - } - Spacer(minLength: 40) - } + return chatSuppressPlanReadyFollowups(in: Array(messages[chatStreamingBoundaryIndex(in: messages)...])) } } diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 0a7f2163..25b7aafd 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -135,8 +135,7 @@ struct ToolResultView: View { } .animation(.spring(response: 0.35, dampingFraction: 0.7), value: statusIconID) - inputSummaryView - .lineLimit(isExpanded ? nil : 1) + inputSummaryView(maximumNumberOfLines: isExpanded ? nil : 1) } .contentShape(Rectangle()) } @@ -161,11 +160,13 @@ struct ToolResultView: View { ClaudeThemeDivider() ScrollView { - Text(result) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(toolCall.isError ? ClaudeTheme.statusError : ClaudeTheme.textPrimary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + ChatTextContentView( + result, + size: ClaudeTheme.messageSize(12), + design: .monospaced, + color: toolCall.isError ? ClaudeTheme.statusError : ClaudeTheme.textPrimary + ) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 200) } @@ -215,9 +216,7 @@ struct ToolResultView: View { .frame(width: 14, height: 14) } - inputSummaryView - .lineLimit(1) - .truncationMode(.tail) + inputSummaryView(maximumNumberOfLines: 1) if toolCall.result == nil && isMessageStreaming { ProgressView() @@ -233,11 +232,13 @@ struct ToolResultView: View { if isExpanded, !isBashTool, let result = toolCall.result, !result.isEmpty { ScrollView { - Text(result) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(toolCall.isError ? ClaudeTheme.statusError : ClaudeTheme.textSecondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + ChatTextContentView( + result, + size: ClaudeTheme.messageSize(12), + design: .monospaced, + color: toolCall.isError ? ClaudeTheme.statusError : ClaudeTheme.textSecondary + ) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 200) .padding(.leading, 20) @@ -293,9 +294,12 @@ struct ToolResultView: View { return VStack(alignment: .leading, spacing: 0) { ForEach(Array(visibleLines.enumerated()), id: \.offset) { idx, item in let (prefix, text, isAdded) = item - Text(prefix + " " + text) - .font(.system(size: ClaudeTheme.messageSize(12), design: .monospaced)) - .foregroundStyle(isAdded ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + ChatTextContentView( + prefix + " " + text, + size: ClaudeTheme.messageSize(12), + design: .monospaced, + color: isAdded ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError + ) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 1) @@ -368,9 +372,12 @@ struct ToolResultView: View { return VStack(alignment: .leading, spacing: 0) { ForEach(Array(visibleLines.enumerated()), id: \.offset) { _, line in - Text(line.isEmpty ? " " : line) - .font(.system(size: ClaudeTheme.messageSize(12), design: .monospaced)) - .foregroundStyle(diffLineColor(line)) + ChatTextContentView( + line.isEmpty ? " " : line, + size: ClaudeTheme.messageSize(12), + design: .monospaced, + color: diffLineColor(line) + ) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 1) @@ -462,14 +469,16 @@ struct ToolResultView: View { } @ViewBuilder - private var inputSummaryView: some View { + private func inputSummaryView(maximumNumberOfLines: Int? = nil) -> some View { if isEditTool || toolNameLower == "write", let filePath = toolCall.editedFilePath { let fileName = URL(fileURLWithPath: filePath).lastPathComponent HStack(spacing: 0) { - Text("\(toolDescriptionPrefix) — ") - .font(.system(size: ClaudeTheme.messageSize(12))) - .foregroundStyle(ClaudeTheme.textSecondary) + ChatTextContentView( + "\(toolDescriptionPrefix) — ", + size: ClaudeTheme.messageSize(12), + color: ClaudeTheme.textSecondary + ) fileActionLink(label: fileName, color: ClaudeTheme.accent) { windowState.inspectorFile = PreviewFile(path: filePath, name: fileName) } @@ -477,9 +486,11 @@ struct ToolResultView: View { let hunks = editHunksFromToolInput() if !hunks.isEmpty { HStack(spacing: 0) { - Text(" · ") - .font(.system(size: ClaudeTheme.messageSize(12))) - .foregroundStyle(ClaudeTheme.textTertiary) + ChatTextContentView( + " · ", + size: ClaudeTheme.messageSize(12), + color: ClaudeTheme.textTertiary + ) fileActionLink(label: "diff") { windowState.diffFile = PreviewFile( path: filePath, @@ -494,9 +505,12 @@ struct ToolResultView: View { } .animation(.easeOut(duration: 0.25), value: toolCall.result != nil) } else { - Text(inputSummary) - .font(.system(size: ClaudeTheme.messageSize(12))) - .foregroundStyle(ClaudeTheme.textSecondary) + ChatTextContentView( + inputSummary, + size: ClaudeTheme.messageSize(12), + color: ClaudeTheme.textSecondary, + maximumNumberOfLines: maximumNumberOfLines + ) } } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 7a2d942c..ae3a8638 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -234,6 +234,23 @@ public struct MobileSettingsUpdatePayload: Codable, Sendable { } } +public struct SessionProgressSnapshot: Codable, Sendable, Equatable { + public let done: Int + public let total: Int + public let inProgress: Bool + + public init(done: Int, total: Int, inProgress: Bool) { + self.done = done + self.total = total + self.inProgress = inProgress + } +} + +public enum SessionAttentionKind: String, Codable, Sendable, Equatable { + case permission + case question +} + public struct SessionSummary: Codable, Sendable, Identifiable { public let id: String public let projectId: UUID @@ -241,13 +258,20 @@ public struct SessionSummary: Codable, Sendable, Identifiable { public let updatedAt: Date public let isPinned: Bool public let isArchived: Bool + public let isStreaming: Bool + public let attention: SessionAttentionKind? + public let progress: SessionProgressSnapshot? + public init( id: String, projectId: UUID, title: String, updatedAt: Date, isPinned: Bool, - isArchived: Bool + isArchived: Bool, + isStreaming: Bool = false, + attention: SessionAttentionKind? = nil, + progress: SessionProgressSnapshot? = nil ) { self.id = id self.projectId = projectId @@ -255,6 +279,26 @@ public struct SessionSummary: Codable, Sendable, Identifiable { self.updatedAt = updatedAt self.isPinned = isPinned self.isArchived = isArchived + self.isStreaming = isStreaming + self.attention = attention + self.progress = progress + } + + private enum CodingKeys: String, CodingKey { + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + projectId = try container.decode(UUID.self, forKey: .projectId) + title = try container.decode(String.self, forKey: .title) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false + 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) } } @@ -270,16 +314,23 @@ public struct SessionUpdatePayload: Codable, Sendable { public let kind: Kind public let message: ChatMessage? public let isStreaming: Bool? + public let summary: SessionSummary? + public let previousSessionID: String? + public init( sessionID: String, kind: Kind, message: ChatMessage? = nil, - isStreaming: Bool? = nil + isStreaming: Bool? = nil, + summary: SessionSummary? = nil, + previousSessionID: String? = nil ) { self.sessionID = sessionID self.kind = kind self.message = message self.isStreaming = isStreaming + self.summary = summary + self.previousSessionID = previousSessionID } } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 2920aeaa..ad089770 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ DF230B9F2FBC73BA008929A6 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B9E2FBC73BA008929A6 /* RxCodeChatKit */; }; DF230BA12FBC73BA008929A6 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = DF230BA02FBC73BA008929A6 /* RxCodeCore */; }; DF230BA32FBC73BA008929A6 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = DF230BA22FBC73BA008929A6 /* RxCodeSync */; }; + DF230BB32FBC9001008929A6 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = DF230BB22FBC9001008929A6 /* RxCodeSync */; }; + DF230BB42FBC9001008929A6 /* RxCodeMobileNotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF23F7362FB8C3EC008929A6 /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DF23F7352FB8C3EC008929A6 /* icon.icon */; }; DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; }; DF31D3DC2FBC8143005D518E /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DF23F7352FB8C3EC008929A6 /* icon.icon */; }; @@ -62,8 +64,29 @@ remoteGlobalIDString = DF230B4E2FBC7367008929A6; remoteInfo = RxCodeMobile; }; + DF230BB62FBC9001008929A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E67335302F7356F600FD26C7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DF230BA42FBC9001008929A6; + remoteInfo = RxCodeMobileNotificationService; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + DF230BB52FBC9001008929A6 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DF230BB42FBC9001008929A6 /* RxCodeMobileNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateProjectSwitchTests.swift; sourceTree = ""; }; 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAIProviderAcceptanceTests.swift; sourceTree = ""; }; @@ -75,6 +98,7 @@ DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCodeMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RxCodeMobileNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; DF23F7352FB8C3EC008929A6 /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = ""; }; DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = ""; }; DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = ""; }; @@ -90,6 +114,13 @@ ); target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; }; + DF230BAA2FBC9001008929A6 /* Exceptions for "RxCodeMobileNotificationService" folder in "RxCodeMobileNotificationService" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -111,6 +142,14 @@ path = RxCodeMobileUITests; sourceTree = ""; }; + DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DF230BAA2FBC9001008929A6 /* Exceptions for "RxCodeMobileNotificationService" folder in "RxCodeMobileNotificationService" target */, + ); + path = RxCodeMobileNotificationService; + sourceTree = ""; + }; E673353A2F7356F600FD26C7 /* RxCode */ = { isa = PBXFileSystemSynchronizedRootGroup; path = RxCode; @@ -164,6 +203,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230BA62FBC9001008929A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DF230BB32FBC9001008929A6 /* RxCodeSync in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335352F7356F600FD26C7 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -224,6 +271,7 @@ E673353A2F7356F600FD26C7 /* RxCode */, 6E17B0042FC8000100A10001 /* RxCodeUITests */, DF230B502FBC7367008929A6 /* RxCodeMobile */, + DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */, DF230B622FBC7368008929A6 /* RxCodeMobileTests */, DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */, E67335392F7356F600FD26C7 /* Products */, @@ -239,6 +287,7 @@ 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */, 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */, DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */, + DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */, DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */, DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */, ); @@ -298,10 +347,12 @@ DF230B4B2FBC7367008929A6 /* Sources */, DF230B4C2FBC7367008929A6 /* Frameworks */, DF230B4D2FBC7367008929A6 /* Resources */, + DF230BB52FBC9001008929A6 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + DF230BB72FBC9001008929A6 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DF230B502FBC7367008929A6 /* RxCodeMobile */, @@ -365,6 +416,29 @@ productReference = DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF230BAE2FBC9001008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobileNotificationService" */; + buildPhases = ( + DF230BA52FBC9001008929A6 /* Sources */, + DF230BA62FBC9001008929A6 /* Frameworks */, + DF230BA72FBC9001008929A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */, + ); + name = RxCodeMobileNotificationService; + packageProductDependencies = ( + DF230BB22FBC9001008929A6 /* RxCodeSync */, + ); + productName = RxCodeMobileNotificationService; + productReference = DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; E67335372F7356F600FD26C7 /* RxCode */ = { isa = PBXNativeTarget; buildConfigurationList = E67335432F7356F700FD26C7 /* Build configuration list for PBXNativeTarget "RxCode" */; @@ -414,6 +488,9 @@ CreatedOnToolsVersion = 26.3; TestTargetID = DF230B4E2FBC7367008929A6; }; + DF230BA42FBC9001008929A6 = { + CreatedOnToolsVersion = 26.3; + }; E67335372F7356F600FD26C7 = { CreatedOnToolsVersion = 26.3; }; @@ -445,6 +522,7 @@ 5D74FE7B782850D23F8D9BF7 /* RxCodeTests */, 6E17B0062FC8000100A10001 /* RxCodeUITests */, DF230B4E2FBC7367008929A6 /* RxCodeMobile */, + DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */, DF230B5E2FBC7368008929A6 /* RxCodeMobileTests */, DF230B682FBC7368008929A6 /* RxCodeMobileUITests */, ); @@ -488,6 +566,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230BA72FBC9001008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335362F7356F600FD26C7 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -540,6 +625,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230BA52FBC9001008929A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335342F7356F600FD26C7 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -572,6 +664,11 @@ target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; targetProxy = DF230B6A2FBC7368008929A6 /* PBXContainerItemProxy */; }; + DF230BB72FBC9001008929A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */; + targetProxy = DF230BB62FBC9001008929A6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -798,6 +895,69 @@ }; name = Release; }; + DF230BAC2FBC9001008929A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = RxCodeMobileNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.rxlab.rxcodemobile.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DF230BAD2FBC9001008929A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = RxCodeMobileNotificationService/RxCodeMobileNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = RxCodeMobileNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.rxlab.rxcodemobile.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; E67335412F7356F700FD26C7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1034,6 +1194,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DF230BAE2FBC9001008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobileNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF230BAC2FBC9001008929A6 /* Debug */, + DF230BAD2FBC9001008929A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; E67335332F7356F600FD26C7 /* Build configuration list for PBXProject "RxCode" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1136,6 +1305,11 @@ package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; productName = RxCodeSync; }; + DF230BB22FBC9001008929A6 /* RxCodeSync */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeSync; + }; DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = { isa = XCSwiftPackageProductDependency; package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 8ab9aa59..52a1511e 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -172,6 +172,10 @@ final class AppState { /// `threadFileEdits(in:)` fetch after a new Edit/Write tool call lands. var threadFileEditsRevision: Int = 0 + /// Pending permission/question prompts keyed by hook id. This mirrors the + /// per-window queues so mobile thread rows can show the same attention state. + private var mobilePendingRequests: [String: PermissionRequest] = [:] + // MARK: - Theme var selectedTheme: AppTheme = .init(rawValue: UserDefaults.standard.string(forKey: "selectedTheme") ?? "") ?? .claude { @@ -1068,6 +1072,34 @@ final class AppState { } mobileSyncObservers.append(settingsObserver) + let userMessageObserver = center.addObserver( + forName: .mobileSyncUserMessageReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let message = notification.userInfo?["payload"] as? UserMessagePayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileUserMessage(message, fromHex: fromHex) + } + } + mobileSyncObservers.append(userMessageObserver) + + let newSessionObserver = center.addObserver( + forName: .mobileSyncNewSessionRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? NewSessionRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileNewSessionRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(newSessionObserver) + observeMobileSnapshotInputs() } @@ -1095,6 +1127,103 @@ final class AppState { } } + private func handleMobileNewSessionRequest(_ request: NewSessionRequestPayload, fromHex: String) async { + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] new thread requested for unknown project=\(request.projectID.uuidString, privacy: .public)") + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + let initialText = request.initialText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !initialText.isEmpty else { + let sessionID = createMobilePlaceholderSession(project: project, requestID: request.clientRequestID) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: sessionID) + return + } + + let window = WindowState() + window.selectedProject = project + _ = await sendPrompt(initialText, displayText: initialText, in: window) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) + } + + private func handleMobileUserMessage(_ message: UserMessagePayload, fromHex: String) async { + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + + let sessionID = resolveCurrentSessionId(message.sessionID) + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }), + let project = projects.first(where: { $0.id == summary.projectId }) + else { + logger.error("[MobileSync] user message for unknown thread=\(message.sessionID, privacy: .public)") + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + await hydrateMobileSessionIfNeeded(summary: summary, project: project) + updateMobilePlaceholderTitleIfNeeded(sessionID: sessionID, firstUserMessage: text) + + let window = WindowState() + window.selectedProject = project + window.currentSessionId = sessionID + if sessionID.hasPrefix("pending-mobile-") { + window.insertPendingPlaceholder(sessionID) + } + _ = await sendPrompt(text, displayText: text, in: window) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) + } + + private func createMobilePlaceholderSession(project: Project, requestID: UUID) -> String { + let sessionID = "pending-mobile-\(requestID.uuidString)" + if allSessionSummaries.contains(where: { $0.id == sessionID }) { + return sessionID + } + + let selection = defaultModelSelection(for: project) + let session = ChatSession( + id: sessionID, + projectId: project.id, + title: ChatSession.defaultTitle, + agentProvider: selection.provider, + model: selection.model, + origin: selection.provider.defaultSessionOrigin + ) + allSessionSummaries.insert(session.summary, at: 0) + threadStore.upsert(session.summary) + updateState(sessionID) { state in + state.agentProvider = selection.provider + state.model = selection.model + } + return sessionID + } + + private func hydrateMobileSessionIfNeeded(summary: ChatSession.Summary, project: Project) async { + if sessionStates[summary.id] == nil, + let full = await persistence.loadFullSession(summary: summary, cwd: project.path) { + updateState(summary.id) { state in + state.messages = full.messages + } + } + + updateState(summary.id) { state in + if state.agentProvider == nil { state.agentProvider = summary.agentProvider } + if state.model == nil { state.model = summary.model } + if state.effort == nil { state.effort = summary.effort } + if state.permissionMode == nil { state.permissionMode = summary.permissionMode } + if state.worktreePath == nil { state.worktreePath = summary.worktreePath } + if state.worktreeBranch == nil { state.worktreeBranch = summary.worktreeBranch } + } + } + + private func updateMobilePlaceholderTitleIfNeeded(sessionID: String, firstUserMessage: String) { + guard let index = allSessionSummaries.firstIndex(where: { $0.id == sessionID }) else { return } + let current = allSessionSummaries[index] + guard current.title == ChatSession.defaultTitle || current.title.isEmpty else { return } + allSessionSummaries[index].title = ChatSession.placeholderTitle(from: firstUserMessage) + allSessionSummaries[index].updatedAt = Date() + threadStore.upsert(allSessionSummaries[index]) + } + private func scheduleMobileSnapshotBroadcast() { mobileSnapshotBroadcastTask?.cancel() mobileSnapshotBroadcastTask = Task { @MainActor [weak self] in @@ -1136,17 +1265,91 @@ final class AppState { return lhs.updatedAt > rhs.updatedAt } .map { - RxCodeSync.SessionSummary( - id: $0.id, - projectId: $0.projectId, - title: $0.title, - updatedAt: $0.updatedAt, - isPinned: $0.isPinned, - isArchived: $0.isArchived - ) + mobileSessionSummary(from: $0) } } + private func mobileSessionSummary(for sessionID: String) -> RxCodeSync.SessionSummary? { + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { + return nil + } + return mobileSessionSummary(from: summary) + } + + private func mobileSessionSummary(from summary: ChatSession.Summary) -> RxCodeSync.SessionSummary { + let progress = mobileProgressSnapshot(forSessionId: summary.id) + return RxCodeSync.SessionSummary( + id: summary.id, + projectId: summary.projectId, + title: summary.title, + updatedAt: summary.updatedAt, + isPinned: summary.isPinned, + isArchived: summary.isArchived, + isStreaming: sessionStates[summary.id]?.isStreaming ?? false, + attention: mobileAttentionKind(forSessionId: summary.id), + progress: progress + ) + } + + private func mobileProgressSnapshot(forSessionId id: String) -> SessionProgressSnapshot? { + if let messages = sessionStates[id]?.messages, + let todos = TodoExtractor.latest(in: messages) + { + return SessionProgressSnapshot( + done: todos.filter { $0.status == .completed }.count, + total: todos.count, + inProgress: todos.contains { $0.status == .inProgress } + ) + } + + 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 + ) + } + + private func mobileAttentionKind(forSessionId id: String) -> SessionAttentionKind? { + let requests = mobilePendingRequests.values.filter { $0.sessionId == id } + if requests.contains(where: { $0.toolName == "AskUserQuestion" }) { + return .question + } + if !requests.isEmpty { + return .permission + } + return nil + } + + private func broadcastMobileSessionStatus(sessionID: String, kind: SessionUpdatePayload.Kind = .statusChanged) { + guard let summary = mobileSessionSummary(for: sessionID) else { return } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionID, + kind: kind, + message: nil, + isStreaming: summary.isStreaming, + summary: summary + ) + } + + private func broadcastMobileSessionRedirect(from previousSessionID: String, to sessionID: String) { + guard previousSessionID != sessionID, + let summary = mobileSessionSummary(for: sessionID) + else { return } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionID, + kind: .statusChanged, + message: nil, + isStreaming: summary.isStreaming, + summary: summary, + previousSessionID: previousSessionID + ) + scheduleMobileSnapshotBroadcast() + } + private func mobileBranchBriefings() -> [MobileBranchBriefing] { let knownProjectIds = Set(projects.map(\.id)) return threadStore.allBranchBriefingItems() @@ -1882,10 +2085,14 @@ final class AppState { guard let window else { break } if !window.pendingPermissions.contains(where: { $0.id == request.id }) { window.pendingPermissions.append(request) + mobilePendingRequests[request.id] = request let projectName = window.selectedProject?.name let projectId = window.selectedProject?.id let sessionId = window.currentSessionId let toolName = request.toolName + if let requestSessionId = request.sessionId { + broadcastMobileSessionStatus(sessionID: requestSessionId) + } // Auto-present the question sheet only when the user is actively viewing // the thread the question belongs to. Otherwise it stays in the queue // (yellow dot in sidebar + banner) so the user can decide when to answer. @@ -2379,6 +2586,7 @@ final class AppState { state.currentTurnOutputTokensByMessage.removeAll(keepingCapacity: true) state.currentTurnOutputTokensUnkeyed = 0 } + broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingStarted) await permission.refreshRunToken() let basePermissionMode = window.sessionPermissionMode ?? permissionMode @@ -2493,6 +2701,7 @@ final class AppState { } state.streamingStartDate = nil } + broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingFinished) } // MARK: - Stream Completion (cross-project MCP) @@ -2981,6 +3190,9 @@ final class AppState { } } } + if previousSessionKey != sid { + broadcastMobileSessionRedirect(from: previousSessionKey, to: sid) + } } if systemEvent.subtype == "compact_boundary" { @@ -3206,6 +3418,7 @@ final class AppState { "[TodoSnapshot] session=\(targetSession, privacy: .public) total=\(snapshot.items.count) done=\(done) active=\(active, privacy: .public)" ) threadStore.upsertTodoSnapshot(sessionId: targetSession, items: snapshot.items) + broadcastMobileSessionStatus(sessionID: targetSession) case .acpModelsDiscovered(let event): logger.info("[Stream:UI] event #\(eventCount) .acpModelsDiscovered clientId=\(event.clientId, privacy: .public) configId=\(event.config.configId, privacy: .public) models=\(event.config.options.count) [\(Self.acpModelListDescription(event.config.options), privacy: .public)]") @@ -3633,6 +3846,10 @@ final class AppState { func respondToPermission(_ request: PermissionRequest, decision: PermissionDecision, in window: WindowState) async { await permission.respond(toolUseId: request.id, decision: decision) window.pendingPermissions.removeAll { $0.id == request.id } + mobilePendingRequests.removeValue(forKey: request.id) + if let sessionId = request.sessionId { + broadcastMobileSessionStatus(sessionID: sessionId) + } } // MARK: - AskUserQuestion Response @@ -3673,9 +3890,13 @@ final class AppState { } window.pendingPermissions.removeAll { $0.id == toolUseId } + let requestSessionId = mobilePendingRequests.removeValue(forKey: toolUseId)?.sessionId if window.presentedPermissionId == toolUseId { window.presentedPermissionId = nil } + if let requestSessionId { + broadcastMobileSessionStatus(sessionID: requestSessionId) + } await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) } @@ -3684,9 +3905,13 @@ final class AppState { /// the CLI does not block, and clear the pending entry from the window queue. func skipAskUserQuestion(toolUseId: String, in window: WindowState) async { window.pendingPermissions.removeAll { $0.id == toolUseId } + let requestSessionId = mobilePendingRequests.removeValue(forKey: toolUseId)?.sessionId if window.presentedPermissionId == toolUseId { window.presentedPermissionId = nil } + if let requestSessionId { + broadcastMobileSessionStatus(sessionID: requestSessionId) + } await permission.respond(toolUseId: toolUseId, decision: .deny) } @@ -4152,6 +4377,7 @@ final class AppState { threadStore.upsert(allSessionSummaries[si]) } await updateSessionMetadata(session, persistTitle: true) { $0.title = newTitle } + broadcastMobileSessionStatus(sessionID: session.id) } /// True if `currentTitle` looks like our auto-derived placeholder (matches what @@ -4669,6 +4895,7 @@ final class AppState { let newIsPinned = allSessionSummaries[si].isPinned threadStore.upsert(allSessionSummaries[si]) await updateSessionMetadata(session) { $0.isPinned = newIsPinned } + scheduleMobileSnapshotBroadcast() } // MARK: - Archive @@ -4708,6 +4935,7 @@ final class AppState { s.isArchived = archived s.archivedAt = archived ? now : nil } + scheduleMobileSnapshotBroadcast() } /// Retention window (days) after which a branch briefing is purged if its diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index b4806d6f..da260725 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -241,6 +241,7 @@ final class MobileSyncService: ObservableObject { throw MobilePushError.invalidRelayURL } + logger.info("[APNs] sending test push deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) tokenPrefix=\(String(token.prefix(12)), privacy: .public) environment=\(device.apnsEnvironment ?? "", privacy: .public) sender=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") let plaintext = AlertPlaintext( title: "RxCode test notification", body: "Notifications are working for \(device.displayName).", @@ -288,13 +289,22 @@ final class MobileSyncService: ObservableObject { /// Called by AppState's streaming loop after each StreamEvent is folded /// into local state. - func broadcastSessionUpdate(sessionID: String, kind: SessionUpdatePayload.Kind, message: ChatMessage?, isStreaming: Bool?) { + func broadcastSessionUpdate( + sessionID: String, + kind: SessionUpdatePayload.Kind, + message: ChatMessage?, + isStreaming: Bool?, + summary: RxCodeSync.SessionSummary? = nil, + previousSessionID: String? = nil + ) { Task { let payload = SessionUpdatePayload( sessionID: sessionID, kind: kind, message: message, - isStreaming: isStreaming + isStreaming: isStreaming, + summary: summary, + previousSessionID: previousSessionID ) await client.broadcast(.sessionUpdate(payload)) } @@ -321,15 +331,25 @@ final class MobileSyncService: ObservableObject { pendingPairing = req Task { try? await client.addPeer(req.mobilePubkeyHex) } case .unpair: + guard isPairedPeer(inbound.fromHex) else { return } handleRemoteUnpair(pubkeyHex: inbound.fromHex) case .apnsToken(let t): if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { + logger.info("[APNs] token received mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") pairedDevices[idx].apnsToken = t.tokenHex pairedDevices[idx].apnsEnvironment = t.environment pairedDevices[idx].lastSeen = .now + for staleIdx in pairedDevices.indices where staleIdx != idx && pairedDevices[staleIdx].apnsToken == t.tokenHex { + logger.warning("[APNs] clearing duplicate token from stale mobileKey=\(String(self.pairedDevices[staleIdx].pubkeyHex.prefix(12)), privacy: .public) currentMobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public)") + pairedDevices[staleIdx].apnsToken = nil + pairedDevices[staleIdx].apnsEnvironment = nil + } savePairedDevices() + } else { + logger.warning("[APNs] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") } case .requestSnapshot(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "request_snapshot") else { return } // AppState owns the data; it observes pendingSnapshotRequests // and replies. Stub for now — wired up by AppState bridge. var userInfo: [String: Any] = ["from": inbound.fromHex] @@ -340,24 +360,28 @@ final class MobileSyncService: ObservableObject { userInfo: userInfo ) case .settingsUpdate(let update): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "settings_update") else { return } NotificationCenter.default.post( name: .mobileSyncSettingsUpdateReceived, object: nil, userInfo: ["from": inbound.fromHex, "payload": update] ) case .userMessage(let msg): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "user_message") else { return } NotificationCenter.default.post( name: .mobileSyncUserMessageReceived, object: nil, userInfo: ["from": inbound.fromHex, "payload": msg] ) case .newSessionRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "new_session_request") else { return } NotificationCenter.default.post( name: .mobileSyncNewSessionRequested, object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) case .subscribeSession(let sub): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "subscribe_session") else { return } subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" var userInfo: [String: Any] = ["from": inbound.fromHex] userInfo["activeSessionID"] = sub.sessionID @@ -367,18 +391,37 @@ final class MobileSyncService: ObservableObject { userInfo: userInfo ) case .permissionResponse(let resp): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "permission_response") else { return } NotificationCenter.default.post( name: .mobileSyncPermissionResponse, object: nil, userInfo: ["payload": resp] ) case .ping: + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } default: break } } + private func isPairedPeer(_ pubkeyHex: String) -> Bool { + pairedDevices.contains { $0.pubkeyHex == pubkeyHex } + } + + private func acceptPairedOnlyPayload(from pubkeyHex: String, type: String) -> Bool { + guard isPairedPeer(pubkeyHex) else { + logger.warning("[MobileSync] rejecting \(type, privacy: .public) from unknown mobileKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") + Task { + try? await client.addPeer(pubkeyHex) + try? await client.send(.unpair(UnpairPayload(reason: "unknown_peer")), toHex: pubkeyHex) + await client.removePeer(pubkeyHex) + } + return false + } + return true + } + private func handleRemoteUnpair(pubkeyHex: String) { guard pairedDevices.contains(where: { $0.pubkeyHex == pubkeyHex }) else { Task { await client.removePeer(pubkeyHex) } diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 9f4f3aa2..8369c267 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -1,5 +1,7 @@ import UIKit import UserNotifications +import CryptoKit +import RxCodeSync import os.log /// Bridges UIKit's APNs registration into `MobileAppState`. The state object @@ -47,7 +49,119 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let content = notification.request.content - logger.info("[APNs] willPresent title=\(content.title, privacy: .public) body=\(content.body, privacy: .public) category=\(content.categoryIdentifier, privacy: .public) keys=\(content.userInfo.keys.map { "\($0)" }.sorted().joined(separator: ","), privacy: .public)") + let requestID = notification.request.identifier + logger.info("[APNs] willPresent request=\(requestID, privacy: .public) title=\(content.title, privacy: .public) body=\(content.body, privacy: .public) category=\(content.categoryIdentifier, privacy: .public) keys=\(content.userInfo.keys.map { "\($0)" }.sorted().joined(separator: ","), privacy: .public) aps=\(Self.apnsSummary(content.userInfo), privacy: .public)") + if content.userInfo["rxcodeForegroundDecrypted"] as? Bool == true { + logger.info("[APNs] willPresent decrypted foreground replacement request=\(requestID, privacy: .public)") + return [.banner, .sound, .list] + } + if let replacement = decryptedForegroundNotification(from: content, requestID: requestID) { + do { + let replacementID = "rxcode-decrypted-\(requestID)" + try await center.add(UNNotificationRequest(identifier: replacementID, content: replacement, trigger: nil)) + logger.info("[APNs] foreground encrypted notification replaced request=\(requestID, privacy: .public) replacement=\(replacementID, privacy: .public)") + return [] + } catch { + logger.error("[APNs] foreground decrypted replacement scheduling failed request=\(requestID, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } return [.banner, .sound, .list] } + + private func decryptedForegroundNotification(from content: UNNotificationContent, + requestID: String) -> UNMutableNotificationContent? { + guard let encB64 = content.userInfo["enc"] as? String else { + logger.info("[APNs] foreground notification has no enc payload request=\(requestID, privacy: .public)") + return nil + } + guard let raw = Data(base64Encoded: encB64) else { + logger.error("[APNs] foreground enc payload is not valid base64 request=\(requestID, privacy: .public) length=\(encB64.count, privacy: .public)") + return nil + } + let envelope: EncryptedAlert + do { + envelope = try JSONDecoder().decode(EncryptedAlert.self, from: raw) + } catch { + logger.error("[APNs] foreground encrypted alert decode failed request=\(requestID, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + + let accessGroup = DeviceIdentity.resolveAccessGroup(suffix: "app.rxlab.rxcodemobile.shared") + let identity: DeviceIdentity + do { + identity = try DeviceIdentity.loadOrCreate(accessGroup: accessGroup) + logger.info("[APNs] foreground identity loaded accessGroup=\(accessGroup, privacy: .public) publicKey=\(String(identity.publicKeyHex.prefix(12)), privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public)") + } catch { + logger.error("[APNs] foreground identity load failed accessGroup=\(accessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + + guard let senderRaw = Data(rxcodeHexString: envelope.from) else { + logger.error("[APNs] foreground sender public key is not hex sender=\(String(envelope.from.prefix(12)), privacy: .public)") + return nil + } + let senderKey: Curve25519.KeyAgreement.PublicKey + do { + senderKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: senderRaw) + } catch { + logger.error("[APNs] foreground sender public key parse failed sender=\(String(envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + + let plaintext: AlertPlaintext + do { + plaintext = try APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) + } catch { + logger.error("[APNs] foreground decrypt failed request=\(requestID, privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public) identity=\(String(identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + + guard let replacement = content.mutableCopy() as? UNMutableNotificationContent else { + logger.error("[APNs] foreground unable to copy notification content request=\(requestID, privacy: .public)") + return nil + } + replacement.title = plaintext.title + replacement.body = plaintext.body + var userInfo = replacement.userInfo + userInfo["rxcodeForegroundDecrypted"] = true + if let sessionID = plaintext.sessionID { userInfo["sessionId"] = sessionID } + if let projectID = plaintext.projectID { userInfo["projectId"] = projectID.uuidString } + if let kind = plaintext.kind { userInfo["kind"] = kind } + replacement.userInfo = userInfo + logger.info("[APNs] foreground decrypted notification request=\(requestID, privacy: .public) kind=\(plaintext.kind ?? "", privacy: .public) sessionID=\(plaintext.sessionID ?? "", privacy: .public)") + return replacement + } + + private static func apnsSummary(_ userInfo: [AnyHashable: Any]) -> String { + guard let aps = userInfo["aps"] as? [String: Any] else { return "" } + let mutableContent = aps["mutable-content"].map { "\($0)" } ?? "" + let contentAvailable = aps["content-available"].map { "\($0)" } ?? "" + let alertSummary: String + if let alert = aps["alert"] as? [String: Any] { + let title = alert["title"].map { "\($0)" } ?? "" + let body = alert["body"].map { "\($0)" } ?? "" + alertSummary = "title=\(title),body=\(body)" + } else { + alertSummary = "\(aps["alert"] ?? "")" + } + return "mutable-content=\(mutableContent),content-available=\(contentAvailable),alert={\(alertSummary)}" + } +} + +private extension Data { + init?(rxcodeHexString: String) { + let chars = Array(rxcodeHexString) + guard chars.count % 2 == 0 else { return nil } + var bytes: [UInt8] = [] + bytes.reserveCapacity(chars.count / 2) + var index = 0 + while index < chars.count { + guard let byte = UInt8(String(chars[index..? private var pairingTimeoutTask: Task? private var apnsTokenHex: String? private var apnsEnvironment: String? + private var clientStarted = false static let pairingTimeoutSeconds: UInt64 = 25 @@ -95,6 +96,7 @@ final class MobileAppState: ObservableObject { } private func startClient() async { + clientStarted = true if !pairedDesktopPubkey.isEmpty { try? await client.addPeer(pairedDesktopPubkey) } @@ -132,6 +134,9 @@ final class MobileAppState: ObservableObject { } else { logger.error("pairing token has invalid relayURL=\(token.relayURL, privacy: .public)") } + if !clientStarted { + await startClient() + } try? await client.addPeer(desktopHex) guard await waitForRelayConnection() else { logger.error("pairing relay connection timed out relay=\(self.relayURL.absoluteString, privacy: .public)") @@ -294,9 +299,7 @@ final class MobileAppState: ObservableObject { activeSessionID = nil pendingPermission = nil savePairedDesktop() - try? DeviceIdentity.reset( - accessGroup: Self.keychainAccessGroup - ) + await resetIdentityAndClient() } // MARK: - Inbound events @@ -328,7 +331,7 @@ final class MobileAppState: ObservableObject { pairedDesktopName = ack.desktopName isPaired = true pairingStatus = .idle - logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") MobileHaptics.connected() savePairedDesktop() Task { @@ -348,8 +351,12 @@ final class MobileAppState: ObservableObject { branchBriefings = snap.branchBriefings ?? [] threadSummaries = snap.threadSummaries ?? [] desktopSettings = snap.settings - if let active = snap.activeSessionID, let messages = snap.activeSessionMessages { - messagesBySession[active] = messages + if let active = snap.activeSessionID { + if let messages = snap.activeSessionMessages { + messagesBySession[active] = messages + } else if messagesBySession[active] == nil { + messagesBySession[active] = [] + } activeSessionID = active } case .sessionUpdate(let update): @@ -368,6 +375,22 @@ final class MobileAppState: ObservableObject { } private func applySessionUpdate(_ update: SessionUpdatePayload) { + if let previous = update.previousSessionID, previous != update.sessionID { + if let messages = messagesBySession.removeValue(forKey: previous), + messagesBySession[update.sessionID] == nil { + messagesBySession[update.sessionID] = messages + } + if activeSessionID == previous { + activeSessionID = update.sessionID + } + } + + if let summary = update.summary { + upsertSessionSummary(summary) + } else if let isStreaming = update.isStreaming { + updateSessionStreamingFlag(sessionID: update.sessionID, isStreaming: isStreaming) + } + switch update.kind { case .messageAppended: if let m = update.message { @@ -386,6 +409,34 @@ final class MobileAppState: ObservableObject { } } + private func upsertSessionSummary(_ summary: SessionSummary) { + if let index = sessions.firstIndex(where: { $0.id == summary.id }) { + sessions[index] = summary + } else { + sessions.append(summary) + } + sessions.sort { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } + return lhs.updatedAt > rhs.updatedAt + } + } + + private func updateSessionStreamingFlag(sessionID: String, isStreaming: Bool) { + guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return } + let current = sessions[index] + sessions[index] = SessionSummary( + id: current.id, + projectId: current.projectId, + title: current.title, + updatedAt: current.updatedAt, + isPinned: current.isPinned, + isArchived: current.isArchived, + isStreaming: isStreaming, + attention: current.attention, + progress: current.progress + ) + } + private func requestSnapshot() async { guard isPaired else { return } let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) @@ -438,9 +489,9 @@ final class MobileAppState: ObservableObject { let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) do { try await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) - logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(pairedDesktopPubkey.prefix(12)), privacy: .public)") + logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") } catch { - logger.error("[APNs] token report failed desktopKey=\(String(pairedDesktopPubkey.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error("[APNs] token report failed desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") } } @@ -470,12 +521,51 @@ final class MobileAppState: ObservableObject { private func loadPairedDesktop() { pairedDesktopPubkey = UserDefaults.standard.string(forKey: "mobileSync.desktopPubkey") ?? "" pairedDesktopName = UserDefaults.standard.string(forKey: "mobileSync.desktopName") ?? "" + let savedMobilePubkey = UserDefaults.standard.string(forKey: "mobileSync.mobilePubkey") + if let savedMobilePubkey, + !savedMobilePubkey.isEmpty, + savedMobilePubkey != identity.publicKeyHex { + logger.warning("[Pairing] clearing stale saved desktop pairing savedMobileKey=\(String(savedMobilePubkey.prefix(12)), privacy: .public) currentMobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + pairedDesktopPubkey = "" + pairedDesktopName = "" + savePairedDesktop() + } isPaired = !pairedDesktopPubkey.isEmpty } private func savePairedDesktop() { - UserDefaults.standard.set(pairedDesktopPubkey, forKey: "mobileSync.desktopPubkey") - UserDefaults.standard.set(pairedDesktopName, forKey: "mobileSync.desktopName") + if pairedDesktopPubkey.isEmpty { + UserDefaults.standard.removeObject(forKey: "mobileSync.desktopPubkey") + UserDefaults.standard.removeObject(forKey: "mobileSync.desktopName") + UserDefaults.standard.removeObject(forKey: "mobileSync.mobilePubkey") + } else { + UserDefaults.standard.set(pairedDesktopPubkey, forKey: "mobileSync.desktopPubkey") + UserDefaults.standard.set(pairedDesktopName, forKey: "mobileSync.desktopName") + UserDefaults.standard.set(identity.publicKeyHex, forKey: "mobileSync.mobilePubkey") + } + } + + private func resetIdentityAndClient() async { + let oldClient = client + eventTask?.cancel() + eventTask = nil + await oldClient.stop() + + do { + try DeviceIdentity.reset(accessGroup: Self.keychainAccessGroup) + identity = try DeviceIdentity.loadOrCreate(accessGroup: Self.keychainAccessGroup) + client = SyncClient(identity: identity, relayURL: relayURL) + connectionState = .disconnected + logger.info("[MobileIdentity] regenerated publicKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public) accessGroup=\(Self.keychainAccessGroup, privacy: .public)") + } catch { + logger.error("[MobileIdentity] reset failed accessGroup=\(Self.keychainAccessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") + client = SyncClient(identity: identity, relayURL: relayURL) + connectionState = .disconnected + } + + if clientStarted { + await startClient() + } } } diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 3b8f5dcb..e75e8850 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -16,32 +16,65 @@ struct MobileBriefingView: View { } var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - if groups.isEmpty { - ContentUnavailableView( - "No Briefings Yet", - systemImage: "doc.text.magnifyingglass", - description: Text("Briefings appear here after threads finish on your Mac.") - ) - .frame(maxWidth: .infinity, minHeight: 280) - } else { - ForEach(groups) { group in - briefingCard(group) + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if groups.isEmpty { + ContentUnavailableView( + "No Briefings Yet", + systemImage: "doc.text.magnifyingglass", + description: Text("Briefings appear here after threads finish on your Mac.") + ) + .frame(maxWidth: .infinity, minHeight: 320) + } else { + LazyVGrid( + columns: gridColumns(for: proxy.size.width), + alignment: .leading, + spacing: 16 + ) { + ForEach(groups) { group in + briefingCard(group) + } + } } } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, horizontalPadding(for: proxy.size.width)) + .padding(.vertical, 20) } - .padding() } .navigationTitle("Briefing") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await state.refreshSnapshot() } - } label: { - Image(systemName: "arrow.clockwise") - } - } + .refreshable { + await state.refreshSnapshot() + } + } + + private func gridColumns(for width: CGFloat) -> [GridItem] { + let minimumWidth: CGFloat + if width >= 980 { + minimumWidth = 360 + } else if width >= 700 { + minimumWidth = 320 + } else { + minimumWidth = max(260, width - (horizontalPadding(for: width) * 2)) + } + + return [ + GridItem( + .adaptive(minimum: minimumWidth, maximum: 560), + spacing: 16, + alignment: .top + ) + ] + } + + private func horizontalPadding(for width: CGFloat) -> CGFloat { + if width >= 900 { + return 24 + } else if width >= 600 { + return 20 + } else { + return 16 } } diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 076fcac0..8fdb6429 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import RxCodeChatKit import RxCodeSync /// Read-write chat view. User messages are forwarded to the desktop and the @@ -8,31 +9,36 @@ struct MobileChatView: View { @EnvironmentObject private var state: MobileAppState let sessionID: String @State private var composer: String = "" + @State private var submittedDraft = false var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { - ForEach(messages, id: \.id) { message in - MobileMessageBubble(message: message) - .id(message.id) - } + ChatMessageListView(messages: messages) + Color.clear + .frame(height: 1) + .id("message-list-bottom") } .padding(.horizontal, 16) .padding(.vertical, 8) } .scrollDismissesKeyboard(.interactively) .onChange(of: messages.last?.id) { _, _ in - if let last = messages.last?.id { - withAnimation { proxy.scrollTo(last, anchor: .bottom) } - } + withAnimation { proxy.scrollTo("message-list-bottom", anchor: .bottom) } } } Divider() MobileInputBar(text: $composer) { trimmed in Task { - await state.sendUserMessage(trimmed, sessionID: sessionID) + if let projectID = draftProjectID { + guard !submittedDraft else { return } + submittedDraft = true + await state.requestNewSession(projectID: projectID, initialText: trimmed) + } else { + await state.sendUserMessage(trimmed, sessionID: sessionID) + } composer = "" } } @@ -41,9 +47,16 @@ struct MobileChatView: View { .navigationTitle(title) } - private var messages: [ChatMessage] { state.messagesBySession[sessionID] ?? [] } + private var messages: [ChatMessage] { + draftProjectID == nil ? state.messagesBySession[sessionID] ?? [] : [] + } private var title: String { - state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" + if draftProjectID != nil { return "New Thread" } + return state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" + } + + private var draftProjectID: UUID? { + MobileDraftSessionID.projectID(from: sessionID) } } diff --git a/RxCodeMobile/Views/MobileKeyboardDismissModifier.swift b/RxCodeMobile/Views/MobileKeyboardDismissModifier.swift new file mode 100644 index 00000000..2040e4c0 --- /dev/null +++ b/RxCodeMobile/Views/MobileKeyboardDismissModifier.swift @@ -0,0 +1,9 @@ +import SwiftUI + +extension View { + func mobileDismissesKeyboardOnScroll( + _ mode: ScrollDismissesKeyboardMode = .interactively + ) -> some View { + scrollDismissesKeyboard(mode) + } +} diff --git a/RxCodeMobile/Views/MobileMessageBubble.swift b/RxCodeMobile/Views/MobileMessageBubble.swift deleted file mode 100644 index 44e2675f..00000000 --- a/RxCodeMobile/Views/MobileMessageBubble.swift +++ /dev/null @@ -1,60 +0,0 @@ -import SwiftUI -import RxCodeCore - -/// Minimal iOS message bubble. Intentionally simpler than the desktop's -/// `MessageBubble`: just text + tool-call summaries, no markdown highlighting -/// or rich diffs. The mobile companion is a glance-and-respond view; deep -/// inspection still lives on the desktop. -struct MobileMessageBubble: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .top) { - if message.role == .user { Spacer(minLength: 40) } - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(message.blocks.enumerated()), id: \.offset) { _, block in - blockView(block) - } - } - .padding(12) - .background(bubbleBackground) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - if message.role == .assistant { Spacer(minLength: 40) } - } - } - - @ViewBuilder - private func blockView(_ block: MessageBlock) -> some View { - if let text = block.text, !text.isEmpty { - Text(text) - .textSelection(.enabled) - .font(.body) - .foregroundStyle(textColor) - } else if let tool = block.toolCall { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Image(systemName: "wrench.and.screwdriver") - .font(.caption) - Text(tool.name) - .font(.caption.monospaced()) - .fontWeight(.semibold) - } - .foregroundStyle(.secondary) - if let result = tool.result, !result.isEmpty { - Text(result) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(4) - } - } - } - } - - private var bubbleBackground: Color { - message.role == .user ? Color.accentColor.opacity(0.18) : Color.gray.opacity(0.12) - } - - private var textColor: Color { - message.role == .user ? .primary : .primary - } -} diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index c3cc0fc2..fc0955f0 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -5,10 +5,15 @@ import RxCodeSync struct MobileSettingsView: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss + let showsDoneButton: Bool @State private var showUnpairConfirm = false @State private var modelDraft = "" @State private var acpClientDraft = "" + init(showsDoneButton: Bool = true) { + self.showsDoneButton = showsDoneButton + } + var body: some View { NavigationStack { Form { @@ -47,9 +52,14 @@ struct MobileSettingsView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) + .refreshable { + await state.refreshSnapshot() + } .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } + if showsDoneButton { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } } } .alert("Unpair this device?", isPresented: $showUnpairConfirm) { diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index e5a4fc64..32ca9811 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -5,9 +5,34 @@ struct ProjectsSidebar: View { @EnvironmentObject private var state: MobileAppState @Binding var selected: UUID? @Binding var showingBriefing: Bool + var showsBriefingItem = true + var usesSelection = true var body: some View { - List(selection: $selected) { + list + .navigationTitle("Projects") + .listStyle(.sidebar) + .refreshable { + await state.refreshSnapshot() + } + } + + @ViewBuilder + private var list: some View { + if usesSelection { + List(selection: $selected) { + listContent + } + } else { + List { + listContent + } + } + } + + @ViewBuilder + private var listContent: some View { + if showsBriefingItem { Button { selected = nil showingBriefing = true @@ -16,22 +41,20 @@ struct ProjectsSidebar: View { .font(.headline) .foregroundStyle(showingBriefing ? Color.accentColor : Color.primary) } + } - Section("Projects") { - ForEach(state.projects) { project in - NavigationLink(value: project.id) { - VStack(alignment: .leading, spacing: 2) { - Text(project.name).font(.headline) - Text(project.path) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Section("Projects") { + ForEach(state.projects) { project in + NavigationLink(value: project.id) { + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.headline) + Text(project.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } } } } - .navigationTitle("Projects") - .listStyle(.sidebar) } } diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 84f8f645..8faae385 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -1,14 +1,23 @@ import SwiftUI import RxCodeSync -/// Mobile app root. On iPad / wide screens this uses NavigationSplitView; on -/// iPhone it auto-collapses to a stack. +private enum MobileRootTab: Hashable { + case briefing + case projects + case settings +} + +/// Mobile app root. iPad / wide screens use NavigationSplitView; iPhone uses +/// bottom navigation with independent NavigationStack tabs. struct RootView: View { + @Environment(\.horizontalSizeClass) private var compactClass @EnvironmentObject private var state: MobileAppState @State private var selectedProject: UUID? @State private var selectedSession: String? @State private var showingBriefing = true @State private var showSettings = false + @State private var selectedTab: MobileRootTab = .briefing + @State private var projectsPath = NavigationPath() var body: some View { Group { @@ -22,22 +31,107 @@ struct RootView: View { PermissionApprovalSheet(request: req) .environmentObject(state) } + .mobileDismissesKeyboardOnScroll() } private var paired: some View { - NavigationSplitView { - ProjectsSidebar(selected: $selectedProject, showingBriefing: $showingBriefing) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { showSettings = true } label: { - Image(systemName: "gear") - } - } + Group { + if compactClass == .compact { + phoneTabs + } else { + ipadSplitView + } + } + .task { + await state.refreshSnapshot() + } + .onChange(of: state.activeSessionID) { _, newValue in + openActiveSession(newValue) + } + .onChange(of: selectedSession) { _, newValue in + guard compactClass == .compact, + let newValue, + MobileDraftSessionID.isDraft(newValue) + else { return } + projectsPath.append(newValue) + } + } + + private var phoneTabs: some View { + TabView(selection: $selectedTab) { + NavigationStack { + MobileBriefingView() + } + .tabItem { + Label("Briefing", systemImage: "doc.text") + } + .tag(MobileRootTab.briefing) + + NavigationStack(path: $projectsPath) { + ProjectsSidebar( + selected: $selectedProject, + showingBriefing: $showingBriefing, + showsBriefingItem: false, + usesSelection: false + ) + .navigationDestination(for: UUID.self) { projectID in + SessionsList( + projectID: projectID, + selected: $selectedSession, + usesSelection: false + ) } - } content: { + .navigationDestination(for: String.self) { sessionID in + chatDestination(sessionID) + } + } + .tabItem { + Label("Projects", systemImage: "folder") + } + .tag(MobileRootTab.projects) + + MobileSettingsView(showsDoneButton: false) + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(MobileRootTab.settings) + } + } + + private var ipadSplitView: some View { + Group { if showingBriefing { - MobileBriefingView() - } else if let projectID = selectedProject { + briefingSplitView + } else { + projectSplitView + } + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $showSettings) { + MobileSettingsView() + .environmentObject(state) + } + .onChange(of: selectedProject) { _, newValue in + if newValue != nil { + selectedSession = nil + showingBriefing = false + } + } + } + + private var briefingSplitView: some View { + NavigationSplitView { + projectSidebar + } detail: { + MobileBriefingView() + } + } + + private var projectSplitView: some View { + NavigationSplitView { + projectSidebar + } content: { + if let projectID = selectedProject { SessionsList(projectID: projectID, selected: $selectedSession) } else { Text("Select a project") @@ -45,25 +139,50 @@ struct RootView: View { } } detail: { if !showingBriefing, let sessionID = selectedSession { - MobileChatView(sessionID: sessionID) - .id(sessionID) + chatDestination(sessionID) } else { Text("Select a thread") .foregroundStyle(.secondary) } } - .navigationSplitViewStyle(.balanced) - .sheet(isPresented: $showSettings) { - MobileSettingsView() - .environmentObject(state) - } - .onChange(of: selectedSession) { _, newValue in - Task { await state.subscribe(to: newValue) } - } - .onChange(of: selectedProject) { _, newValue in - if newValue != nil { - showingBriefing = false + } + + private var projectSidebar: some View { + ProjectsSidebar(selected: $selectedProject, showingBriefing: $showingBriefing) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showSettings = true } label: { + Image(systemName: "gear") + } + } + } + } + + private func chatDestination(_ sessionID: String) -> some View { + MobileChatView(sessionID: sessionID) + .id(sessionID) + .task(id: sessionID) { + if !MobileDraftSessionID.isDraft(sessionID) { + await state.subscribe(to: sessionID) + } } + } + + private func openActiveSession(_ sessionID: String?) { + guard let sessionID, + let session = state.sessions.first(where: { $0.id == sessionID }) + else { return } + + selectedTab = .projects + selectedProject = session.projectId + selectedSession = sessionID + showingBriefing = false + + if compactClass == .compact { + var path = NavigationPath() + path.append(session.projectId) + path.append(sessionID) + projectsPath = path } } } diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 4ebcc487..7b485930 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -1,35 +1,38 @@ import SwiftUI +import RxCodeCore import RxCodeSync +enum MobileDraftSessionID { + private static let prefix = "draft-new" + + static func make(projectID: UUID) -> String { + "\(prefix):\(projectID.uuidString):\(UUID().uuidString)" + } + + static func projectID(from id: String) -> UUID? { + let parts = id.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0] == prefix else { return nil } + return UUID(uuidString: String(parts[1])) + } + + static func isDraft(_ id: String) -> Bool { + projectID(from: id) != nil + } +} + struct SessionsList: View { @EnvironmentObject private var state: MobileAppState let projectID: UUID @Binding var selected: String? + var usesSelection = true var body: some View { - List(filtered, selection: $selected) { session in - NavigationLink(value: session.id) { - VStack(alignment: .leading, spacing: 2) { - HStack { - if session.isPinned { - Image(systemName: "pin.fill") - .font(.caption) - .foregroundStyle(.orange) - } - Text(session.title.isEmpty ? "Untitled" : session.title) - .font(.headline) - } - Text(session.updatedAt.formatted(.relative(presentation: .named))) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } + list .navigationTitle("Threads") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { - Task { await state.requestNewSession(projectID: projectID) } + selected = MobileDraftSessionID.make(projectID: projectID) } label: { Image(systemName: "square.and.pencil") } @@ -37,9 +40,106 @@ struct SessionsList: View { } } + @ViewBuilder + private var list: some View { + if usesSelection { + List(filtered, selection: $selected) { session in + sessionLink(session) + } + } else { + List(filtered) { session in + sessionLink(session) + } + } + } + + private func sessionLink(_ session: SessionSummary) -> some View { + NavigationLink(value: session.id) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if let attention = session.attention { + statusDot(for: attention) + } + + Text(displayTitle(for: session)) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 6) + + if session.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + + if session.isStreaming { + compactProgress(for: session.progress) + } else { + Text(session.updatedAt.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + if session.isStreaming { + progressBar(for: session.progress) + } else if let attention = session.attention { + Text(attention == .question ? "Question pending" : "Permission pending") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 3) + } + } + private var filtered: [SessionSummary] { state.sessions .filter { $0.projectId == projectID && !$0.isArchived } - .sorted { $0.updatedAt > $1.updatedAt } + .sorted { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } + return lhs.updatedAt > rhs.updatedAt + } + } + + private func displayTitle(for session: SessionSummary) -> String { + let cleaned = ChatSession.stripAttachmentMarkers(from: session.title) + let resolved = cleaned.isEmpty ? ChatSession.defaultTitle : cleaned + return resolved.prefix(1).uppercased() + resolved.dropFirst() + } + + private func statusDot(for attention: SessionAttentionKind) -> some View { + Circle() + .fill(attention == .question ? Color.yellow : Color.orange) + .frame(width: 7, height: 7) + .accessibilityLabel(attention == .question ? "Question pending" : "Permission pending") + } + + @ViewBuilder + private func compactProgress(for progress: SessionProgressSnapshot?) -> some View { + if let progress, progress.total > 0 { + Text("\(progress.done)/\(progress.total)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + } + } + + @ViewBuilder + private func progressBar(for progress: SessionProgressSnapshot?) -> some View { + if let progress, progress.total > 0 { + ProgressView(value: Double(progress.done), total: Double(progress.total)) + .tint(progress.done == progress.total ? .green : .accentColor) + .accessibilityLabel("Todos \(progress.done) of \(progress.total)") + } else { + ProgressView() + .accessibilityLabel("Response in progress") + } } } diff --git a/RxCodeMobileNotificationService/Info.plist b/RxCodeMobileNotificationService/Info.plist index 360689da..9ebb8404 100644 --- a/RxCodeMobileNotificationService/Info.plist +++ b/RxCodeMobileNotificationService/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 0.1.0 + $(MARKETING_VERSION) CFBundleVersion 1 NSExtension diff --git a/RxCodeMobileNotificationService/NotificationService.swift b/RxCodeMobileNotificationService/NotificationService.swift index d682fc2a..75725d58 100644 --- a/RxCodeMobileNotificationService/NotificationService.swift +++ b/RxCodeMobileNotificationService/NotificationService.swift @@ -55,7 +55,7 @@ final class NotificationService: UNNotificationServiceExtension { contentHandler(attempt) return } - guard let senderRaw = Data(hexString: envelope.from) else { + guard let senderRaw = Data(rxcodeHexString: envelope.from) else { logger.error("[NSE] sender public key is not hex sender=\(String(envelope.from.prefix(12)), privacy: .public)") contentHandler(attempt) return @@ -95,3 +95,21 @@ final class NotificationService: UNNotificationServiceExtension { } } } + +private extension Data { + init?(rxcodeHexString: String) { + let chars = Array(rxcodeHexString) + guard chars.count % 2 == 0 else { return nil } + var bytes: [UInt8] = [] + bytes.reserveCapacity(chars.count / 2) + var index = 0 + while index < chars.count { + guard let byte = UInt8(String(chars[index.. some View { - if let text = block.text, !text.isEmpty { - Text(text) - .textSelection(.enabled) - .font(.body) - .foregroundStyle(textColor) - } else if let tool = block.toolCall { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Image(systemName: "wrench.and.screwdriver") - .font(.caption) - Text(tool.name) - .font(.caption.monospaced()) - .fontWeight(.semibold) - } - .foregroundStyle(.secondary) - if let result = tool.result, !result.isEmpty { - Text(result) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(4) - } - } - } - } - - private var bubbleBackground: Color { - message.role == .user ? Color.accentColor.opacity(0.18) : Color.gray.opacity(0.12) - } - - private var textColor: Color { - message.role == .user ? .primary : .primary - } -} From 9f811c8789948b9704505bbffb77ba9fdafaf102 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 22:14:01 +0800 Subject: [PATCH 6/8] refactor(git): consolidate message styling across views --- .../RxCodeChatKit/ChatMessageListView.swift | 13 +-- .../Sources/RxCodeChatKit/MessageBubble.swift | 13 +-- .../RxCodeChatKit/MessageListView.swift | 91 ++++++++++--------- .../RxCodeChatKit/ToolResultView.swift | 1 + 4 files changed, 63 insertions(+), 55 deletions(-) diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift index 06981633..ba59810c 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -34,7 +34,7 @@ public struct ChatMessageListView: View { extension View { func chatMessageListRowStyle() -> some View { - listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 16, trailing: 20)) + listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) } @@ -60,24 +60,25 @@ struct ChatTransientToolSummaryView: View { @State private var isExpanded = false var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 14) { Button { withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } } label: { - HStack(spacing: 6) { + HStack(spacing: 8) { Image(systemName: "eye.slash") - .font(.system(size: ClaudeTheme.messageSize(11))) + .font(.system(size: ClaudeTheme.messageSize(13))) .foregroundStyle(ClaudeTheme.textTertiary) Text(String(format: String(localized: "%lld tools executed", bundle: .module), tools.count)) - .font(.system(size: ClaudeTheme.messageSize(12))) + .font(.system(size: ClaudeTheme.messageSize(13), weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: ClaudeTheme.messageSize(9))) + .font(.system(size: ClaudeTheme.messageSize(10), weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) } .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index 6e817932..08fadb03 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -42,7 +42,7 @@ struct MessageBubble: View { Spacer(minLength: 80) } - VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 8) { + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 14) { // Show attachments if !message.attachmentPaths.isEmpty { attachmentPreview @@ -478,7 +478,7 @@ struct MessageBubble: View { @State private var expandedTransientGroupIds: Set = [] private func transientToolSummary(groupId: String, tools: [ToolCall]) -> some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 14) { Button { withAnimation(.easeInOut(duration: 0.2)) { if expandedTransientGroupIds.contains(groupId) { @@ -489,18 +489,19 @@ struct MessageBubble: View { } } label: { let isExpanded = expandedTransientGroupIds.contains(groupId) - HStack(spacing: 6) { + HStack(spacing: 8) { Image(systemName: "eye.slash") - .font(.system(size: ClaudeTheme.messageSize(11))) + .font(.system(size: ClaudeTheme.messageSize(13))) .foregroundStyle(ClaudeTheme.textTertiary) Text(String(format: String(localized: "%lld tools executed", bundle: .module), tools.count)) - .font(.system(size: ClaudeTheme.messageSize(12))) + .font(.system(size: ClaudeTheme.messageSize(13), weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: ClaudeTheme.messageSize(9))) + .font(.system(size: ClaudeTheme.messageSize(10), weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) } .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 0fe4b1e9..21af6770 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -9,7 +9,6 @@ import os struct MessageListView: View { @Environment(ChatBridge.self) private var chatBridge @Environment(WindowState.self) private var windowState - @State private var scrollPosition = ScrollPosition() @State private var settledItems: [ChatMessage] = [] @State private var scrollTask: Task? /// Separate handle from `scrollTask`. Owns the fade-in / scroll-on-switch @@ -20,54 +19,56 @@ struct MessageListView: View { @State private var isSessionReady = false private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") + private static let bottomAnchorID = "message-list-bottom-anchor" var body: some View { - List { - messageRows(settledItems[...]) - - // Streaming view is outside VStack — text deltas don't affect settled layout - if !windowState.focusMode { - StreamingMessageView { - rebuildSettledItems() - if anchor.isNearBottom { scrollToBottomDebounced() } + ScrollViewReader { proxy in + 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() } - // 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) + 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() } - .chatMessageListRowStyle() - } - if !chatBridge.isStreaming && !settledItems.isEmpty { - WebPreviewButton(messages: settledItems) - .id("web-preview") + if !chatBridge.isStreaming && !settledItems.isEmpty { + WebPreviewButton(messages: settledItems) + .id("web-preview") + .chatMessageListRowStyle() + } + + Color.clear.frame(height: 1) + .id(Self.bottomAnchorID) .chatMessageListRowStyle() } - - Color.clear.frame(height: 1) - .chatMessageListRowStyle() - } .listStyle(.plain) .contentMargins(.top, 16, for: .scrollContent) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 0) .opacity(isSessionReady ? 1 : 0) - .scrollPosition($scrollPosition) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollSample.self) { geo in ScrollSample(contentHeight: geo.contentSize.height, visibleMaxY: geo.visibleRect.maxY) @@ -78,7 +79,7 @@ struct MessageListView: View { // happened while anchored, the anchor asks us to scroll. let decision = anchor.apply(contentHeight: sample.contentHeight, visibleMaxY: sample.visibleMaxY) if decision == .scrollToBottom { - scrollToBottomDebounced() + scrollToBottomDebounced(proxy) } } .task(id: windowState.currentSessionId) { @@ -99,7 +100,6 @@ struct MessageListView: View { isSessionReady = false scrollTask?.cancel() readyTask?.cancel() - scrollPosition = ScrollPosition() rebuildSettledItems() Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") // Skip scroll/fade delay for empty sessions — appear instantly, @@ -116,7 +116,7 @@ struct MessageListView: View { return } try? await Task.sleep(for: .milliseconds(16)) // 1 frame: scroll after VStack layout is committed - scrollPosition.scrollTo(edge: .bottom) + scrollToBottom(proxy) // Pre-set isNearBottom so streaming messages that arrive before onScrollGeometryChange // fires still trigger scrollToBottomDebounced(), keeping the pulse pinned to the bottom. anchor.resetToBottom() @@ -142,7 +142,7 @@ struct MessageListView: View { readyTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(16)) guard !Task.isCancelled else { return } - scrollPosition.scrollTo(edge: .bottom) + scrollToBottom(proxy) anchor.resetToBottom() guard !isSessionReady else { return } try? await Task.sleep(for: .milliseconds(32)) @@ -154,7 +154,7 @@ struct MessageListView: View { // Only update when streaming ends — settled list doesn't change at start, so skip if old && !new { rebuildSettledItems() - scrollToBottomDebounced() + scrollToBottomDebounced(proxy) } } .onChange(of: isSessionReady) { _, new in @@ -169,6 +169,7 @@ struct MessageListView: View { .allowsHitTesting(false) } } + } } // MARK: - Helpers @@ -206,12 +207,16 @@ struct MessageListView: View { return chatSuppressPlanReadyFollowups(in: settled) } - private func scrollToBottomDebounced() { + private func scrollToBottom(_ proxy: ScrollViewProxy) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + + private func scrollToBottomDebounced(_ proxy: ScrollViewProxy) { scrollTask?.cancel() scrollTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } - scrollPosition.scrollTo(edge: .bottom) + scrollToBottom(proxy) } } } diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 25b7aafd..4461df88 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -52,6 +52,7 @@ struct ToolResultView: View { if isCardTool { cardBody .bubbleStyle(toolCall.isError ? .toolError : .tool) + .padding(.vertical, 6) .transition(.asymmetric( insertion: .opacity .combined(with: .move(edge: .top)) From d1bc0dfc1ea24eaa18b540c08bd4cbfe94d52320 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 23:24:48 +0800 Subject: [PATCH 7/8] fix: sidebar, and mobile style --- .../RxCodeChatKit/ChatMessageBubble.swift | 2 - .../RxCodeChatKit/ChatMessageListView.swift | 2 +- .../Sources/RxCodeChatKit/InputBarView.swift | 32 +- .../Sources/RxCodeChatKit/MarkdownView.swift | 18 +- .../RxCodeChatKit/ToolResultView.swift | 17 +- .../RxCodeCore/Views/SessionSidebarRow.swift | 89 +++ .../Views}/TypewriterTitleText.swift | 17 +- .../Sources/RxCodeSync/Protocol/Payload.swift | 253 ++++++++- .../InputBarQueueTests.swift | 68 --- .../RunTaskExecutorTests.swift | 4 +- RxCode/App/AppState.swift | 419 ++++++++++++++- RxCode/Services/MobileSyncService.swift | 32 ++ RxCode/Services/NotificationService.swift | 5 +- RxCode/Views/Sidebar/HistoryListView.swift | 72 +-- RxCodeMobile/RxCodeMobileApp.swift | 3 + RxCodeMobile/State/MobileAppState.swift | 152 +++++- .../Views/MobileBriefingDetailView.swift | 156 ++++++ RxCodeMobile/Views/MobileBriefingView.swift | 177 +++--- RxCodeMobile/Views/MobileChatView.swift | 242 ++++++++- RxCodeMobile/Views/MobileInputBar.swift | 195 ++++++- RxCodeMobile/Views/NewThreadSheet.swift | 508 ++++++++++++++++++ RxCodeMobile/Views/ProjectsSidebar.swift | 129 ++++- RxCodeMobile/Views/RootView.swift | 10 +- RxCodeMobile/Views/SessionsList.swift | 106 ++-- relay-server/push.go | 22 +- 25 files changed, 2330 insertions(+), 400 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift rename {RxCode/Views/Sidebar => Packages/Sources/RxCodeCore/Views}/TypewriterTitleText.swift (84%) delete mode 100644 Packages/Tests/RxCodeChatKitTests/InputBarQueueTests.swift create mode 100644 RxCodeMobile/Views/MobileBriefingDetailView.swift create mode 100644 RxCodeMobile/Views/NewThreadSheet.swift diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift index 3b67b6ef..66d09f9f 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift @@ -95,8 +95,6 @@ private struct CompactChatMessageBubble: View { color: ClaudeTheme.userBubbleText, lineSpacing: 2 ) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) .bubbleStyle(.user) .frame(maxWidth: 500, alignment: .trailing) } diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift index ba59810c..8e4678a7 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -34,7 +34,7 @@ public struct ChatMessageListView: View { extension View { func chatMessageListRowStyle() -> some View { - listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)) + listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) } diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift index cf2ecc64..d904b5a3 100644 --- a/Packages/Sources/RxCodeChatKit/InputBarView.swift +++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift @@ -129,23 +129,13 @@ struct InputBarView: View { } .onChange(of: windowState.currentSessionId) { _, _ in historyIndex = -1 - // Defer to next MainActor iteration so the bridge observation has time to - // update isStreaming to reflect the newly-active session before we check it. - Task { @MainActor in - if !chatBridge.isStreaming { - processNextQueued() - } - } + // Queue auto-flush is owned by AppState (`flushNextQueuedMessageIfNeeded`) + // so the macOS and mobile paths share one arbiter — no duplicate sends. Task { @MainActor in try? await Task.sleep(for: .milliseconds(300)) inputFocusTrigger = UUID() } } - .onChange(of: chatBridge.isStreaming) { _, isStreaming in - if !isStreaming { - processNextQueued() - } - } .onAppear { Task { @MainActor in try? await Task.sleep(for: .milliseconds(300)) @@ -832,24 +822,6 @@ struct InputBarView: View { resetIMEState() } - private func processNextQueued() { - guard let next = chatBridge.dequeueNextForFlush() else { return } - let draftText = windowState.inputText - let draftAttachments = windowState.attachments - let shouldRestoreDraft = !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || !draftAttachments.isEmpty - - windowState.inputText = next.text - windowState.attachments = next.attachments - Task { - await chatBridge.send() - if shouldRestoreDraft { - windowState.inputText = draftText - windowState.attachments = draftAttachments - } - } - } - private func handleReturnKey() { // IMETextView dispatches doCommand(insertNewline:) only after the IME has finalized any // composing text, so windowState.inputText is already up-to-date here. diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 6e17d02b..2ba7e589 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -629,15 +629,25 @@ struct NativeChatTextView: NSViewRepresentable { } func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: InlineCodeTextView, context: Context) -> CGSize? { - let width = proposal.width ?? 500 guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { - return CGSize(width: width, height: 0) + return CGSize(width: proposal.width ?? 0, height: 0) } - textContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + textContainer.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + layoutManager.ensureLayout(for: textContainer) + let idealWidth = ceil(layoutManager.usedRect(for: textContainer).width) + + let resolvedWidth: CGFloat + if let proposed = proposal.width, proposed.isFinite { + resolvedWidth = min(proposed, idealWidth) + } else { + resolvedWidth = idealWidth + } + + textContainer.containerSize = NSSize(width: resolvedWidth, height: CGFloat.greatestFiniteMagnitude) layoutManager.ensureLayout(for: textContainer) let usedRect = layoutManager.usedRect(for: textContainer) - return CGSize(width: width, height: ceil(usedRect.height)) + return CGSize(width: resolvedWidth, height: ceil(usedRect.height)) } private static func nsAttributedString( diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 4461df88..cd579fa8 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -475,11 +475,9 @@ struct ToolResultView: View { let filePath = toolCall.editedFilePath { let fileName = URL(fileURLWithPath: filePath).lastPathComponent HStack(spacing: 0) { - ChatTextContentView( - "\(toolDescriptionPrefix) — ", - size: ClaudeTheme.messageSize(12), - color: ClaudeTheme.textSecondary - ) + Text("\(toolDescriptionPrefix) — ") + .font(.system(size: ClaudeTheme.messageSize(12))) + .foregroundStyle(ClaudeTheme.textSecondary) fileActionLink(label: fileName, color: ClaudeTheme.accent) { windowState.inspectorFile = PreviewFile(path: filePath, name: fileName) } @@ -487,11 +485,9 @@ struct ToolResultView: View { let hunks = editHunksFromToolInput() if !hunks.isEmpty { HStack(spacing: 0) { - ChatTextContentView( - " · ", - size: ClaudeTheme.messageSize(12), - color: ClaudeTheme.textTertiary - ) + Text(" · ") + .font(.system(size: ClaudeTheme.messageSize(12))) + .foregroundStyle(ClaudeTheme.textTertiary) fileActionLink(label: "diff") { windowState.diffFile = PreviewFile( path: filePath, @@ -503,6 +499,7 @@ struct ToolResultView: View { .transition(.opacity.combined(with: .scale(scale: 0.85, anchor: .leading))) } } + Spacer(minLength: 0) } .animation(.easeOut(duration: 0.25), value: toolCall.result != nil) } else { diff --git a/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift b/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift new file mode 100644 index 00000000..eb046e07 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Shared sidebar row used by the macOS history list and the iOS / iPadOS +/// threads list. Pure presentation: callers supply a value-typed model. +public struct SessionSidebarRow: View { + public let title: String + public let projectName: String? + public let updatedAt: Date + public let isPinned: Bool + public let isBackgroundStreaming: Bool + + public init( + title: String, + projectName: String? = nil, + updatedAt: Date, + isPinned: Bool = false, + isBackgroundStreaming: Bool = false + ) { + self.title = title + self.projectName = projectName + self.updatedAt = updatedAt + self.isPinned = isPinned + self.isBackgroundStreaming = isBackgroundStreaming + } + + public var body: some View { + HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 3) { + TypewriterTitleText(title: title.prefix(1).uppercased() + title.dropFirst()) + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(.primary.opacity(0.8)) + .lineLimit(1) + .contentTransition(.opacity) + .animation(.easeInOut(duration: 0.25), value: title) + + HStack(spacing: 4) { + if let projectName { + Text(projectName) + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + .foregroundStyle(ClaudeTheme.accent.opacity(0.8)) + .lineLimit(1) + + Text("·") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.tertiary) + } + + Text(Self.compactElapsed(since: updatedAt)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.tertiary) + } + } + + Spacer() + + if isBackgroundStreaming { + ProgressView() + .controlSize(.mini) + .help("Response in progress in the background") + } + + if isPinned { + Image(systemName: "pin.fill") + .font(.system(size: ClaudeTheme.size(9))) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .padding(.vertical, 2) + } + + public static func compactElapsed(since date: Date, now: Date = Date()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { return "0m" } + + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + + let days = hours / 24 + if days < 7 { return "\(days)d" } + + let weeks = days / 7 + if weeks < 52 { return "\(weeks)w" } + + return "\(days / 365)y" + } +} diff --git a/RxCode/Views/Sidebar/TypewriterTitleText.swift b/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift similarity index 84% rename from RxCode/Views/Sidebar/TypewriterTitleText.swift rename to Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift index 5d6a4896..308f9c10 100644 --- a/RxCode/Views/Sidebar/TypewriterTitleText.swift +++ b/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift @@ -1,10 +1,9 @@ import SwiftUI -import RxCodeCore /// Animates a text swap by erasing the current value character-by-character /// (cursor moving right→left) and then typing the new value left→right. /// No animation runs on the first appearance — the initial value is shown immediately. -struct TypewriterTitleText: View { +public struct TypewriterTitleText: View { let title: String var charInterval: Duration = .milliseconds(18) var pauseBetween: Duration = .milliseconds(120) @@ -15,7 +14,19 @@ struct TypewriterTitleText: View { @State private var hasAppeared: Bool = false @State private var animationTask: Task? - var body: some View { + public init( + title: String, + charInterval: Duration = .milliseconds(18), + pauseBetween: Duration = .milliseconds(120), + holdAfter: Duration = .milliseconds(180) + ) { + self.title = title + self.charInterval = charInterval + self.pauseBetween = pauseBetween + self.holdAfter = holdAfter + } + + public var body: some View { HStack(alignment: .firstTextBaseline, spacing: 0) { Text(displayed) if isAnimating { diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index ae3a8638..c201172b 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -17,10 +17,16 @@ public enum Payload: Sendable { case sessionUpdate(SessionUpdatePayload) case subscribeSession(SubscribeSessionPayload) case userMessage(UserMessagePayload) + case cancelStream(CancelStreamPayload) + case removeQueuedMessage(RemoveQueuedMessagePayload) case newSessionRequest(NewSessionRequestPayload) + case searchRequest(SearchRequestPayload) + case searchResults(SearchResultsPayload) case notification(NotificationPayload) case permissionRequest(PermissionRequestPayload) case permissionResponse(PermissionResponsePayload) + case branchOpRequest(BranchOpRequestPayload) + case branchOpResult(BranchOpResultPayload) case ping(PingPayload) case pong(PongPayload) case unknown(type: String) @@ -83,6 +89,11 @@ public struct SnapshotPayload: Codable, Sendable { public let settings: MobileSettingsSnapshot? public let activeSessionID: String? public let activeSessionMessages: [ChatMessage]? + /// Current git branch resolved per project on the desktop. Mobile uses this + /// to surface "you're about to create a thread on branch X" when starting + /// a new thread. Missing entries mean the project isn't a git repo or the + /// branch couldn't be resolved. + public let projectBranches: [ProjectBranchInfo]? public init( projects: [Project], sessions: [SessionSummary], @@ -90,7 +101,8 @@ public struct SnapshotPayload: Codable, Sendable { threadSummaries: [MobileThreadSummary]? = nil, settings: MobileSettingsSnapshot? = nil, activeSessionID: String? = nil, - activeSessionMessages: [ChatMessage]? = nil + activeSessionMessages: [ChatMessage]? = nil, + projectBranches: [ProjectBranchInfo]? = nil ) { self.projects = projects self.sessions = sessions @@ -99,6 +111,98 @@ public struct SnapshotPayload: Codable, Sendable { self.settings = settings self.activeSessionID = activeSessionID self.activeSessionMessages = activeSessionMessages + self.projectBranches = projectBranches + } + + private enum CodingKeys: String, CodingKey { + case projects, sessions, branchBriefings, threadSummaries, settings + case activeSessionID, activeSessionMessages, projectBranches + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + projects = try c.decode([Project].self, forKey: .projects) + sessions = try c.decode([SessionSummary].self, forKey: .sessions) + branchBriefings = try c.decodeIfPresent([MobileBranchBriefing].self, forKey: .branchBriefings) + threadSummaries = try c.decodeIfPresent([MobileThreadSummary].self, forKey: .threadSummaries) + settings = try c.decodeIfPresent(MobileSettingsSnapshot.self, forKey: .settings) + activeSessionID = try c.decodeIfPresent(String.self, forKey: .activeSessionID) + activeSessionMessages = try c.decodeIfPresent([ChatMessage].self, forKey: .activeSessionMessages) + projectBranches = try c.decodeIfPresent([ProjectBranchInfo].self, forKey: .projectBranches) + } +} + +public struct ProjectBranchInfo: Codable, Sendable, Equatable { + public let projectId: UUID + public let currentBranch: String + /// All local branches the desktop discovered via `git branch --list`. + /// Optional for backward compatibility with older desktops that only sent + /// the current branch. + public let availableBranches: [String]? + + public init(projectId: UUID, currentBranch: String, availableBranches: [String]? = nil) { + self.projectId = projectId + self.currentBranch = currentBranch + self.availableBranches = availableBranches + } + + private enum CodingKeys: String, CodingKey { + case projectId, currentBranch, availableBranches + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + projectId = try c.decode(UUID.self, forKey: .projectId) + currentBranch = try c.decode(String.self, forKey: .currentBranch) + availableBranches = try c.decodeIfPresent([String].self, forKey: .availableBranches) + } +} + +/// Mobile-initiated request to either switch to an existing local branch in +/// the project root, or create a new branch (and its worktree) on the desktop. +/// The desktop responds with `BranchOpResultPayload` carrying success/error, +/// then broadcasts a fresh snapshot so mobile sees the new branch state. +public struct BranchOpRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case switchExisting + case createNew + } + + public let clientRequestID: UUID + public let projectID: UUID + public let operation: Operation + public let branch: String + + public init(clientRequestID: UUID = UUID(), projectID: UUID, operation: Operation, branch: String) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.operation = operation + self.branch = branch + } +} + +public struct BranchOpResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let operation: BranchOpRequestPayload.Operation + public let branch: String + public let ok: Bool + public let errorMessage: String? + + public init( + clientRequestID: UUID, + projectID: UUID, + operation: BranchOpRequestPayload.Operation, + branch: String, + ok: Bool, + errorMessage: String? = nil + ) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.operation = operation + self.branch = branch + self.ok = ok + self.errorMessage = errorMessage } } @@ -161,6 +265,10 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { public let archiveRetentionDays: Int public let autoPreviewSettings: AttachmentAutoPreviewSettings public let availableEfforts: [String] + /// All agent models discovered on the desktop, flattened across providers + /// and ACP clients. Lets mobile show a model picker without re-deriving the + /// list itself. Optional for backward compatibility with older desktops. + public let availableModels: [AgentModel]? public init( selectedAgentProvider: AgentProvider, @@ -177,7 +285,8 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { autoArchiveEnabled: Bool, archiveRetentionDays: Int, autoPreviewSettings: AttachmentAutoPreviewSettings, - availableEfforts: [String] + availableEfforts: [String], + availableModels: [AgentModel]? = nil ) { self.selectedAgentProvider = selectedAgentProvider self.selectedModel = selectedModel @@ -194,6 +303,35 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { self.archiveRetentionDays = archiveRetentionDays self.autoPreviewSettings = autoPreviewSettings self.availableEfforts = availableEfforts + self.availableModels = availableModels + } + + private enum CodingKeys: String, CodingKey { + case selectedAgentProvider, selectedModel, selectedACPClientId, selectedEffort + case permissionMode, summarizationProvider, summarizationProviderDisplayName + case openAISummarizationEndpoint, openAISummarizationModel + case notificationsEnabled, focusMode, autoArchiveEnabled, archiveRetentionDays + case autoPreviewSettings, availableEfforts, availableModels + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + selectedAgentProvider = try c.decode(AgentProvider.self, forKey: .selectedAgentProvider) + selectedModel = try c.decode(String.self, forKey: .selectedModel) + selectedACPClientId = try c.decode(String.self, forKey: .selectedACPClientId) + selectedEffort = try c.decode(String.self, forKey: .selectedEffort) + permissionMode = try c.decode(PermissionMode.self, forKey: .permissionMode) + summarizationProvider = try c.decode(String.self, forKey: .summarizationProvider) + summarizationProviderDisplayName = try c.decode(String.self, forKey: .summarizationProviderDisplayName) + openAISummarizationEndpoint = try c.decode(String.self, forKey: .openAISummarizationEndpoint) + openAISummarizationModel = try c.decode(String.self, forKey: .openAISummarizationModel) + notificationsEnabled = try c.decode(Bool.self, forKey: .notificationsEnabled) + focusMode = try c.decode(Bool.self, forKey: .focusMode) + autoArchiveEnabled = try c.decode(Bool.self, forKey: .autoArchiveEnabled) + archiveRetentionDays = try c.decode(Int.self, forKey: .archiveRetentionDays) + autoPreviewSettings = try c.decode(AttachmentAutoPreviewSettings.self, forKey: .autoPreviewSettings) + availableEfforts = try c.decode([String].self, forKey: .availableEfforts) + availableModels = try c.decodeIfPresent([AgentModel].self, forKey: .availableModels) } } @@ -261,6 +399,10 @@ public struct SessionSummary: Codable, Sendable, Identifiable { public let isStreaming: Bool public let attention: SessionAttentionKind? public let progress: SessionProgressSnapshot? + /// 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. + public let queuedMessages: [QueuedUserMessage]? public init( id: String, @@ -271,7 +413,8 @@ public struct SessionSummary: Codable, Sendable, Identifiable { isArchived: Bool, isStreaming: Bool = false, attention: SessionAttentionKind? = nil, - progress: SessionProgressSnapshot? = nil + progress: SessionProgressSnapshot? = nil, + queuedMessages: [QueuedUserMessage]? = nil ) { self.id = id self.projectId = projectId @@ -282,10 +425,11 @@ public struct SessionSummary: Codable, Sendable, Identifiable { self.isStreaming = isStreaming self.attention = attention self.progress = progress + self.queuedMessages = queuedMessages } private enum CodingKeys: String, CodingKey { - case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, queuedMessages } public init(from decoder: Decoder) throws { @@ -299,6 +443,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) + queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) } } @@ -350,6 +495,36 @@ public struct UserMessagePayload: Codable, Sendable { } } +public struct CancelStreamPayload: Codable, Sendable { + public let sessionID: String + public init(sessionID: String) { + self.sessionID = sessionID + } +} + +/// A user message that's waiting for the active turn to finish before being +/// sent to the agent. Mirrored to mobile via `SessionSummary.queuedMessages`. +public struct QueuedUserMessage: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + public let text: String + public init(id: UUID, text: String) { + self.id = id + self.text = text + } +} + +/// Asks the desktop to drop the queued message from threadStore. Used by the +/// mobile UI when the user swipes a queued row away. The desktop never tries +/// to send the message after this point. +public struct RemoveQueuedMessagePayload: Codable, Sendable { + public let sessionID: String + public let queuedMessageID: UUID + public init(sessionID: String, queuedMessageID: UUID) { + self.sessionID = sessionID + self.queuedMessageID = queuedMessageID + } +} + public struct NewSessionRequestPayload: Codable, Sendable { public let clientRequestID: UUID public let projectID: UUID @@ -361,6 +536,58 @@ public struct NewSessionRequestPayload: Codable, Sendable { } } +/// Mobile-initiated search across all threads and projects. The desktop is +/// the authoritative source of session content, so the heavy lifting (semantic +/// matching, title/project lookups) lives there; mobile just renders results. +public struct SearchRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let query: String + public let limit: Int + public init(clientRequestID: UUID = UUID(), query: String, limit: Int = 25) { + self.clientRequestID = clientRequestID + self.query = query + self.limit = limit + } +} + +public struct SearchHit: Codable, Sendable, Identifiable, Equatable { + public let sessionID: String + public let projectID: UUID + public let title: String + public let snippet: String + public let updatedAt: Date + public let score: Float + public var id: String { sessionID } + public init( + sessionID: String, + projectID: UUID, + title: String, + snippet: String, + updatedAt: Date, + score: Float + ) { + self.sessionID = sessionID + self.projectID = projectID + self.title = title + self.snippet = snippet + self.updatedAt = updatedAt + self.score = score + } +} + +public struct SearchResultsPayload: Codable, Sendable { + public let clientRequestID: UUID + public let query: String + public let projectIDs: [UUID] + public let threadHits: [SearchHit] + public init(clientRequestID: UUID, query: String, projectIDs: [UUID], threadHits: [SearchHit]) { + self.clientRequestID = clientRequestID + self.query = query + self.projectIDs = projectIDs + self.threadHits = threadHits + } +} + public struct NotificationPayload: Codable, Sendable { public enum Kind: String, Codable, Sendable { case responseComplete @@ -442,10 +669,16 @@ extension Payload: Codable { case sessionUpdate = "session_update" case subscribeSession = "subscribe_session" case userMessage = "user_message" + case cancelStream = "cancel_stream" + case removeQueuedMessage = "remove_queued_message" case newSessionRequest = "new_session_request" + case searchRequest = "search_request" + case searchResults = "search_results" case notification case permissionRequest = "permission_request" case permissionResponse = "permission_response" + case branchOpRequest = "branch_op_request" + case branchOpResult = "branch_op_result" case ping case pong } @@ -468,10 +701,16 @@ extension Payload: Codable { case .sessionUpdate: self = .sessionUpdate(try container.decode(SessionUpdatePayload.self, forKey: .data)) case .subscribeSession: self = .subscribeSession(try container.decode(SubscribeSessionPayload.self, forKey: .data)) case .userMessage: self = .userMessage(try container.decode(UserMessagePayload.self, forKey: .data)) + case .cancelStream: self = .cancelStream(try container.decode(CancelStreamPayload.self, forKey: .data)) + case .removeQueuedMessage: self = .removeQueuedMessage(try container.decode(RemoveQueuedMessagePayload.self, forKey: .data)) case .newSessionRequest: self = .newSessionRequest(try container.decode(NewSessionRequestPayload.self, forKey: .data)) + case .searchRequest: self = .searchRequest(try container.decode(SearchRequestPayload.self, forKey: .data)) + case .searchResults: self = .searchResults(try container.decode(SearchResultsPayload.self, forKey: .data)) case .notification: self = .notification(try container.decode(NotificationPayload.self, forKey: .data)) case .permissionRequest: self = .permissionRequest(try container.decode(PermissionRequestPayload.self, forKey: .data)) case .permissionResponse: self = .permissionResponse(try container.decode(PermissionResponsePayload.self, forKey: .data)) + case .branchOpRequest: self = .branchOpRequest(try container.decode(BranchOpRequestPayload.self, forKey: .data)) + case .branchOpResult: self = .branchOpResult(try container.decode(BranchOpResultPayload.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)) } @@ -490,10 +729,16 @@ extension Payload: Codable { case .sessionUpdate(let p): try container.encode(TypeKey.sessionUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .subscribeSession(let p): try container.encode(TypeKey.subscribeSession.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .userMessage(let p): try container.encode(TypeKey.userMessage.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .cancelStream(let p): try container.encode(TypeKey.cancelStream.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .removeQueuedMessage(let p): try container.encode(TypeKey.removeQueuedMessage.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .newSessionRequest(let p): try container.encode(TypeKey.newSessionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .searchRequest(let p): try container.encode(TypeKey.searchRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .searchResults(let p): try container.encode(TypeKey.searchResults.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .notification(let p): try container.encode(TypeKey.notification.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .permissionRequest(let p): try container.encode(TypeKey.permissionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .permissionResponse(let p): try container.encode(TypeKey.permissionResponse.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .branchOpRequest(let p): try container.encode(TypeKey.branchOpRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .branchOpResult(let p): try container.encode(TypeKey.branchOpResult.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/Tests/RxCodeChatKitTests/InputBarQueueTests.swift b/Packages/Tests/RxCodeChatKitTests/InputBarQueueTests.swift deleted file mode 100644 index 019a2396..00000000 --- a/Packages/Tests/RxCodeChatKitTests/InputBarQueueTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Testing -import SwiftUI -import ViewInspector -import RxCodeCore -@testable import RxCodeChatKit - -@MainActor -@Suite("InputBar queue interactions") -struct InputBarQueueTests { - - @Test("Draft typed while queued message auto-flushes is preserved") - func draftTypedWhileQueuedMessageAutoFlushesIsPreserved() async throws { - let window = WindowState() - let bridge = ChatBridge() - bridge.isStreaming = true - - var sentPrompts: [String] = [] - bridge.enqueueMessageHandler = { text, attachments in - window.enqueueMessage(text: text, attachments: attachments) - } - bridge.dequeueNextForFlushHandler = { - window.dequeueNext() - } - bridge.sendHandler = { - sentPrompts.append(window.inputText) - window.inputText = "" - window.attachments = [] - bridge.isStreaming = true - } - - let sut = InputBarView( - windowState: window, - chatBridge: bridge, - accessory: EmptyView() - ) { - EmptyView() - } - - ViewHosting.host(view: sut) - defer { ViewHosting.expel() } - - try type("queued turn", in: sut) - try sut.inspect() - .find(viewWithAccessibilityIdentifier: "chat-send-button") - .button() - .tap() - - #expect(window.messageQueue.map(\.text) == ["queued turn"]) - #expect(window.inputText.isEmpty) - - try type("draft typed during queued processing", in: sut) - bridge.isStreaming = false - try await Task.sleep(for: .milliseconds(100)) - - #expect(sentPrompts == ["queued turn"]) - #expect(window.messageQueue.isEmpty) - #expect(window.inputText == "draft typed during queued processing") - try await Task.sleep(for: .milliseconds(350)) - } - - private func type(_ text: String, in view: V) throws { - let input = try view.inspect() - .find(IMETextView.self) - .actualView() - input.text = text - } - -} diff --git a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift index ab4cb1de..9e7a44e7 100644 --- a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift +++ b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift @@ -256,7 +256,9 @@ struct RunTaskExecutorTests { make: MakeRunConfig(makefile: "BuildScripts/release.mk", target: "all", arguments: "VAR=1 -j4") ) let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") - #expect(script.contains("make -f 'BuildScripts/release.mk' 'all' VAR=1 -j4")) + // Project-relative Makefile paths are resolved against the project root + // so `make -f` doesn't re-resolve them against a subdir working directory. + #expect(script.contains("make -f '/p/BuildScripts/release.mk' 'all' VAR=1 -j4")) } @Test("Make profile with empty config emits no main command") diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 52a1511e..d8cd5233 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -944,8 +944,33 @@ final class AppState { let runService = RunService() let ideMCPServer = IDEMCPServer() private var mobileSyncObservers: [NSObjectProtocol] = [] + + /// Weak refs to every `WindowState` that's been wired up via `setupChatBridge`. + /// Used by AppState-driven queue maintenance (e.g. `flushNextQueuedMessageIfNeeded`) + /// to scrub stale entries out of each window's in-memory queue mirror. + private struct WeakWindowRef { weak var window: WindowState? } + private var liveWindowRefs: [WeakWindowRef] = [] + + private func registerLiveWindow(_ window: WindowState) { + liveWindowRefs.removeAll { $0.window == nil || $0.window === window } + liveWindowRefs.append(WeakWindowRef(window: window)) + } + + private func registeredWindows() -> [WindowState] { + liveWindowRefs.removeAll { $0.window == nil } + return liveWindowRefs.compactMap(\.window) + } private var mobileSnapshotBroadcastTask: Task? + /// Worktrees freshly created by a mobile "create branch" request, keyed by + /// project. Consumed by the next mobile new-session request for the same + /// project so the thread spawns into the new worktree. + private struct MobilePendingWorktree { + let path: String + let branch: String + } + private var mobilePendingWorktrees: [UUID: MobilePendingWorktree] = [:] + // MARK: - Run Profiles /// Loaded lazily per project. Keyed by `Project.id`. @@ -1086,6 +1111,32 @@ final class AppState { } mobileSyncObservers.append(userMessageObserver) + let cancelStreamObserver = center.addObserver( + forName: .mobileSyncCancelStreamRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let cancel = notification.userInfo?["payload"] as? CancelStreamPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileCancelStream(cancel) + } + } + mobileSyncObservers.append(cancelStreamObserver) + + let removeQueuedObserver = center.addObserver( + forName: .mobileSyncRemoveQueuedRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let payload = notification.userInfo?["payload"] as? RemoveQueuedMessagePayload + else { return } + Task { @MainActor [weak self] in + self?.handleMobileRemoveQueuedMessage(payload) + } + } + mobileSyncObservers.append(removeQueuedObserver) + let newSessionObserver = center.addObserver( forName: .mobileSyncNewSessionRequested, object: nil, @@ -1100,6 +1151,34 @@ final class AppState { } mobileSyncObservers.append(newSessionObserver) + let searchObserver = center.addObserver( + forName: .mobileSyncSearchRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SearchRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSearchRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(searchObserver) + + let branchOpObserver = center.addObserver( + forName: .mobileSyncBranchOpRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? BranchOpRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileBranchOpRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(branchOpObserver) + observeMobileSnapshotInputs() } @@ -1143,10 +1222,97 @@ final class AppState { let window = WindowState() window.selectedProject = project + if let pending = mobilePendingWorktrees.removeValue(forKey: project.id) { + window.pendingWorktreePath = pending.path + window.pendingWorktreeBranch = pending.branch + } _ = await sendPrompt(initialText, displayText: initialText, in: window) await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) } + private func handleMobileBranchOpRequest(_ request: BranchOpRequestPayload, fromHex: String) async { + guard let project = projects.first(where: { $0.id == request.projectID }) else { + await replyBranchOpResult( + request: request, + ok: false, + errorMessage: "Project not found on desktop.", + toHex: fromHex + ) + return + } + + let trimmed = request.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + await replyBranchOpResult( + request: request, + ok: false, + errorMessage: "Branch name is required.", + toHex: fromHex + ) + return + } + + let window = WindowState() + window.selectedProject = project + + do { + switch request.operation { + case .switchExisting: + try await switchToExistingBranch(trimmed, in: window) + case .createNew: + try await attachWorktree(branch: trimmed, in: window) + if let path = window.pendingWorktreePath, + let branch = window.pendingWorktreeBranch + { + mobilePendingWorktrees[project.id] = MobilePendingWorktree(path: path, branch: branch) + } + } + } catch { + await replyBranchOpResult( + request: request, + ok: false, + errorMessage: branchOpErrorMessage(error), + toHex: fromHex + ) + return + } + + await replyBranchOpResult(request: request, ok: true, errorMessage: nil, toHex: fromHex) + scheduleMobileSnapshotBroadcast() + } + + private func replyBranchOpResult( + request: BranchOpRequestPayload, + ok: Bool, + errorMessage: String?, + toHex hex: String + ) async { + let result = BranchOpResultPayload( + clientRequestID: request.clientRequestID, + projectID: request.projectID, + operation: request.operation, + branch: request.branch, + ok: ok, + errorMessage: errorMessage + ) + await MobileSyncService.shared.send(.branchOpResult(result), toHex: hex) + } + + private func branchOpErrorMessage(_ error: Error) -> String { + if let werr = error as? GitWorktreeService.WorktreeError { + return werr.description + } + return error.localizedDescription + } + + private func handleMobileCancelStream(_ cancel: CancelStreamPayload) async { + let sessionID = cancel.sessionID + guard sessionStates[sessionID]?.isStreaming == true else { return } + let window = WindowState() + window.currentSessionId = sessionID + await cancelStreaming(in: window) + } + private func handleMobileUserMessage(_ message: UserMessagePayload, fromHex: String) async { let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } @@ -1163,6 +1329,17 @@ final class AppState { await hydrateMobileSessionIfNeeded(summary: summary, project: project) updateMobilePlaceholderTitleIfNeeded(sessionID: sessionID, firstUserMessage: text) + // If a turn is already streaming, the mobile message goes into the + // shared (threadStore-backed) queue so it flushes once the current turn + // ends — same behavior as the macOS InputBarView's enqueue path. + if sessionStates[sessionID]?.isStreaming == true { + let queued = QueuedMessage(text: text, attachments: []) + threadStore.appendQueued(sessionKey: sessionID, message: queued) + appendToWindowQueueMirrors(sessionID: sessionID, message: queued) + broadcastMobileSessionStatus(sessionID: sessionID) + return + } + let window = WindowState() window.selectedProject = project window.currentSessionId = sessionID @@ -1173,6 +1350,79 @@ final class AppState { await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) } + private func handleMobileRemoveQueuedMessage(_ payload: RemoveQueuedMessagePayload) { + threadStore.removeQueued(id: payload.queuedMessageID) + evictFromWindowQueueMirrors(sessionID: payload.sessionID, queuedID: payload.queuedMessageID) + broadcastMobileSessionStatus(sessionID: payload.sessionID) + } + + /// Flushes the next queued message from threadStore as a new user turn. + /// AppState is the single auto-flush authority — both macOS-side and + /// mobile-side queued items are flushed here, so the two flows never + /// race on duplicate sends. Triggered at the tail of `finalizeStreamSession`. + /// + /// Any macOS window currently viewing the session keeps a mirror copy of + /// the queue in `window.messageQueue`; clear the popped entry from those + /// mirrors so the UI doesn't show a stale queued row. + private func flushNextQueuedMessageIfNeeded(sessionID: String) async { + let queue = threadStore.loadQueue(sessionKey: sessionID) + guard let next = queue.first else { return } + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }), + let project = projects.first(where: { $0.id == summary.projectId }) + else { return } + + threadStore.removeQueued(id: next.id) + evictFromWindowQueueMirrors(sessionID: sessionID, queuedID: next.id) + broadcastMobileSessionStatus(sessionID: sessionID) + + let window = WindowState() + window.selectedProject = project + window.currentSessionId = sessionID + _ = await sendPrompt( + next.text, + displayText: next.text, + attachments: next.attachments, + in: window + ) + } + + /// Mirror of `enqueueMessage` for queue items appended by AppState itself + /// (e.g. when a mobile-sent message arrives while the session is streaming). + /// Every desktop window currently viewing the session keeps an in-memory + /// copy in `messageQueue` and `draftQueues[sessionID]`; push the new entry + /// into those mirrors so the chat UI shows the queued row immediately, + /// matching the macOS enqueue path. + private func appendToWindowQueueMirrors(sessionID: String, message: QueuedMessage) { + for window in registeredWindows() where window.currentSessionId == sessionID { + if !window.messageQueue.contains(where: { $0.id == message.id }) { + window.messageQueue.append(message) + } + var mirror = window.draftQueues[sessionID] ?? [] + if !mirror.contains(where: { $0.id == message.id }) { + mirror.append(message) + } + window.draftQueues[sessionID] = mirror + } + } + + /// macOS windows hold an in-memory mirror of the queue in `messageQueue` and + /// in `draftQueues[sessionID]`. When AppState pops an entry from threadStore + /// (auto-flush or remote remove), drop it from every registered window so + /// the UI doesn't show a phantom queued row. + private func evictFromWindowQueueMirrors(sessionID: String, queuedID: UUID) { + for window in registeredWindows() where window.currentSessionId == sessionID { + window.messageQueue.removeAll { $0.id == queuedID } + if var mirror = window.draftQueues[sessionID] { + mirror.removeAll { $0.id == queuedID } + if mirror.isEmpty { + window.draftQueues.removeValue(forKey: sessionID) + } else { + window.draftQueues[sessionID] = mirror + } + } + } + } + private func createMobilePlaceholderSession(project: Project, requestID: UUID) -> String { let sessionID = "pending-mobile-\(requestID.uuidString)" if allSessionSummaries.contains(where: { $0.id == sessionID }) { @@ -1239,8 +1489,82 @@ final class AppState { } } + private func handleMobileSearchRequest(_ request: SearchRequestPayload, fromHex hex: String) async { + let trimmed = request.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + let empty = SearchResultsPayload( + clientRequestID: request.clientRequestID, + query: request.query, + projectIDs: [], + threadHits: [] + ) + await MobileSyncService.shared.send(.searchResults(empty), toHex: hex) + return + } + + let knownProjectIDs = Set(projects.map(\.id)) + let summaries = allSessionSummaries.filter { knownProjectIDs.contains($0.projectId) } + let summaryByID = Dictionary(uniqueKeysWithValues: summaries.map { ($0.id, $0) }) + + let semantic = await searchService.search(trimmed, limit: max(request.limit, 1)) + + var threadHitByID: [String: SearchHit] = [:] + for group in semantic { + for hit in group.hits { + guard let summary = summaryByID[hit.threadId] else { continue } + let title = ChatSession.stripAttachmentMarkers(from: summary.title) + threadHitByID[hit.threadId] = SearchHit( + sessionID: hit.threadId, + projectID: hit.projectId, + title: title.isEmpty ? ChatSession.defaultTitle : title, + snippet: hit.snippet, + updatedAt: summary.updatedAt, + score: hit.score + ) + } + } + + let lowered = trimmed.lowercased() + for summary in summaries where threadHitByID[summary.id] == nil { + let cleaned = ChatSession.stripAttachmentMarkers(from: summary.title) + guard cleaned.lowercased().contains(lowered) else { continue } + let title = cleaned.isEmpty ? ChatSession.defaultTitle : cleaned + threadHitByID[summary.id] = SearchHit( + sessionID: summary.id, + projectID: summary.projectId, + title: title, + snippet: title, + updatedAt: summary.updatedAt, + score: 0 + ) + } + + let threadHits = threadHitByID.values + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + return lhs.updatedAt > rhs.updatedAt + } + .prefix(max(request.limit, 1)) + + let projectIDs = projects + .filter { project in + project.name.lowercased().contains(lowered) + || project.path.lowercased().contains(lowered) + } + .map(\.id) + + let payload = SearchResultsPayload( + clientRequestID: request.clientRequestID, + query: request.query, + projectIDs: projectIDs, + threadHits: Array(threadHits) + ) + await MobileSyncService.shared.send(.searchResults(payload), toHex: hex) + } + private func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { let active = await mobileActiveSessionPayload(for: activeSessionID) + let branches = await mobileProjectBranches() let payload = SnapshotPayload( projects: projects, sessions: mobileSessionSummaries(), @@ -1248,7 +1572,8 @@ final class AppState { threadSummaries: mobileThreadSummaries(), settings: mobileSettingsSnapshot(), activeSessionID: active.id, - activeSessionMessages: active.messages + activeSessionMessages: active.messages, + projectBranches: branches ) await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) logger.info( @@ -1278,6 +1603,9 @@ final class AppState { private func mobileSessionSummary(from summary: ChatSession.Summary) -> RxCodeSync.SessionSummary { let progress = mobileProgressSnapshot(forSessionId: summary.id) + let queued = threadStore.loadQueue(sessionKey: summary.id).map { + QueuedUserMessage(id: $0.id, text: $0.text) + } return RxCodeSync.SessionSummary( id: summary.id, projectId: summary.projectId, @@ -1287,7 +1615,8 @@ final class AppState { isArchived: summary.isArchived, isStreaming: sessionStates[summary.id]?.isStreaming ?? false, attention: mobileAttentionKind(forSessionId: summary.id), - progress: progress + progress: progress, + queuedMessages: queued ) } @@ -1324,6 +1653,39 @@ final class AppState { return nil } + /// Diff two message arrays for a session and emit `messageAppended` / + /// `messageUpdated` payloads for each change. Called from `updateState` + /// and `flushPendingUpdates` so every visible mutation reaches mobile + /// without per-call site instrumentation. + private func broadcastMobileMessageDiff( + sessionKey: String, + prev: [ChatMessage], + next: [ChatMessage], + isStreaming: Bool + ) { + guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } + if prev.count == next.count, prev == next { return } + let prevById = Dictionary(uniqueKeysWithValues: prev.map { ($0.id, $0) }) + for message in next { + if let old = prevById[message.id] { + guard old != message else { continue } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionKey, + kind: .messageUpdated, + message: message, + isStreaming: isStreaming + ) + } else { + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionKey, + kind: .messageAppended, + message: message, + isStreaming: isStreaming + ) + } + } + } + private func broadcastMobileSessionStatus(sessionID: String, kind: SessionUpdatePayload.Kind = .statusChanged) { guard let summary = mobileSessionSummary(for: sessionID) else { return } MobileSyncService.shared.broadcastSessionUpdate( @@ -1381,7 +1743,8 @@ final class AppState { } private func mobileSettingsSnapshot() -> MobileSettingsSnapshot { - MobileSettingsSnapshot( + let models = availableAgentModelSections().flatMap(\.models) + return MobileSettingsSnapshot( selectedAgentProvider: selectedAgentProvider, selectedModel: selectedModel, selectedACPClientId: selectedACPClientId, @@ -1396,10 +1759,38 @@ final class AppState { autoArchiveEnabled: autoArchiveEnabled, archiveRetentionDays: archiveRetentionDays, autoPreviewSettings: autoPreviewSettings, - availableEfforts: ["auto"] + Self.availableEfforts + availableEfforts: ["auto"] + Self.availableEfforts, + availableModels: models ) } + /// Resolve the current git branch and the local branch list for each known + /// project. Runs the per-project git calls concurrently so a large project + /// list doesn't serialize on disk I/O. + private func mobileProjectBranches() async -> [ProjectBranchInfo] { + let inputs = projects.map { (id: $0.id, path: $0.path) } + return await withTaskGroup(of: ProjectBranchInfo?.self) { group in + for input in inputs { + group.addTask { + async let current = GitHelper.currentBranch(at: input.path) + async let list = GitHelper.listLocalBranches(at: input.path) + guard let branch = await current else { return nil } + let branches = await list + return ProjectBranchInfo( + projectId: input.id, + currentBranch: branch, + availableBranches: branches.isEmpty ? nil : branches + ) + } + } + var result: [ProjectBranchInfo] = [] + for await info in group { + if let info { result.append(info) } + } + return result + } + } + private func applyMobileSettingsUpdate(_ update: MobileSettingsUpdatePayload) { if let provider = update.selectedAgentProvider { selectedAgentProvider = provider @@ -2173,6 +2564,7 @@ final class AppState { /// Configures a `ChatBridge`'s action handlers and starts an observation loop that keeps /// the bridge's state properties in sync with the underlying `sessionStates`. func setupChatBridge(_ bridge: ChatBridge, for window: WindowState) { + registerLiveWindow(window) bridge.sendHandler = { [weak self, weak window] in guard let self, let window else { return } await self.send(in: window) @@ -2658,14 +3050,17 @@ final class AppState { } private func updateState(_ key: String, _ mutate: (inout SessionStreamState) -> Void) { + let prevMessages = sessionStates[key]?.messages ?? [] guard var state = sessionStates[key] else { var fresh = SessionStreamState() mutate(&fresh) sessionStates[key] = fresh + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: fresh.messages, isStreaming: fresh.isStreaming) return } mutate(&state) sessionStates[key] = state + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) } private func finalizeStreamSession( @@ -2702,6 +3097,9 @@ final class AppState { state.streamingStartDate = nil } broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingFinished) + Task { @MainActor [weak self] in + await self?.flushNextQueuedMessageIfNeeded(sessionID: sessionKey) + } } // MARK: - Stream Completion (cross-project MCP) @@ -3372,19 +3770,20 @@ final class AppState { } } - if notificationsEnabled, !NSApp.isActive { + if notificationsEnabled { let summary = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId }) let title = summary?.title ?? "New Session" let responseText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) let fallbackBody = responseNotificationFallback(from: responseText) let pid = projectId let sid = resultEvent.sessionId + let postLocalBanner = !NSApp.isActive Task { [weak self] in var body = fallbackBody if let self, let summary { body = await self.generateResponseNotificationSummary(responseText: responseText, summary: summary) ?? fallbackBody } - await NotificationService.shared.postResponseComplete(title: title, body: body, projectId: pid, sessionId: sid) + await NotificationService.shared.postResponseComplete(title: title, body: body, projectId: pid, sessionId: sid, postLocalBanner: postLocalBanner) } } @@ -3564,6 +3963,8 @@ final class AppState { guard hasText || hasToolResults else { return } + let prevMessages = state.messages + func lastAssistantIdx() -> Int? { state.messages.indices.reversed().first { state.messages[$0].role == .assistant } } @@ -3643,6 +4044,7 @@ final class AppState { } sessionStates[key] = state + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) } // MARK: - Stream Event Handler @@ -5676,12 +6078,14 @@ final class AppState { let key = queueKey(for: window) window.draftQueues[key] = window.messageQueue threadStore.appendQueued(sessionKey: key, message: message) + broadcastMobileSessionStatus(sessionID: key) } func removeQueuedMessage(id: UUID, in window: WindowState) { window.dequeueMessage(id: id) saveQueue(in: window) threadStore.removeQueued(id: id) + broadcastMobileSessionStatus(sessionID: queueKey(for: window)) } /// Pops the head of the queue (the auto-flush path used when a stream ends) @@ -5690,6 +6094,7 @@ final class AppState { guard let next = window.dequeueNext() else { return nil } saveQueue(in: window) threadStore.removeQueued(id: next.id) + broadcastMobileSessionStatus(sessionID: queueKey(for: window)) return next } @@ -5734,6 +6139,7 @@ final class AppState { window.inputText = draftText window.attachments = draftAttachments } + broadcastMobileSessionStatus(sessionID: key) } /// Concatenates every queued message (texts joined with a blank line, @@ -5769,6 +6175,7 @@ final class AppState { window.inputText = draftText window.attachments = draftAttachments } + broadcastMobileSessionStatus(sessionID: key) } /// Sends the next queued message for a background session (one the window is not currently displaying). diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index da260725..73740760 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -373,6 +373,20 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": msg] ) + case .cancelStream(let cancel): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "cancel_stream") else { return } + NotificationCenter.default.post( + name: .mobileSyncCancelStreamRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": cancel] + ) + case .removeQueuedMessage(let payload): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "remove_queued_message") else { return } + NotificationCenter.default.post( + name: .mobileSyncRemoveQueuedRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": payload] + ) case .newSessionRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "new_session_request") else { return } NotificationCenter.default.post( @@ -380,6 +394,13 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .searchRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "search_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncSearchRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .subscribeSession(let sub): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "subscribe_session") else { return } subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" @@ -397,6 +418,13 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["payload": resp] ) + case .branchOpRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "branch_op_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncBranchOpRequested, + 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) } @@ -536,7 +564,11 @@ private struct APNsPushResponse: Codable { extension Notification.Name { static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") + static let mobileSyncCancelStreamRequested = Notification.Name("mobileSync.cancelStreamRequested") + static let mobileSyncRemoveQueuedRequested = Notification.Name("mobileSync.removeQueuedRequested") static let mobileSyncNewSessionRequested = Notification.Name("mobileSync.newSessionRequested") + static let mobileSyncSearchRequested = Notification.Name("mobileSync.searchRequested") static let mobileSyncSettingsUpdateReceived = Notification.Name("mobileSync.settingsUpdateReceived") static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") + static let mobileSyncBranchOpRequested = Notification.Name("mobileSync.branchOpRequested") } diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index 9a85b48d..21eb73c0 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -183,7 +183,9 @@ final class NotificationService: NSObject { } /// Post a "response complete" notification. Silently no-ops if unauthorized. - func postResponseComplete(title: String, body: String, projectId: UUID, sessionId: String) async { + /// Mobile fan-out always runs; the local macOS banner is skipped when + /// `postLocalBanner` is false (e.g. the desktop app is foregrounded). + func postResponseComplete(title: String, body: String, projectId: UUID, sessionId: String, postLocalBanner: Bool = true) async { await fanoutToMobile(.init( kind: .responseComplete, title: title, @@ -191,6 +193,7 @@ final class NotificationService: NSObject { sessionID: sessionId, projectID: projectId )) + guard postLocalBanner else { return } let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional: diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index 52692d30..c927fe03 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -148,48 +148,14 @@ struct HistoryListView: View { } private func sessionRow(_ session: DisplaySession) -> some View { - return HStack(spacing: 4) { - VStack(alignment: .leading, spacing: 3) { - TypewriterTitleText(title: session.title.prefix(1).uppercased() + session.title.dropFirst()) - .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(.primary.opacity(0.8)) - .lineLimit(1) - .contentTransition(.opacity) - .animation(.easeInOut(duration: 0.25), value: session.title) - - HStack(spacing: 4) { - if showAllProjects && !windowState.isProjectWindow, let projectName = session.projectName { - Text(projectName) - .font(.system(size: ClaudeTheme.size(10), weight: .medium)) - .foregroundStyle(ClaudeTheme.accent.opacity(0.8)) - .lineLimit(1) - - Text("·") - .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(.tertiary) - } - - Text(formattedDate(session.updatedAt)) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.tertiary) - } - } - - Spacer() - - if session.isBackgroundStreaming { - ProgressView() - .controlSize(.mini) - .help("Response in progress in the background") - } - - if session.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: ClaudeTheme.size(9))) - .foregroundStyle(ClaudeTheme.textTertiary) - } - } - .padding(.vertical, 2) + let projectName = (showAllProjects && !windowState.isProjectWindow) ? session.projectName : nil + return SessionSidebarRow( + title: session.title, + projectName: projectName, + updatedAt: session.updatedAt, + isPinned: session.isPinned, + isBackgroundStreaming: session.isBackgroundStreaming + ) .onLongPressGesture(minimumDuration: 0, maximumDistance: 10, pressing: { pressing in if pressing { appState.selectSession(id: session.id, in: windowState) @@ -368,28 +334,6 @@ struct HistoryListView: View { ) } - private func formattedDate(_ date: Date) -> String { - Self.compactElapsedTime(since: date) - } - - private static func compactElapsedTime(since date: Date, now: Date = Date()) -> String { - let seconds = max(0, Int(now.timeIntervalSince(date))) - if seconds < 60 { return "0m" } - - let minutes = seconds / 60 - if minutes < 60 { return "\(minutes)m" } - - let hours = minutes / 60 - if hours < 24 { return "\(hours)h" } - - let days = hours / 24 - if days < 7 { return "\(days)d" } - - let weeks = days / 7 - if weeks < 52 { return "\(weeks)w" } - - return "\(days / 365)y" - } } #Preview { diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index 4e86c57c..8d524b34 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -1,14 +1,17 @@ import SwiftUI +import RxCodeCore @main struct RxCodeMobileApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var state = MobileAppState() + @State private var windowState = WindowState() var body: some Scene { WindowGroup { RootView() .environmentObject(state) + .environment(windowState) .onAppear { appDelegate.mobileState = state state.start() diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 01bb3309..cb90591d 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -29,10 +29,26 @@ final class MobileAppState: ObservableObject { @Published var branchBriefings: [MobileBranchBriefing] = [] @Published var threadSummaries: [MobileThreadSummary] = [] @Published var desktopSettings: MobileSettingsSnapshot? + /// 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]] = [:] + /// 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 = [] + /// Last branch op error message, surfaced once and cleared by the UI. + @Published var lastBranchOpError: String? @Published var messagesBySession: [String: [ChatMessage]] = [:] @Published var activeSessionID: String? @Published var pendingPermission: PermissionRequestPayload? + @Published var searchQuery: String = "" + @Published var searchProjectIDs: [UUID] = [] + @Published var searchThreadHits: [SearchHit] = [] + @Published var isSearching: Bool = false + private var pendingSearchID: UUID? + private var searchDebounceTask: Task? + private var identity: DeviceIdentity private var client: SyncClient private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") @@ -234,12 +250,66 @@ final class MobileAppState: ObservableObject { try? await client.send(.userMessage(payload), toHex: pairedDesktopPubkey) } + func cancelStream(sessionID: String) async { + guard isPaired else { return } + let payload = CancelStreamPayload(sessionID: sessionID) + try? await client.send(.cancelStream(payload), toHex: pairedDesktopPubkey) + } + + func removeQueuedMessage(sessionID: String, queuedID: UUID) async { + guard isPaired else { return } + let payload = RemoveQueuedMessagePayload(sessionID: sessionID, queuedMessageID: queuedID) + try? await client.send(.removeQueuedMessage(payload), toHex: pairedDesktopPubkey) + } + + /// True iff the desktop reports the given session as actively streaming. + func isSessionStreaming(_ sessionID: String) -> Bool { + sessions.first(where: { $0.id == sessionID })?.isStreaming ?? false + } + + /// Mirror of the desktop's per-session queue, surfaced via `SessionSummary`. + func queuedMessages(sessionID: String) -> [QueuedUserMessage] { + sessions.first(where: { $0.id == sessionID })?.queuedMessages ?? [] + } + func requestNewSession(projectID: UUID, initialText: String? = nil) async { guard isPaired else { return } let payload = NewSessionRequestPayload(projectID: projectID, initialText: initialText) try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) } + /// Tell the desktop to switch the project to an existing local branch. + /// Returns immediately; the eventual snapshot broadcast carries the new + /// `currentBranch` and any error surfaces via `lastBranchOpError`. + func switchProjectBranch(projectID: UUID, branch: String) async { + guard isPaired else { return } + let request = BranchOpRequestPayload( + projectID: projectID, + operation: .switchExisting, + branch: branch + ) + inFlightBranchOps.insert(request.clientRequestID) + try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) + } + + /// Tell the desktop to create a new branch + worktree off the current + /// branch. The desktop parks the worktree against the project so the next + /// new-thread request for this project spawns into it. + func createProjectBranch(projectID: UUID, branch: String) async { + guard isPaired else { return } + let request = BranchOpRequestPayload( + projectID: projectID, + operation: .createNew, + branch: branch + ) + inFlightBranchOps.insert(request.clientRequestID) + try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) + } + + func clearBranchOpError() { + lastBranchOpError = nil + } + func subscribe(to sessionID: String?) async { activeSessionID = sessionID guard isPaired else { return } @@ -268,6 +338,38 @@ final class MobileAppState: ObservableObject { await requestSnapshot() } + /// 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`. + func updateSearchQuery(_ query: String) { + searchQuery = query + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + searchDebounceTask?.cancel() + guard !trimmed.isEmpty else { + pendingSearchID = nil + isSearching = false + searchProjectIDs = [] + searchThreadHits = [] + return + } + guard isPaired else { + isSearching = false + searchProjectIDs = [] + searchThreadHits = [] + return + } + let id = UUID() + pendingSearchID = id + isSearching = true + searchDebounceTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 200_000_000) + guard !Task.isCancelled, let self else { return } + guard self.pendingSearchID == id else { return } + let payload = SearchRequestPayload(clientRequestID: id, query: trimmed, limit: 25) + try? await self.client.send(.searchRequest(payload), toHex: self.pairedDesktopPubkey) + } + } + func reportAPNsToken(hex: String, environment: String) { logger.info("[APNs] received token from app delegate tokenPrefix=\(String(hex.prefix(12)), privacy: .public) environment=\(environment, privacy: .public)") apnsTokenHex = hex @@ -295,6 +397,10 @@ final class MobileAppState: ObservableObject { branchBriefings = [] threadSummaries = [] desktopSettings = nil + projectBranches = [:] + availableBranchesByProject = [:] + inFlightBranchOps = [] + lastBranchOpError = nil messagesBySession = [:] activeSessionID = nil pendingPermission = nil @@ -351,6 +457,15 @@ final class MobileAppState: ObservableObject { branchBriefings = snap.branchBriefings ?? [] threadSummaries = snap.threadSummaries ?? [] desktopSettings = snap.settings + if let branches = snap.projectBranches { + projectBranches = Dictionary(uniqueKeysWithValues: branches.map { ($0.projectId, $0.currentBranch) }) + availableBranchesByProject = Dictionary( + uniqueKeysWithValues: branches.compactMap { info -> (UUID, [String])? in + guard let list = info.availableBranches else { return nil } + return (info.projectId, list) + } + ) + } if let active = snap.activeSessionID { if let messages = snap.activeSessionMessages { messagesBySession[active] = messages @@ -363,10 +478,21 @@ final class MobileAppState: ObservableObject { applySessionUpdate(update) case .permissionRequest(let req): pendingPermission = req + MobileHaptics.attentionNeeded() case .notification: // Foreground notifications arriving over WS — iOS won't show a // banner automatically; UI surfaces these in a toast/badge. break + case .searchResults(let results): + guard let pending = pendingSearchID, results.clientRequestID == pending else { return } + searchProjectIDs = results.projectIDs + searchThreadHits = results.threadHits + isSearching = false + case .branchOpResult(let result): + inFlightBranchOps.remove(result.clientRequestID) + if !result.ok { + lastBranchOpError = result.errorMessage ?? "Branch operation failed." + } case .ping: Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } default: @@ -403,7 +529,14 @@ final class MobileAppState: ObservableObject { list[idx] = m messagesBySession[update.sessionID] = list } - case .streamingStarted, .streamingFinished, .statusChanged: + case .streamingFinished: + // Soft success cue, but only when the user is actually looking at + // (or last looked at) the session that just finished. Avoids + // buzzing on background-session completions. + if update.sessionID == activeSessionID { + MobileHaptics.streamFinished() + } + case .streamingStarted, .statusChanged: // Surface as a flag on the relevant session row. break } @@ -433,7 +566,8 @@ final class MobileAppState: ObservableObject { isArchived: current.isArchived, isStreaming: isStreaming, attention: current.attention, - progress: current.progress + progress: current.progress, + queuedMessages: current.queuedMessages ) } @@ -512,7 +646,8 @@ final class MobileAppState: ObservableObject { autoArchiveEnabled: update.autoArchiveEnabled ?? current.autoArchiveEnabled, archiveRetentionDays: update.archiveRetentionDays ?? current.archiveRetentionDays, autoPreviewSettings: update.autoPreviewSettings ?? current.autoPreviewSettings, - availableEfforts: current.availableEfforts + availableEfforts: current.availableEfforts, + availableModels: current.availableModels ) } @@ -586,4 +721,15 @@ enum MobileHaptics { static func connectionError() { UINotificationFeedbackGenerator().notificationOccurred(.error) } + + /// Soft success cue when the desktop agent finishes a turn. + static func streamFinished() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + /// Warning cue for things that block progress until the user acts: + /// permission prompts, AskUserQuestion, and ExitPlanMode confirmation. + static func attentionNeeded() { + UINotificationFeedbackGenerator().notificationOccurred(.warning) + } } diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift new file mode 100644 index 00000000..f3222d08 --- /dev/null +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -0,0 +1,156 @@ +import SwiftUI +import RxCodeCore +import RxCodeChatKit +import RxCodeSync + +struct MobileBriefingDetailView: View { + @EnvironmentObject private var state: MobileAppState + let groupKey: BriefingGroupKey + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + summarySection + threadsSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + } + .navigationTitle(projectName) + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await state.refreshSnapshot() + } + } + + private var group: GroupedBriefing? { + groupBriefings( + briefings: state.branchBriefings.filter { + $0.projectId == groupKey.projectId && $0.branch == groupKey.branch + }, + threads: state.threadSummaries.filter { + $0.projectId == groupKey.projectId && $0.branch == groupKey.branch + } + ).first + } + + private var projectName: String { + state.projects.first(where: { $0.id == groupKey.projectId })?.name ?? "Unknown Project" + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text(projectName) + .font(.title2.weight(.semibold)) + HStack(spacing: 8) { + Label(groupKey.branch, systemImage: "arrow.triangle.branch") + if let updatedAt = group?.updatedAt { + Text(updatedAt.formatted(.relative(presentation: .named))) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var summarySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Summary") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if let summary = group?.briefing?.briefing, !summary.isEmpty { + ChatTextContentView( + markdown: summary, + size: 16, + color: .primary, + lineSpacing: 3 + ) + } else { + Text("No summary available yet.") + .font(.body) + .foregroundStyle(.tertiary) + .italic() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } + + @ViewBuilder + private var threadsSection: some View { + let threads = group?.threads ?? [] + VStack(alignment: .leading, spacing: 8) { + Text("Threads") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if threads.isEmpty { + Text("No threads on this branch yet.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .italic() + .padding(.vertical, 8) + } else { + VStack(spacing: 8) { + ForEach(threads) { thread in + NavigationLink(value: thread.sessionId) { + threadRow(thread) + } + .buttonStyle(.plain) + } + } + } + } + } + + private func threadRow(_ thread: MobileThreadSummary) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(thread.title.isEmpty ? "Untitled" : thread.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(2) + if !thread.summary.isEmpty { + ChatTextContentView( + markdown: thread.summary, + size: 12, + color: .secondary, + lineSpacing: 1, + maximumNumberOfLines: 3 + ) + } + Text(thread.updatedAt.formatted(.relative(presentation: .named))) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .padding(.top, 4) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + .contentShape(Rectangle()) + } +} diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index e75e8850..7ac095c6 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -1,24 +1,31 @@ import SwiftUI import RxCodeCore +import RxCodeChatKit import RxCodeSync -struct MobileBriefingView: View { - @EnvironmentObject private var state: MobileAppState +struct BriefingGroupKey: Hashable { + let projectId: UUID + let branch: String +} - private struct GroupedBriefing: Identifiable { - let projectId: UUID - let branch: String - let briefing: MobileBranchBriefing? - let threads: [MobileThreadSummary] - let updatedAt: Date +struct GroupedBriefing: Identifiable { + let projectId: UUID + let branch: String + let briefing: MobileBranchBriefing? + let threads: [MobileThreadSummary] + let updatedAt: Date - var id: String { "\(projectId.uuidString)::\(branch)" } - } + var id: String { "\(projectId.uuidString)::\(branch)" } + var key: BriefingGroupKey { BriefingGroupKey(projectId: projectId, branch: branch) } +} + +struct MobileBriefingView: View { + @EnvironmentObject private var state: MobileAppState var body: some View { GeometryReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { if groups.isEmpty { ContentUnavailableView( "No Briefings Yet", @@ -30,23 +37,38 @@ struct MobileBriefingView: View { LazyVGrid( columns: gridColumns(for: proxy.size.width), alignment: .leading, - spacing: 16 + spacing: 12 ) { ForEach(groups) { group in - briefingCard(group) + NavigationLink(value: group.key) { + briefingRow(group) + } + .buttonStyle(.plain) } } } } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.horizontal, horizontalPadding(for: proxy.size.width)) - .padding(.vertical, 20) + .padding(.vertical, 16) } } .navigationTitle("Briefing") .refreshable { await state.refreshSnapshot() } + .navigationDestination(for: BriefingGroupKey.self) { key in + MobileBriefingDetailView(groupKey: key) + } + .navigationDestination(for: String.self) { sessionID in + MobileChatView(sessionID: sessionID) + .id(sessionID) + .task(id: sessionID) { + if !MobileDraftSessionID.isDraft(sessionID) { + await state.subscribe(to: sessionID) + } + } + } } private func gridColumns(for width: CGFloat) -> [GridItem] { @@ -62,7 +84,7 @@ struct MobileBriefingView: View { return [ GridItem( .adaptive(minimum: minimumWidth, maximum: 560), - spacing: 16, + spacing: 12, alignment: .top ) ] @@ -83,41 +105,31 @@ struct MobileBriefingView: View { } private var groups: [GroupedBriefing] { - var buckets: [String: GroupedBriefing] = [:] - - for briefing in state.branchBriefings { - let key = "\(briefing.projectId.uuidString)::\(briefing.branch)" - buckets[key] = GroupedBriefing( - projectId: briefing.projectId, - branch: briefing.branch, - briefing: briefing, - threads: buckets[key]?.threads ?? [], - updatedAt: max(briefing.updatedAt, buckets[key]?.updatedAt ?? .distantPast) - ) - } - - for thread in state.threadSummaries { - let key = "\(thread.projectId.uuidString)::\(thread.branch)" - let existing = buckets[key] - var threads = existing?.threads ?? [] - threads.append(thread) - buckets[key] = GroupedBriefing( - projectId: thread.projectId, - branch: thread.branch, - briefing: existing?.briefing, - threads: threads.sorted { $0.updatedAt > $1.updatedAt }, - updatedAt: max(thread.updatedAt, existing?.updatedAt ?? .distantPast) - ) - } - - return buckets.values.sorted { $0.updatedAt > $1.updatedAt } + groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) } - private func briefingCard(_ group: GroupedBriefing) -> some View { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { + private func briefingRow(_ group: GroupedBriefing) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { Text(projectsById[group.projectId]?.name ?? "Unknown Project") .font(.headline) + .foregroundStyle(.primary) + + if let summary = group.briefing?.briefing, !summary.isEmpty { + ChatTextContentView( + markdown: summary, + size: 14, + color: .secondary, + lineSpacing: 2, + maximumNumberOfLines: 3 + ) + } else { + Text("No summary yet") + .font(.subheadline) + .foregroundStyle(.tertiary) + .italic() + } + HStack(spacing: 8) { Label(group.branch, systemImage: "arrow.triangle.branch") Text(group.updatedAt.formatted(.relative(presentation: .named))) @@ -126,23 +138,12 @@ struct MobileBriefingView: View { .foregroundStyle(.secondary) } - if let briefing = group.briefing?.briefing, !briefing.isEmpty { - Text(briefing) - .font(.body) - .textSelection(.enabled) - } + Spacer(minLength: 0) - if !group.threads.isEmpty { - Divider() - VStack(alignment: .leading, spacing: 8) { - Text("Threads") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - ForEach(group.threads) { thread in - threadRow(thread) - } - } - } + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .padding(.top, 4) } .padding() .frame(maxWidth: .infinity, alignment: .leading) @@ -152,26 +153,40 @@ struct MobileBriefingView: View { RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) + .contentShape(Rectangle()) + } +} + +func groupBriefings( + briefings: [MobileBranchBriefing], + threads: [MobileThreadSummary] +) -> [GroupedBriefing] { + var buckets: [String: GroupedBriefing] = [:] + + for briefing in briefings { + let key = "\(briefing.projectId.uuidString)::\(briefing.branch)" + buckets[key] = GroupedBriefing( + projectId: briefing.projectId, + branch: briefing.branch, + briefing: briefing, + threads: buckets[key]?.threads ?? [], + updatedAt: max(briefing.updatedAt, buckets[key]?.updatedAt ?? .distantPast) + ) } - private func threadRow(_ thread: MobileThreadSummary) -> some View { - VStack(alignment: .leading, spacing: 3) { - HStack { - Text(thread.title.isEmpty ? "Untitled" : thread.title) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - Spacer() - Text(thread.updatedAt.formatted(.relative(presentation: .named))) - .font(.caption2) - .foregroundStyle(.secondary) - } - if !thread.summary.isEmpty { - Text(thread.summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(3) - } - } - .textSelection(.enabled) + for thread in threads { + let key = "\(thread.projectId.uuidString)::\(thread.branch)" + let existing = buckets[key] + var existingThreads = existing?.threads ?? [] + existingThreads.append(thread) + buckets[key] = GroupedBriefing( + projectId: thread.projectId, + branch: thread.branch, + briefing: existing?.briefing, + threads: existingThreads.sorted { $0.updatedAt > $1.updatedAt }, + updatedAt: max(thread.updatedAt, existing?.updatedAt ?? .distantPast) + ) } + + return buckets.values.sorted { $0.updatedAt > $1.updatedAt } } diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 8fdb6429..8c9bbedd 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -9,54 +9,246 @@ struct MobileChatView: View { @EnvironmentObject private var state: MobileAppState let sessionID: String @State private var composer: String = "" - @State private var submittedDraft = false + @State private var isNearBottom: Bool = true + @State private var showingQueueSheet = false + @State private var didEstablishInitialScroll = false + + private static let bottomAnchorID = "message-list-bottom" + private static let bottomContentPadding: CGFloat = 200 + private static let nearBottomThreshold: CGFloat = bottomContentPadding + 40 var body: some View { - VStack(spacing: 0) { + activeThreadLayout + .animation(.easeInOut(duration: 0.2), value: queuedMessages.count) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + .sheet(isPresented: $showingQueueSheet) { + QueuedMessagesSheet( + messages: queuedMessages, + onRemove: removeQueued + ) + } + } + + // MARK: - Active Thread Layout + + private var activeThreadLayout: some View { + ZStack(alignment: .bottom) { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ChatMessageListView(messages: messages) Color.clear - .frame(height: 1) - .id("message-list-bottom") + .frame(height: Self.bottomContentPadding) + .id(Self.bottomAnchorID) } .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.top, 8) } .scrollDismissesKeyboard(.interactively) - .onChange(of: messages.last?.id) { _, _ in - withAnimation { proxy.scrollTo("message-list-bottom", anchor: .bottom) } + .onScrollGeometryChange(for: Bool.self) { geo in + let distanceFromBottom = geo.contentSize.height - geo.visibleRect.maxY + return distanceFromBottom <= Self.nearBottomThreshold + } action: { _, newValue in + if isNearBottom != newValue { isNearBottom = newValue } } - } - Divider() - MobileInputBar(text: $composer) { trimmed in - Task { - if let projectID = draftProjectID { - guard !submittedDraft else { return } - submittedDraft = true - await state.requestNewSession(projectID: projectID, initialText: trimmed) - } else { - await state.sendUserMessage(trimmed, sessionID: sessionID) + .onAppear { + if !didEstablishInitialScroll { + didEstablishInitialScroll = true } - composer = "" } + .onChange(of: messages.count) { _, _ in + guard didEstablishInitialScroll else { + didEstablishInitialScroll = true + return + } + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + } + .onChange(of: messages.last?.content) { _, _ in + guard didEstablishInitialScroll, isNearBottom else { return } + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + } + .overlay(alignment: .bottomTrailing) { + if !isNearBottom { + scrollToBottomButton(proxy: proxy) + .padding(.trailing, 16) + .padding(.bottom, 80) + .transition(.scale.combined(with: .opacity)) + .zIndex(1) + } + } + .animation(.spring(duration: 0.25), value: isNearBottom) } + + VStack(spacing: 0) { + if !queuedMessages.isEmpty { + queuedPreviewPill + .padding(.horizontal, 12) + .padding(.bottom, 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + MobileInputBar( + text: $composer, + isStreaming: isStreaming, + onSend: handleSend, + onStop: handleStop + ) + } + .background(Color.clear) + .zIndex(2) } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(title) } private var messages: [ChatMessage] { - draftProjectID == nil ? state.messagesBySession[sessionID] ?? [] : [] + state.messagesBySession[sessionID] ?? [] } private var title: String { - if draftProjectID != nil { return "New Thread" } - return state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" + state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" + } + + private var isStreaming: Bool { + state.isSessionStreaming(sessionID) + } + + private var queuedMessages: [QueuedUserMessage] { + state.queuedMessages(sessionID: sessionID) + } + + // MARK: - Send / Stop + + private func handleSend(_ trimmed: String) { + Task { + await state.sendUserMessage(trimmed, sessionID: sessionID) + composer = "" + } } - private var draftProjectID: UUID? { - MobileDraftSessionID.projectID(from: sessionID) + private func handleStop() { + guard !sessionID.isEmpty else { return } + Task { await state.cancelStream(sessionID: sessionID) } + } + + private func removeQueued(_ queued: QueuedUserMessage) { + Task { await state.removeQueuedMessage(sessionID: sessionID, queuedID: queued.id) } + } + + // MARK: - Scroll to bottom button + + private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { + Button { + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + } label: { + Image(systemName: "arrow.down") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .background( + Circle() + .fill(.regularMaterial) + ) + .overlay( + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel("Scroll to bottom") + } + + // MARK: - Queued preview pill + + private var queuedPreviewPill: some View { + Button { + showingQueueSheet = true + } label: { + HStack(spacing: 10) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 1) { + Text("\(queuedMessages.count) queued") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if let first = queuedMessages.first { + Text(first.text) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.up") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(queuedMessages.count) queued messages. Tap to view all.") + } +} + +// MARK: - Queued Messages Sheet + +private struct QueuedMessagesSheet: View { + let messages: [QueuedUserMessage] + let onRemove: (QueuedUserMessage) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if messages.isEmpty { + ContentUnavailableView( + "No Queued Messages", + systemImage: "tray", + description: Text("Messages you queue while a response is streaming appear here.") + ) + } else { + List { + ForEach(messages) { queued in + VStack(alignment: .leading, spacing: 0) { + Text(queued.text) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 4) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + onRemove(queued) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + .listStyle(.plain) + } + } + .navigationTitle("Queued Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } } diff --git a/RxCodeMobile/Views/MobileInputBar.swift b/RxCodeMobile/Views/MobileInputBar.swift index baf5a4e3..001aeab4 100644 --- a/RxCodeMobile/Views/MobileInputBar.swift +++ b/RxCodeMobile/Views/MobileInputBar.swift @@ -1,35 +1,190 @@ import SwiftUI +import UIKit +/// Liquid-glass message composer. Renders a send button while idle and a stop +/// button while the desktop reports the session as streaming. Sending while +/// streaming is allowed: the host view buffers the text into a local queue +/// (mirroring the macOS app) and flushes the next entry when the current turn +/// completes. struct MobileInputBar: View { @Binding var text: String - let onSend: (String) -> Void - @FocusState private var focused: Bool + var isStreaming: Bool + var onSend: (String) -> Void + var onStop: () -> Void + + @State private var borderAnimationProgress: CGFloat = 0 + @State private var shimmerOffset: CGFloat = -1 + @FocusState private var isInputFocused: Bool + + /// Subtle warm gradient that mirrors the streaming glow. + private let gradientColors: [Color] = [ + Color(red: 0.95, green: 0.6, blue: 0.4), + Color(red: 0.85, green: 0.5, blue: 0.55), + Color(red: 0.65, green: 0.5, blue: 0.7), + Color(red: 0.5, green: 0.55, blue: 0.75), + Color(red: 0.55, green: 0.65, blue: 0.7), + Color(red: 0.7, green: 0.65, blue: 0.55), + Color(red: 0.9, green: 0.65, blue: 0.45), + Color(red: 0.95, green: 0.6, blue: 0.4), + ] + + private var hasText: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Send is disabled when there's nothing to send. We *do* allow sending + /// while streaming — the host enqueues the message instead. + private var isSendDisabled: Bool { !hasText } var body: some View { HStack(alignment: .bottom, spacing: 8) { - TextField("Message…", text: $text, axis: .vertical) - .focused($focused) - .lineLimit(1...6) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Color.gray.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - Button { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - onSend(trimmed) - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 32)) - .foregroundStyle(canSend ? Color.accentColor : Color.gray) + inputField + .frame(maxWidth: .infinity) + + if isStreaming { + stopButton + if hasText { + sendButton + .transition(.scale.combined(with: .opacity)) + } + } else { + sendButton } - .disabled(!canSend) } .padding(.horizontal, 12) .padding(.vertical, 8) + .animation(.easeInOut(duration: 0.15), value: isStreaming) + .animation(.easeInOut(duration: 0.15), value: hasText) + .onChange(of: isStreaming) { _, newValue in + if newValue { + startBorderAnimation() + startShimmerAnimation() + } else { + borderAnimationProgress = 0 + shimmerOffset = -1 + } + } + .onAppear { + if isStreaming { + startBorderAnimation() + startShimmerAnimation() + } + } } - private var canSend: Bool { - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // MARK: - Buttons + + private var sendButton: some View { + Button { + sendIfPossible() + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .glassEffect(.regular.interactive(), in: .circle) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .disabled(isSendDisabled) + .accessibilityIdentifier("send-button") + } + + private var stopButton: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onStop() + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(red: 0.9, green: 0.4, blue: 0.4)) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .glassEffect( + .regular.tint(Color(red: 0.95, green: 0.6, blue: 0.6).opacity(0.3)).interactive(), + in: .circle + ) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .accessibilityIdentifier("stop-button") + } + + // MARK: - Input Field + + private var inputField: some View { + TextField(placeholder, text: $text, axis: .vertical) + .lineLimit(1 ... 6) + .textFieldStyle(.plain) + .padding(.vertical, 10) + .padding(.horizontal, 16) + .accessibilityIdentifier("chat-input") + .focused($isInputFocused) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isStreaming ? AnyShapeStyle(animatedBackgroundGradient) : AnyShapeStyle(.clear)) + ) + .glassEffect(in: .rect(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(animatedBorderGradient, lineWidth: 4) + .blur(radius: 8) + .opacity(isStreaming ? 0.6 : 0) + ) + .shadow( + color: isStreaming + ? gradientColors[Int(borderAnimationProgress * 7) % 8].opacity(0.4) + : .clear, + radius: 12, + x: 0, + y: 0 + ) + } + + private var placeholder: String { + isStreaming ? "Queue a message..." : "Message…" + } + + private var animatedBorderGradient: AnyShapeStyle { + AnyShapeStyle( + AngularGradient( + colors: gradientColors, + center: .center, + angle: .degrees(borderAnimationProgress * 360) + ) + ) + } + + private var animatedBackgroundGradient: LinearGradient { + LinearGradient( + colors: gradientColors.map { $0.opacity(0.15) }, + startPoint: UnitPoint(x: borderAnimationProgress, y: 0), + endPoint: UnitPoint(x: borderAnimationProgress + 0.5, y: 1) + ) + } + + // MARK: - Actions + + private func sendIfPossible() { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + text = "" + onSend(trimmed) + } + + private func startBorderAnimation() { + borderAnimationProgress = 0 + withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) { + borderAnimationProgress = 1 + } + } + + private func startShimmerAnimation() { + shimmerOffset = -1 + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + shimmerOffset = 2 + } } } diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift new file mode 100644 index 00000000..f1eca4bb --- /dev/null +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -0,0 +1,508 @@ +import SwiftUI +import UIKit +import RxCodeCore +import RxCodeSync + +/// Modal sheet for composing a new thread. Captures the prompt + config knobs +/// (branch, model, permission mode), fires `requestNewSession` on submit, then +/// watches the session list for the freshly-created summary and dismisses, +/// handing the new session ID back to the caller so it can navigate. +struct NewThreadSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + + let projectID: UUID + let onSessionCreated: (String) -> Void + + @State private var text: String = "" + @State private var isSubmitting = false + @State private var awaitingFromSessionIDs: Set? + @FocusState private var isFocused: Bool + + private var trimmed: String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSend: Bool { + !trimmed.isEmpty && !isSubmitting + } + + private var projectName: String { + state.projects.first(where: { $0.id == projectID })?.name ?? "this project" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 28) { + header + composer + NewThreadConfigStrip(projectID: projectID) + .environmentObject(state) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 32) + } + .scrollDismissesKeyboard(.interactively) + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .navigationTitle("New Thread") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled(isSubmitting) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + isFocused = true + } + } + .onChange(of: state.sessions.map(\.id)) { _, _ in + attemptJumpToNewSession() + } + } + + // MARK: - Header + + private var header: some View { + VStack(spacing: 10) { + Image(systemName: "sparkles") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(accentGradient) + .padding(.bottom, 2) + + Text("What should we build in \(projectName)?") + .font(.system(size: 22, weight: .semibold)) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + + Text("Describe a task, paste a snippet, or ask a question.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + + // MARK: - Composer (multiline input with embedded send) + + private var composer: some View { + VStack(alignment: .trailing, spacing: 8) { + TextField( + "Describe what to build…", + text: $text, + axis: .vertical + ) + .lineLimit(4 ... 14) + .textFieldStyle(.plain) + .font(.body) + .focused($isFocused) + .disabled(isSubmitting) + .submitLabel(.return) + .accessibilityIdentifier("new-thread-input") + + sendButton + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 10) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.secondary.opacity(0.18), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.04), radius: 12, x: 0, y: 4) + } + + private var sendButton: some View { + Button { + submit() + } label: { + ZStack { + Circle() + .fill(canSend ? AnyShapeStyle(accentGradient) : AnyShapeStyle(Color.secondary.opacity(0.18))) + .frame(width: 36, height: 36) + + if isSubmitting { + ProgressView() + .controlSize(.small) + .tint(.white) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(canSend ? Color.white : .secondary) + } + } + } + .buttonStyle(.plain) + .disabled(!canSend) + .accessibilityIdentifier("new-thread-send") + .animation(.easeInOut(duration: 0.15), value: canSend) + .animation(.easeInOut(duration: 0.15), value: isSubmitting) + } + + // MARK: - Style helpers + + private var accentGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.6, blue: 0.4), + Color(red: 0.85, green: 0.5, blue: 0.55), + Color(red: 0.65, green: 0.5, blue: 0.7), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // MARK: - Actions + + private func submit() { + guard canSend else { return } + let body = trimmed + isSubmitting = true + isFocused = false + awaitingFromSessionIDs = Set( + state.sessions + .filter { $0.projectId == projectID } + .map { $0.id } + ) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { + await state.requestNewSession(projectID: projectID, initialText: body) + } + } + + private func attemptJumpToNewSession() { + guard let existing = awaitingFromSessionIDs else { return } + let newest = state.sessions + .filter { $0.projectId == projectID && !existing.contains($0.id) } + .max(by: { $0.updatedAt < $1.updatedAt }) + guard let newest else { return } + awaitingFromSessionIDs = nil + onSessionCreated(newest.id) + dismiss() + } +} + +// MARK: - New Thread Config Strip + +/// Compact configuration row showing the same three knobs the desktop exposes +/// for a new session: branch (with create/switch menu), permission mode, and +/// model. Picker changes mutate the desktop's default settings via the +/// existing `settings_update` payload — the new thread inherits whatever is +/// selected when the user sends the first message. +struct NewThreadConfigStrip: View { + @EnvironmentObject private var state: MobileAppState + let projectID: UUID + @State private var showingCreateBranch = false + + var body: some View { + VStack(spacing: 10) { + HStack { + Text("Settings") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + } + + HStack(spacing: 8) { + branchMenu + permissionMenu + modelMenu + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .sheet(isPresented: $showingCreateBranch) { + CreateBranchSheet( + projectID: projectID, + baseBranch: state.projectBranches[projectID] + ) + .environmentObject(state) + } + .alert( + "Branch operation failed", + isPresented: branchOpErrorBinding, + actions: { + Button("OK", role: .cancel) { state.clearBranchOpError() } + }, + message: { + Text(state.lastBranchOpError ?? "") + } + ) + } + + private var branchOpErrorBinding: Binding { + Binding( + get: { state.lastBranchOpError != nil }, + set: { if !$0 { state.clearBranchOpError() } } + ) + } + + // MARK: Branch + + private var branchList: [String] { + state.availableBranchesByProject[projectID] ?? [] + } + + private var currentBranch: String? { + state.projectBranches[projectID] + } + + private var branchMenu: some View { + Menu { + if !branchList.isEmpty { + Section("Switch branch") { + ForEach(branchList, id: \.self) { name in + Button { + Task { await state.switchProjectBranch(projectID: projectID, branch: name) } + } label: { + HStack { + Image(systemName: name == currentBranch ? "checkmark" : "arrow.triangle.branch") + Text(name) + } + } + .disabled(name == currentBranch) + } + } + } + Button { + showingCreateBranch = true + } label: { + Label("Create new branch…", systemImage: "plus") + } + } label: { + chipLabel( + icon: "arrow.triangle.branch", + title: currentBranch ?? "No branch" + ) + } + } + + // MARK: Model + + private var availableModels: [AgentModel] { + state.desktopSettings?.availableModels ?? [] + } + + private var groupedModels: [(provider: AgentProvider, models: [AgentModel])] { + var seen: [AgentProvider] = [] + for model in availableModels where !seen.contains(model.provider) { + seen.append(model.provider) + } + return seen.map { provider in + (provider, availableModels.filter { $0.provider == provider }) + } + } + + private var selectedModelLabel: String { + guard let settings = state.desktopSettings else { return "Model" } + if let match = availableModels.first(where: { + $0.provider == settings.selectedAgentProvider && $0.id == settings.selectedModel + }) { + return match.displayName + } + return settings.selectedModel.isEmpty ? settings.selectedAgentProvider.displayName : settings.selectedModel + } + + private var modelMenu: some View { + Menu { + ForEach(groupedModels, id: \.provider) { group in + Section(group.provider.displayName) { + ForEach(group.models, id: \.key) { model in + Button { + applyModel(model) + } label: { + let isSelected = state.desktopSettings?.selectedAgentProvider == model.provider + && state.desktopSettings?.selectedModel == model.id + HStack { + Text(model.displayName) + if isSelected { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } + } + if availableModels.isEmpty { + Text("No models available") + } + } label: { + chipLabel(icon: "cpu", title: selectedModelLabel) + } + .disabled(availableModels.isEmpty) + } + + private func applyModel(_ model: AgentModel) { + Task { + await state.updateDesktopSettings( + MobileSettingsUpdatePayload( + selectedAgentProvider: model.provider, + selectedModel: model.id + ) + ) + } + } + + // MARK: Permission Mode + + private var permissionMenu: some View { + Menu { + ForEach(PermissionMode.allCases, id: \.self) { mode in + Button { + applyPermissionMode(mode) + } label: { + HStack { + Label(mode.displayName, systemImage: mode.systemImage) + if state.desktopSettings?.permissionMode == mode { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } label: { + let mode = state.desktopSettings?.permissionMode ?? .default + chipLabel(icon: mode.systemImage, title: mode.displayName) + } + } + + private func applyPermissionMode(_ mode: PermissionMode) { + Task { + await state.updateDesktopSettings( + MobileSettingsUpdatePayload(permissionMode: mode) + ) + } + } + + // MARK: Chip + + private func chipLabel(icon: String, title: String, showsChevron: Bool = true) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12, weight: .medium)) + Text(title) + .font(.callout.weight(.medium)) + .lineLimit(1) + .truncationMode(.tail) + if showsChevron { + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(.tertiarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + } +} + +// MARK: - Create Branch Sheet + +struct CreateBranchSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let projectID: UUID + let baseBranch: String? + + @State private var branchText: String = "rxcode/" + @State private var isSubmitting = false + @FocusState private var isFocused: Bool + + private var trimmed: String { + branchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var validationError: String? { + if trimmed.isEmpty { return "Branch name is required." } + if trimmed.hasSuffix("/") { return "Branch name cannot end with “/”." } + if trimmed == "rxcode/" { return "Add a branch name after the prefix." } + return nil + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("rxcode/feature-name", text: $branchText) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .focused($isFocused) + .disabled(isSubmitting) + .submitLabel(.go) + .onSubmit(submit) + } header: { + Text("Branch name") + } footer: { + if let base = baseBranch { + Text("Created from \(base)") + } else { + Text("Created from the project's current branch") + } + } + + if let error = validationError, !trimmed.isEmpty, trimmed != "rxcode/" { + Section { Text(error).foregroundStyle(.secondary) } + } + } + .navigationTitle("Create branch") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + submit() + } label: { + if isSubmitting { + ProgressView() + } else { + Text("Create") + .fontWeight(.semibold) + } + } + .disabled(validationError != nil || isSubmitting) + } + } + .onAppear { + DispatchQueue.main.async { isFocused = true } + } + } + .presentationDetents([.medium]) + } + + private func submit() { + guard validationError == nil else { return } + isSubmitting = true + let name = trimmed + Task { + await state.createProjectBranch(projectID: projectID, branch: name) + isSubmitting = false + dismiss() + } + } +} diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 32ca9811..9e18bf9b 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import RxCodeSync struct ProjectsSidebar: View { @EnvironmentObject private var state: MobileAppState @@ -7,6 +8,7 @@ struct ProjectsSidebar: View { @Binding var showingBriefing: Bool var showsBriefingItem = true var usesSelection = true + @State private var searchText = "" var body: some View { list @@ -15,6 +17,19 @@ struct ProjectsSidebar: View { .refreshable { await state.refreshSnapshot() } + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: "Search projects and threads" + ) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .onChange(of: searchText) { _, newValue in + state.updateSearchQuery(newValue) + } + .onDisappear { + state.updateSearchQuery("") + } } @ViewBuilder @@ -32,6 +47,15 @@ struct ProjectsSidebar: View { @ViewBuilder private var listContent: some View { + if searchText.isEmpty { + defaultContent + } else { + searchResultsContent + } + } + + @ViewBuilder + private var defaultContent: some View { if showsBriefingItem { Button { selected = nil @@ -46,15 +70,110 @@ struct ProjectsSidebar: View { Section("Projects") { ForEach(state.projects) { project in NavigationLink(value: project.id) { - VStack(alignment: .leading, spacing: 2) { - Text(project.name).font(.headline) - Text(project.path) - .font(.caption) + projectRow(project) + } + } + } + } + + @ViewBuilder + private var searchResultsContent: some View { + let projectMatches = state.projects.filter { project in + state.searchProjectIDs.contains(project.id) + } + let threadHits = state.searchThreadHits + let projectsByID = Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) + + if projectMatches.isEmpty && threadHits.isEmpty { + Section { + if state.isSearching { + HStack(spacing: 8) { + ProgressView() + Text("Searching…") .foregroundStyle(.secondary) - .lineLimit(1) + } + } else { + ContentUnavailableView.search(text: searchText) + .listRowBackground(Color.clear) + } + } + } else { + if !projectMatches.isEmpty { + Section("Projects") { + ForEach(projectMatches) { project in + NavigationLink(value: project.id) { + projectRow(project) + } } } } + + if !threadHits.isEmpty { + Section("Threads") { + ForEach(threadHits) { hit in + NavigationLink(value: hit.sessionID) { + threadHitRow(hit, project: projectsByID[hit.projectID]) + } + } + } + } + } + } + + private func threadHitRow(_ hit: SearchHit, project: Project?) -> some View { + let title = hit.title.isEmpty ? ChatSession.defaultTitle : hit.title + let showSnippet = !hit.snippet.isEmpty && hit.snippet != title + + return HStack(spacing: 8) { + Image(systemName: "bubble.left") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body) + .lineLimit(1) + + if showSnippet { + Text(hit.snippet) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let project { + Text(project.name) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + } + .padding(.vertical, 2) + } + + private func projectRow(_ project: Project) -> some View { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(project.name) + .font(.body) + .lineLimit(1) + + Text(Self.truncatedPath(project.path)) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } } + .padding(.vertical, 2) + } + + private static func truncatedPath(_ path: String) -> String { + guard path.hasPrefix("/Users/") else { return path } + let afterUsers = path.dropFirst("/Users/".count) + guard let slash = afterUsers.firstIndex(of: "/") else { return path } + return "~" + afterUsers[slash...] } } diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 8faae385..993811c6 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -49,10 +49,7 @@ struct RootView: View { openActiveSession(newValue) } .onChange(of: selectedSession) { _, newValue in - guard compactClass == .compact, - let newValue, - MobileDraftSessionID.isDraft(newValue) - else { return } + guard compactClass == .compact, let newValue else { return } projectsPath.append(newValue) } } @@ -123,7 +120,9 @@ struct RootView: View { NavigationSplitView { projectSidebar } detail: { - MobileBriefingView() + NavigationStack { + MobileBriefingView() + } } } @@ -161,6 +160,7 @@ struct RootView: View { private func chatDestination(_ sessionID: String) -> some View { MobileChatView(sessionID: sessionID) .id(sessionID) + .toolbar(.hidden, for: .tabBar) .task(id: sessionID) { if !MobileDraftSessionID.isDraft(sessionID) { await state.subscribe(to: sessionID) diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 7b485930..da2ed7e7 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -25,6 +25,8 @@ struct SessionsList: View { let projectID: UUID @Binding var selected: String? var usesSelection = true + @State private var searchText = "" + @State private var showingNewThread = false var body: some View { list @@ -32,12 +34,30 @@ struct SessionsList: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { - selected = MobileDraftSessionID.make(projectID: projectID) + showingNewThread = true } label: { Image(systemName: "square.and.pencil") } } } + .sheet(isPresented: $showingNewThread) { + NewThreadSheet(projectID: projectID) { newSessionID in + selected = newSessionID + } + .environmentObject(state) + } + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: "Search threads" + ) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .overlay { + if filtered.isEmpty && !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } + } } @ViewBuilder @@ -55,61 +75,39 @@ struct SessionsList: View { private func sessionLink(_ session: SessionSummary) -> some View { NavigationLink(value: session.id) { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - if let attention = session.attention { - statusDot(for: attention) - } - - Text(displayTitle(for: session)) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - - Spacer(minLength: 6) - - if session.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - } - - if session.isStreaming { - compactProgress(for: session.progress) - } else { - Text(session.updatedAt.formatted(.relative(presentation: .named))) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + HStack(spacing: 6) { + if let attention = session.attention { + statusDot(for: attention) } - if session.isStreaming { - progressBar(for: session.progress) - } else if let attention = session.attention { - Text(attention == .question ? "Question pending" : "Permission pending") - .font(.caption) - .foregroundStyle(.secondary) - } + SessionSidebarRow( + title: rowTitle(for: session), + updatedAt: session.updatedAt, + isPinned: session.isPinned, + isBackgroundStreaming: session.isStreaming + ) } - .padding(.vertical, 3) } } private var filtered: [SessionSummary] { - state.sessions - .filter { $0.projectId == projectID && !$0.isArchived } + let query = searchText.lowercased() + return state.sessions + .filter { session in + guard session.projectId == projectID, !session.isArchived else { return false } + if query.isEmpty { return true } + let title = ChatSession.stripAttachmentMarkers(from: session.title).lowercased() + return title.contains(query) + } .sorted { lhs, rhs in if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } return lhs.updatedAt > rhs.updatedAt } } - private func displayTitle(for session: SessionSummary) -> String { + private func rowTitle(for session: SessionSummary) -> String { let cleaned = ChatSession.stripAttachmentMarkers(from: session.title) - let resolved = cleaned.isEmpty ? ChatSession.defaultTitle : cleaned - return resolved.prefix(1).uppercased() + resolved.dropFirst() + return cleaned.isEmpty ? ChatSession.defaultTitle : cleaned } private func statusDot(for attention: SessionAttentionKind) -> some View { @@ -118,28 +116,4 @@ struct SessionsList: View { .frame(width: 7, height: 7) .accessibilityLabel(attention == .question ? "Question pending" : "Permission pending") } - - @ViewBuilder - private func compactProgress(for progress: SessionProgressSnapshot?) -> some View { - if let progress, progress.total > 0 { - Text("\(progress.done)/\(progress.total)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } else { - ProgressView() - .controlSize(.small) - } - } - - @ViewBuilder - private func progressBar(for progress: SessionProgressSnapshot?) -> some View { - if let progress, progress.total > 0 { - ProgressView(value: Double(progress.done), total: Double(progress.total)) - .tint(progress.done == progress.total ? .green : .accentColor) - .accessibilityLabel("Todos \(progress.done) of \(progress.total)") - } else { - ProgressView() - .accessibilityLabel("Response in progress") - } - } } diff --git a/relay-server/push.go b/relay-server/push.go index e39c034c..e224f1d7 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -132,6 +132,16 @@ func pushHandler(sender *PushSender) http.HandlerFunc { notif.CollapseID = req.CollapseID } + log.Printf( + "apns push send: device=%s category=%q collapse_id=%q collapse_len=%d payload_bytes=%d enc_bytes=%d", + short(req.DeviceToken), + req.Category, + req.CollapseID, + len(req.CollapseID), + len(raw), + len(req.EncryptedAlertB64), + ) + res, err := sender.client.Push(notif) if err != nil { log.Printf( @@ -145,7 +155,17 @@ func pushHandler(sender *PushSender) http.HandlerFunc { http.Error(w, "apns push failed", http.StatusBadGateway) return } - if !res.Sent() { + if res.Sent() { + log.Printf( + "apns push sent: status=%d apns_id=%s device=%s category=%q collapse_id=%q collapse_len=%d", + res.StatusCode, + res.ApnsID, + short(req.DeviceToken), + req.Category, + req.CollapseID, + len(req.CollapseID), + ) + } else { log.Printf( "apns push rejected: status=%d reason=%q apns_id=%s device=%s category=%q collapse_id=%q collapse_len=%d", res.StatusCode, From 7d87a989ce8922e3341ebf30ca0fbd56566c804d Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 20 May 2026 00:21:29 +0800 Subject: [PATCH 8/8] fix(git): refactor TypewriterTitleText to remove unnecessary assignments and simplify logic - consolidate assignments into a single block - reduce repeated calculations and conditional checks - improve readability and maintainability - remove redundant variables --- .../Sources/RxCodeCore/Views/TypewriterTitleText.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift b/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift index 308f9c10..cff1946d 100644 --- a/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift +++ b/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift @@ -4,10 +4,10 @@ import SwiftUI /// (cursor moving right→left) and then typing the new value left→right. /// No animation runs on the first appearance — the initial value is shown immediately. public struct TypewriterTitleText: View { - let title: String - var charInterval: Duration = .milliseconds(18) - var pauseBetween: Duration = .milliseconds(120) - var holdAfter: Duration = .milliseconds(180) + public let title: String + public var charInterval: Duration = .milliseconds(18) + public var pauseBetween: Duration = .milliseconds(120) + public var holdAfter: Duration = .milliseconds(180) @State private var displayed: String = "" @State private var isAnimating: Bool = false