diff --git a/.gitignore b/.gitignore index e46d89e9..2c8829cb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,9 @@ sparkle.key 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/Package.swift b/Packages/Package.swift index 2801c55d..3c388297 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"], @@ -42,5 +48,10 @@ let package = Package( ], path: "Tests/RxCodeChatKitTests" ), + .testTarget( + name: "RxCodeSyncTests", + dependencies: ["RxCodeSync"], + path: "Tests/RxCodeSyncTests" + ), ] ) 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/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift new file mode 100644 index 00000000..66d09f9f --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift @@ -0,0 +1,213 @@ +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 + ) + .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..8e4678a7 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -0,0 +1,93 @@ +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: 8, leading: 20, bottom: 24, 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: 14) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "eye.slash") + .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(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: ClaudeTheme.messageSize(10), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .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/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..d904b5a3 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? @@ -127,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)) @@ -830,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. @@ -961,3 +935,4 @@ private struct InputHeightMeasurer: View { return capped.hasSuffix("\n") ? capped + " " : capped } } +#endif 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/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 3618cdae..2ba7e589 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 @@ -544,6 +545,164 @@ 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? { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return CGSize(width: proposal.width ?? 0, height: 0) + } + 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: resolvedWidth, 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 @@ -692,7 +851,7 @@ private final class InlineCodeLayoutManager: NSLayoutManager, @unchecked Sendabl } } -private final class InlineCodeTextView: NSTextView { +final class InlineCodeTextView: NSTextView { init() { let storage = NSTextStorage() let layoutManager = InlineCodeLayoutManager() @@ -971,3 +1130,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..08fadb03 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 @@ -41,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 @@ -142,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) @@ -166,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) } @@ -221,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)) { @@ -471,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) { @@ -482,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) @@ -701,3 +709,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..21af6770 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -3,11 +3,12 @@ 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 @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 @@ -18,22 +19,23 @@ 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 { - ScrollView { - VStack(spacing: 16) { + ScrollViewReader { proxy in + List { messageRows(settledItems[...]) - } - .padding(.horizontal, 20) - .padding(.top, 16) - // Streaming view is outside VStack — text deltas don't affect settled layout - VStack(spacing: 16) { + // Streaming view is outside VStack — text deltas don't affect settled layout if !windowState.focusMode { StreamingMessageView { rebuildSettledItems() - if anchor.isNearBottom { scrollToBottomDebounced() } + if anchor.isNearBottom { scrollToBottomDebounced(proxy) } } + // Suppress layout animations when switching sessions so the pulse indicator + // doesn't visually jump as StreamingMessageView changes height. + .animation(.none, value: windowState.currentSessionId) + .chatMessageListRowStyle() } if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision { @@ -49,23 +51,24 @@ struct MessageListView: View { ) Spacer(minLength: 40) } + .chatMessageListRowStyle() } 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) - } + Color.clear.frame(height: 1) + .id(Self.bottomAnchorID) + .chatMessageListRowStyle() + } + .listStyle(.plain) + .contentMargins(.top, 16, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) .opacity(isSessionReady ? 1 : 0) - .scrollPosition($scrollPosition) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollSample.self) { geo in ScrollSample(contentHeight: geo.contentSize.height, visibleMaxY: geo.visibleRect.maxY) @@ -76,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) { @@ -97,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, @@ -114,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() @@ -140,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)) @@ -152,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 @@ -167,29 +169,14 @@ struct MessageListView: View { .allowsHitTesting(false) } } + } } // MARK: - Helpers @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 @@ -209,7 +196,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 { PlanCardView.containsExitPlanMode($0) } - var hasRecentPlanCard = false - - for message in assistantRun { - if hasExitPlan && PlanCardView.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 PlanCardView.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 PlanCardView.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 { @@ -386,21 +231,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)) } @@ -408,14 +253,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)) } @@ -437,52 +282,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)...])) } } @@ -651,3 +451,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..04953c75 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 { @@ -432,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 { @@ -1063,3 +994,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/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 0a7f2163..cd579fa8 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)) @@ -135,8 +136,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 +161,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 +217,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 +233,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 +295,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 +373,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,7 +470,7 @@ 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 @@ -491,12 +499,16 @@ 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 { - 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/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/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/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/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/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 77% rename from RxCode/Views/Sidebar/TypewriterTitleText.swift rename to Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift index 5d6a4896..cff1946d 100644 --- a/RxCode/Views/Sidebar/TypewriterTitleText.swift +++ b/Packages/Sources/RxCodeCore/Views/TypewriterTitleText.swift @@ -1,21 +1,32 @@ 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 { - let title: String - var charInterval: Duration = .milliseconds(18) - var pauseBetween: Duration = .milliseconds(120) - var holdAfter: Duration = .milliseconds(180) +public struct TypewriterTitleText: View { + 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 @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/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..4a9acb66 --- /dev/null +++ b/Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift @@ -0,0 +1,177 @@ +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) + } + + /// 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[.. 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..4a97e5dc --- /dev/null +++ b/Packages/Sources/RxCodeSync/Pairing/Pairing.swift @@ -0,0 +1,122 @@ +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 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 data.base64EncodedString(options: .init()) + } + + public static func parse(_ qrString: String) throws -> PairingToken { + 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) + 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..c201172b --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -0,0 +1,747 @@ +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 unpair(UnpairPayload) + case apnsToken(APNsTokenPayload) + case requestSnapshot(RequestSnapshotPayload) + case snapshot(SnapshotPayload) + case settingsUpdate(MobileSettingsUpdatePayload) + 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) +} + +// 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 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 + 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 branchBriefings: [MobileBranchBriefing]? + public let threadSummaries: [MobileThreadSummary]? + 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], + branchBriefings: [MobileBranchBriefing]? = nil, + threadSummaries: [MobileThreadSummary]? = nil, + settings: MobileSettingsSnapshot? = nil, + activeSessionID: String? = nil, + activeSessionMessages: [ChatMessage]? = nil, + projectBranches: [ProjectBranchInfo]? = nil + ) { + self.projects = projects + self.sessions = sessions + self.branchBriefings = branchBriefings + self.threadSummaries = threadSummaries + 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 + } +} + +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] + /// 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, + 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], + availableModels: [AgentModel]? = nil + ) { + 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 + 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) + } +} + +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 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 + public let title: String + public let updatedAt: Date + public let isPinned: Bool + public let isArchived: Bool + 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, + projectId: UUID, + title: String, + updatedAt: Date, + isPinned: Bool, + isArchived: Bool, + isStreaming: Bool = false, + attention: SessionAttentionKind? = nil, + progress: SessionProgressSnapshot? = nil, + queuedMessages: [QueuedUserMessage]? = nil + ) { + self.id = id + self.projectId = projectId + self.title = title + self.updatedAt = updatedAt + self.isPinned = isPinned + self.isArchived = isArchived + 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, queuedMessages + } + + 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) + queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) + } +} + +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 let summary: SessionSummary? + public let previousSessionID: String? + + public init( + sessionID: String, + kind: Kind, + message: ChatMessage? = 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 + } +} + +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 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 + public let initialText: String? + public init(clientRequestID: UUID = UUID(), projectID: UUID, initialText: String? = nil) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.initialText = initialText + } +} + +/// 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 + 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 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" + 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 + } + + 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 .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)) + 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)) + } + } + + 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 .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) + 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/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..26d99e78 --- /dev/null +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -0,0 +1,247 @@ +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 + } + 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) + return + } + + let newTask = session.webSocketTask(with: url) + task = newTask + newTask.resume() + + // URLSessionWebSocketTask reports success only via receive/ping. Stay + // in .connecting and let the first successful ping flip us to + // .connected, so the UI reflects the real handshake outcome. + startReceiveLoop() + sendPing() + 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 + updateState(.connected) + } + } + + 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 let error { + Task { await self?.handleSocketFailure(error: error) } + } else { + Task { await self?.markConnected() } + } + } + } + + 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/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/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.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 6477f1e7..ad089770 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -13,8 +13,17 @@ 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 */; }; + 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 */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; @@ -23,6 +32,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,8 +50,43 @@ 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; + }; + 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 = ""; }; @@ -50,6 +95,10 @@ 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; }; + 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 = ""; }; @@ -57,7 +106,50 @@ 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 */; + }; + DF230BAA2FBC9001008929A6 /* Exceptions for "RxCodeMobileNotificationService" folder in "RxCodeMobileNotificationService" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */; + }; +/* 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 = ""; + }; + DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DF230BAA2FBC9001008929A6 /* Exceptions for "RxCodeMobileNotificationService" folder in "RxCodeMobileNotificationService" target */, + ); + path = RxCodeMobileNotificationService; + sourceTree = ""; + }; E673353A2F7356F600FD26C7 /* RxCode */ = { isa = PBXFileSystemSynchronizedRootGroup; path = RxCode; @@ -84,6 +176,41 @@ ); 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; + }; + DF230BA62FBC9001008929A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DF230BB32FBC9001008929A6 /* RxCodeSync in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335352F7356F600FD26C7 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -92,6 +219,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 +270,10 @@ 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */, E673353A2F7356F600FD26C7 /* RxCode */, 6E17B0042FC8000100A10001 /* RxCodeUITests */, + DF230B502FBC7367008929A6 /* RxCodeMobile */, + DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */, + DF230B622FBC7368008929A6 /* RxCodeMobileTests */, + DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */, E67335392F7356F600FD26C7 /* Products */, 609E8EE085862BD7D5B4012F /* Frameworks */, 1525FE6BFB6F06A3F00B92D3 /* RxCodeTests */, @@ -154,6 +286,10 @@ E67335382F7356F600FD26C7 /* RxCode.app */, 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */, 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */, + DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */, + DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */, + DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */, + DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */, ); name = Products; sourceTree = ""; @@ -204,6 +340,105 @@ 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 */, + DF230BB52FBC9001008929A6 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + DF230BB72FBC9001008929A6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DF230B502FBC7367008929A6 /* RxCodeMobile */, + ); + name = RxCodeMobile; + packageProductDependencies = ( + DF230B9E2FBC73BA008929A6 /* RxCodeChatKit */, + DF230BA02FBC73BA008929A6 /* RxCodeCore */, + DF230BA22FBC73BA008929A6 /* RxCodeSync */, + ); + productName = RxCodeMobile; + productReference = DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */; + productType = "com.apple.product-type.application"; + }; + 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"; + }; + 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" */; @@ -225,6 +460,7 @@ E6C001012F9B000100000001 /* Sparkle */, E6D001012FA0000100000001 /* RxCodeCore */, E6D001022FA0000100000001 /* RxCodeChatKit */, + E6D001062FA0000100000001 /* RxCodeSync */, DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, ); productName = RxCode; @@ -241,6 +477,20 @@ LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 2630; TargetAttributes = { + DF230B4E2FBC7367008929A6 = { + CreatedOnToolsVersion = 26.3; + }; + DF230B5E2FBC7368008929A6 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = DF230B4E2FBC7367008929A6; + }; + DF230B682FBC7368008929A6 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = DF230B4E2FBC7367008929A6; + }; + DF230BA42FBC9001008929A6 = { + CreatedOnToolsVersion = 26.3; + }; E67335372F7356F600FD26C7 = { CreatedOnToolsVersion = 26.3; }; @@ -271,6 +521,10 @@ E67335372F7356F600FD26C7 /* RxCode */, 5D74FE7B782850D23F8D9BF7 /* RxCodeTests */, 6E17B0062FC8000100A10001 /* RxCodeUITests */, + DF230B4E2FBC7367008929A6 /* RxCodeMobile */, + DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */, + DF230B5E2FBC7368008929A6 /* RxCodeMobileTests */, + DF230B682FBC7368008929A6 /* RxCodeMobileUITests */, ); }; /* End PBXProject section */ @@ -290,6 +544,35 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF230B4D2FBC7367008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DF31D3DC2FBC8143005D518E /* icon.icon in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B5D2FBC7368008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230B672FBC7368008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF230BA72FBC9001008929A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335362F7356F600FD26C7 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -321,6 +604,34 @@ ); 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; + }; + DF230BA52FBC9001008929A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67335342F7356F600FD26C7 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -343,6 +654,21 @@ 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 */; + }; + DF230BB72FBC9001008929A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */; + targetProxy = DF230BB62FBC9001008929A6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -402,6 +728,236 @@ }; name = Release; }; + DF230B712FBC7368008929A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = icon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RxCodeMobile/RxCodeMobile.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeMobile/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "RxCode Mobile"; + 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 = app.rxlab.rxcodemobile; + 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 = icon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RxCodeMobile/RxCodeMobile.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeMobile/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "RxCode Mobile"; + 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 = app.rxlab.rxcodemobile; + 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; + }; + 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 = { @@ -611,6 +1167,42 @@ 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; + }; + 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 = ( @@ -683,6 +1275,41 @@ 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; + }; + DF230BB22FBC9001008929A6 /* RxCodeSync */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeSync; + }; DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = { isa = XCSwiftPackageProductDependency; package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; @@ -712,6 +1339,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.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme new file mode 100644 index 00000000..47ad7a72 --- /dev/null +++ b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index b7640e52..d8cd5233 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 @@ -171,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 { @@ -938,6 +943,33 @@ final class AppState { var reindexProgress: (done: Int, total: Int)? = nil 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 @@ -1031,6 +1063,783 @@ 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) + + 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 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, + 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) + + 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() + } + + 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 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 + 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 } + + 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) + + // 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 + if sessionID.hasPrefix("pending-mobile-") { + window.insertPendingPlaceholder(sessionID) + } + _ = await sendPrompt(text, displayText: text, in: window) + 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 }) { + 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 + 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 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(), + branchBriefings: mobileBranchBriefings(), + threadSummaries: mobileThreadSummaries(), + settings: mobileSettingsSnapshot(), + activeSessionID: active.id, + activeSessionMessages: active.messages, + projectBranches: branches + ) + 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 { + 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) + let queued = threadStore.loadQueue(sessionKey: summary.id).map { + QueuedUserMessage(id: $0.id, text: $0.text) + } + 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, + queuedMessages: queued + ) + } + + 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 + } + + /// 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( + 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() + .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 { + let models = availableAgentModelSections().flatMap(\.models) + return 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, + 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 + } + 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, @@ -1667,10 +2476,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. @@ -1751,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) @@ -2164,6 +2978,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 @@ -2235,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( @@ -2278,6 +3096,10 @@ 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) @@ -2766,6 +3588,9 @@ final class AppState { } } } + if previousSessionKey != sid { + broadcastMobileSessionRedirect(from: previousSessionKey, to: sid) + } } if systemEvent.subtype == "compact_boundary" { @@ -2945,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) } } @@ -2991,6 +3817,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)]") @@ -3136,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 } } @@ -3215,6 +4044,7 @@ final class AppState { } sessionStates[key] = state + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) } // MARK: - Stream Event Handler @@ -3418,6 +4248,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 @@ -3458,9 +4292,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) } @@ -3469,9 +4307,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) } @@ -3937,6 +4779,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 @@ -4454,6 +5297,7 @@ final class AppState { let newIsPinned = allSessionSummaries[si].isPinned threadStore.upsert(allSessionSummaries[si]) await updateSessionMetadata(session) { $0.isPinned = newIsPinned } + scheduleMobileSnapshotBroadcast() } // MARK: - Archive @@ -4493,6 +5337,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 @@ -5233,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) @@ -5247,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 } @@ -5291,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, @@ -5326,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/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/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 new file mode 100644 index 00000000..73740760 --- /dev/null +++ b/RxCode/Services/MobileSyncService.swift @@ -0,0 +1,574 @@ +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 +} + +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. +@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 var 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 + 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() + } + } + + func stop() { + eventTask?.cancel() + eventTask = nil + Task { await client.stop() } + } + + /// 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 + eventTask?.cancel() + eventTask = nil + let oldClient = client + client = SyncClient(identity: identity, relayURL: url) + connectionState = .disconnected + Task { await oldClient.stop() } + start() + } + + // 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 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) + } + + // 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. + } + } + + /// 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 + } + + 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).", + 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 + /// into local state. + 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, + summary: summary, + previousSessionID: previousSessionID + ) + 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 .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] + userInfo["activeSessionID"] = req.activeSessionID + NotificationCenter.default.post( + name: .mobileSyncSnapshotRequested, + object: nil, + 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 .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( + name: .mobileSyncNewSessionRequested, + 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 ?? "" + var userInfo: [String: Any] = ["from": inbound.fromHex] + userInfo["activeSessionID"] = sub.sessionID + NotificationCenter.default.post( + name: .mobileSyncSnapshotRequested, + object: nil, + 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 .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) } + 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) } + 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 { + 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)") + } + } + + 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 { + 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 10f99e39..21eb73c0 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: @@ -157,7 +183,17 @@ 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, + body: body.isEmpty ? "Response complete" : body, + sessionID: sessionId, + projectID: projectId + )) + guard postLocalBanner else { return } let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional: @@ -186,6 +222,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/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/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/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 new file mode 100644 index 00000000..0408b209 --- /dev/null +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -0,0 +1,346 @@ +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 + @State private var testNotificationDeviceID: String? + @State private var testNotificationAlert: TestNotificationAlert? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + headerSection + relaySection + Divider() + pairedSection + } + .padding(20) + } + .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 { + 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) + .disabled(sync.connectionState != .connected) + .help(sync.connectionState == .connected ? "" : "Connect to the relay before pairing a device.") + } + + 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() + testNotificationButton(for: device) + Button(role: .destructive) { + Task { await sync.unpair(device) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .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 + +private struct PairingSheet: View { + @Environment(\.dismiss) private var dismiss + @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) { + 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() } + .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 + 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 { + expirationLabel(for: token) + } + Button { + startPairing() + } label: { + Label("Force reload", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + } + + @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 — generating a new QR code") + .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/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/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift new file mode 100644 index 00000000..8369c267 --- /dev/null +++ b/RxCodeMobile/AppDelegate.swift @@ -0,0 +1,167 @@ +import UIKit +import UserNotifications +import CryptoKit +import RxCodeSync +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 + } + + 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 + 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) { + logger.error("[APNs] registration failed: \(error.localizedDescription, privacy: .public)") + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + let content = notification.request.content + 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.. + + + + 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/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements new file mode 100644 index 00000000..3a4a5a49 --- /dev/null +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -0,0 +1,16 @@ + + + + + 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 new file mode 100644 index 00000000..8d524b34 --- /dev/null +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -0,0 +1,35 @@ +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() + } + .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 new file mode 100644 index 00000000..cb90591d --- /dev/null +++ b/RxCodeMobile/State/MobileAppState.swift @@ -0,0 +1,735 @@ +import Foundation +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 +/// 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] = [] + @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") + private var eventTask: Task? + private var pairingTimeoutTask: Task? + private var apnsTokenHex: String? + private var apnsEnvironment: String? + private var clientStarted = false + + static let pairingTimeoutSeconds: UInt64 = 25 + + init() { + let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") + 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. 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: 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() + } + + /// 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() { + Task { @MainActor in + await startClient() + } + } + + private func startClient() async { + clientStarted = true + 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 + + func pair(with token: PairingToken, displayName: String) async { + guard !token.isExpired, + let desktopKey = token.desktopPublicKey else { + failPairing("Invalid or expired pairing code.") + return + } + pairingStatus = .inProgress + let desktopHex = token.desktopPubkeyHex + 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) { + await updateRelayForPairingIfNeeded(url) + } 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)") + 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, + displayName: displayName, + platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", + 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 { + 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 + 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.failPairing( + "Your Mac didn't respond. Make sure RxCode is open and connected, then try again." + ) + } + } + } + + // 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 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 } + 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 updateDesktopSettings(_ update: MobileSettingsUpdatePayload) async { + guard isPaired else { return } + applySettingsUpdateLocally(update) + try? await client.send(.settingsUpdate(update), toHex: pairedDesktopPubkey) + } + + func refreshSnapshot() async { + 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 + apnsEnvironment = environment + Task { await reportAPNsTokenIfPending() } + } + + func unpair() async { + 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 + projectBranches = [:] + availableBranchesByProject = [:] + inFlightBranchOps = [] + lastBranchOpError = nil + messagesBySession = [:] + activeSessionID = nil + pendingPermission = nil + savePairedDesktop() + await resetIdentityAndClient() + } + + // MARK: - Inbound events + + 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() } + } + case .deliveryFailed: + break + case .inbound(let inbound): + handleInbound(inbound) + } + } + + 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 + 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 { + await self.requestSnapshot() + await self.reportAPNsTokenIfPending() + } + } else { + isPaired = false + 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 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 + } else if messagesBySession[active] == nil { + messagesBySession[active] = [] + } + activeSessionID = active + } + case .sessionUpdate(let update): + 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: + break + } + } + + 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 { + 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 .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 + } + } + + 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, + queuedMessages: current.queuedMessages + ) + } + + private func requestSnapshot() async { + guard isPaired else { return } + let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) + 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 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) + 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(self.pairedDesktopPubkey.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + } catch { + 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)") + } + } + + 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, + availableModels: current.availableModels + ) + } + + // MARK: - Persistence + + 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() { + 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() + } + } +} + +@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) + } + + /// 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 new file mode 100644 index 00000000..7ac095c6 --- /dev/null +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import RxCodeCore +import RxCodeChatKit +import RxCodeSync + +struct BriefingGroupKey: Hashable { + let projectId: UUID + let branch: String +} + +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 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: 12) { + 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: 12 + ) { + ForEach(groups) { group in + NavigationLink(value: group.key) { + briefingRow(group) + } + .buttonStyle(.plain) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, horizontalPadding(for: proxy.size.width)) + .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] { + 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: 12, + alignment: .top + ) + ] + } + + private func horizontalPadding(for width: CGFloat) -> CGFloat { + if width >= 900 { + return 24 + } else if width >= 600 { + return 20 + } else { + return 16 + } + } + + private var projectsById: [UUID: Project] { + Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) + } + + private var groups: [GroupedBriefing] { + groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) + } + + 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))) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + 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()) + } +} + +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) + ) + } + + 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 new file mode 100644 index 00000000..8c9bbedd --- /dev/null +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -0,0 +1,254 @@ +import SwiftUI +import RxCodeCore +import RxCodeChatKit +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. +struct MobileChatView: View { + @EnvironmentObject private var state: MobileAppState + let sessionID: String + @State private var composer: String = "" + @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 { + 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: Self.bottomContentPadding) + .id(Self.bottomAnchorID) + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + .scrollDismissesKeyboard(.interactively) + .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 } + } + .onAppear { + if !didEstablishInitialScroll { + didEstablishInitialScroll = true + } + } + .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) + } + } + + private var messages: [ChatMessage] { + state.messagesBySession[sessionID] ?? [] + } + + private var title: String { + 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 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 new file mode 100644 index 00000000..001aeab4 --- /dev/null +++ b/RxCodeMobile/Views/MobileInputBar.swift @@ -0,0 +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 + 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) { + inputField + .frame(maxWidth: .infinity) + + if isStreaming { + stopButton + if hasText { + sendButton + .transition(.scale.combined(with: .opacity)) + } + } else { + sendButton + } + } + .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() + } + } + } + + // 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/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/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift new file mode 100644 index 00000000..fc0955f0 --- /dev/null +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -0,0 +1,255 @@ +import SwiftUI +import RxCodeCore +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 { + Section("Paired Mac") { + HStack { + Image(systemName: "desktopcomputer").frame(width: 22) + Text(state.pairedDesktopName.isEmpty ? "Unknown Mac" : state.pairedDesktopName) + } + HStack { + Text("Connection") + Spacer() + connectionLabel + } + } + + 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 + } 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) + .refreshable { + await state.refreshSnapshot() + } + .toolbar { + if showsDoneButton { + 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() + } + } + .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 + } + } + + @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(let seconds): + Label("Reconnecting in \(seconds)s", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) + } + } +} 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/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift new file mode 100644 index 00000000..d28346ef --- /dev/null +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -0,0 +1,405 @@ +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 { + ZStack { + BackdropOrbs() + + 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) + } + .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 { + 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() } + } 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.") + } + .sheet(isPresented: $showScanner) { + 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 { + MobileHaptics.buttonTap() + 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 { + MobileHaptics.buttonTap() + 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 { + MobileHaptics.buttonTap() + 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 { + MobileHaptics.buttonTap() + 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) { + 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 + } + pairingError = nil + 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 new file mode 100644 index 00000000..72498ab1 --- /dev/null +++ b/RxCodeMobile/Views/PermissionApprovalSheet.swift @@ -0,0 +1,73 @@ +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 + 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..9e18bf9b --- /dev/null +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -0,0 +1,179 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +struct ProjectsSidebar: View { + @EnvironmentObject private var state: MobileAppState + @Binding var selected: UUID? + @Binding var showingBriefing: Bool + var showsBriefingItem = true + var usesSelection = true + @State private var searchText = "" + + var body: some View { + list + .navigationTitle("Projects") + .listStyle(.sidebar) + .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 + private var list: some View { + if usesSelection { + List(selection: $selected) { + listContent + } + } else { + List { + listContent + } + } + } + + @ViewBuilder + private var listContent: some View { + if searchText.isEmpty { + defaultContent + } else { + searchResultsContent + } + } + + @ViewBuilder + private var defaultContent: some View { + if showsBriefingItem { + 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) { + 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) + } + } 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/QRScannerView.swift b/RxCodeMobile/Views/QRScannerView.swift new file mode 100644 index 00000000..151829c2 --- /dev/null +++ b/RxCodeMobile/Views/QRScannerView.swift @@ -0,0 +1,113 @@ +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 + } + + 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 + + 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 + preview.frame = view.layer.bounds + view.layer.addSublayer(preview) + previewLayer = preview + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.session.startRunning() + scannerLogger.info("capture session started") + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.layer.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + 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 { + 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 new file mode 100644 index 00000000..993811c6 --- /dev/null +++ b/RxCodeMobile/Views/RootView.swift @@ -0,0 +1,188 @@ +import SwiftUI +import RxCodeSync + +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 { + if state.isPaired { + paired + } else { + OnboardingView() + } + } + .sheet(item: $state.pendingPermission) { req in + PermissionApprovalSheet(request: req) + .environmentObject(state) + } + .mobileDismissesKeyboardOnScroll() + } + + private var paired: some View { + 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 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 + ) + } + .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 { + 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: { + NavigationStack { + MobileBriefingView() + } + } + } + + private var projectSplitView: some View { + NavigationSplitView { + projectSidebar + } content: { + if let projectID = selectedProject { + SessionsList(projectID: projectID, selected: $selectedSession) + } else { + Text("Select a project") + .foregroundStyle(.secondary) + } + } detail: { + if !showingBriefing, let sessionID = selectedSession { + chatDestination(sessionID) + } else { + Text("Select a thread") + .foregroundStyle(.secondary) + } + } + } + + 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) + .toolbar(.hidden, for: .tabBar) + .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 new file mode 100644 index 00000000..da2ed7e7 --- /dev/null +++ b/RxCodeMobile/Views/SessionsList.swift @@ -0,0 +1,119 @@ +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 + @State private var searchText = "" + @State private var showingNewThread = false + + var body: some View { + list + .navigationTitle("Threads") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + 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 + 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) { + HStack(spacing: 6) { + if let attention = session.attention { + statusDot(for: attention) + } + + SessionSidebarRow( + title: rowTitle(for: session), + updatedAt: session.updatedAt, + isPinned: session.isPinned, + isBackgroundStreaming: session.isStreaming + ) + } + } + } + + private var filtered: [SessionSummary] { + 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 rowTitle(for session: SessionSummary) -> String { + let cleaned = ChatSession.stripAttachmentMarkers(from: session.title) + return cleaned.isEmpty ? ChatSession.defaultTitle : cleaned + } + + 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") + } +} diff --git a/RxCodeMobileNotificationService/Info.plist b/RxCodeMobileNotificationService/Info.plist new file mode 100644 index 00000000..9ebb8404 --- /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 + $(MARKETING_VERSION) + 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..75725d58 --- /dev/null +++ b/RxCodeMobileNotificationService/NotificationService.swift @@ -0,0 +1,115 @@ +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 +/// 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? + 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 + } + + 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(rxcodeHexString: 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 + } + + 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 + 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) + } + } +} + +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.. + + + + keychain-access-groups + + $(AppIdentifierPrefix)app.rxlab.rxcodemobile.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..abc10ef6 --- /dev/null +++ b/RxCodeTests/RxCodeMobile/Views/MobileChatView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import RxCodeCore +import RxCodeChatKit + +/// 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) { + ChatMessageListView(messages: messages) + Color.clear + .frame(height: 1) + .id("message-list-bottom") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .onChange(of: messages.last?.id) { _, _ in + withAnimation { proxy.scrollTo("message-list-bottom", 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/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/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 new file mode 100644 index 00000000..de1a4311 --- /dev/null +++ b/relay-server/README.md @@ -0,0 +1,132 @@ +# 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 +``` + +## 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 +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 \ + -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. +- `-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..61147108 --- /dev/null +++ b/relay-server/go.mod @@ -0,0 +1,15 @@ +module github.com/rxlab/rxcode-relay + +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 +) + +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..ab5d0afa --- /dev/null +++ b/relay-server/go.sum @@ -0,0 +1,37 @@ +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/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= +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..d068ad8f --- /dev/null +++ b/relay-server/main.go @@ -0,0 +1,138 @@ +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() { + // 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 != "" || len(keyPEM) > 0 { + p, err := NewPushSender(*apnsKeyPath, keyPEM, *apnsKeyID, *apnsTeamID, *apnsTopic, *apnsProduction) + if err != nil { + log.Fatalf("init APNs sender: %v", err) + } + pushSender = p + 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 (set APNS_KEY_B64 or -apns-key to enable)") + } + + 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) +} + +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 new file mode 100644 index 00000000..e224f1d7 --- /dev/null +++ b/relay-server/push.go @@ -0,0 +1,188 @@ +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. +// +// 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") + } + 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 { + 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 + } + + 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( + "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 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, + 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, + "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] +} 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; +}