diff --git a/Packages/Sources/RxCodeChatKit/ChangeDiffView.swift b/Packages/Sources/RxCodeChatKit/ChangeDiffView.swift new file mode 100644 index 0000000..06a0dbc --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChangeDiffView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import RxCodeCore + +/// Full, non-collapsing diff renderer for a single file. Used by the mobile +/// "View Changes" detail page, which owns a whole screen and therefore renders +/// every diff line. Accepts either a raw unified diff string (git changes) or a +/// set of old/new edit-hunk pairs (thread file edits). +/// +/// The +/- coloring intentionally mirrors `ToolResultView`'s inline chat diffs. +public struct ChangeDiffView: View { + private enum Source { + case unified(String) + case hunks([PreviewFile.EditHunk]) + } + + private let source: Source + + /// Renders a raw unified diff, e.g. `git diff` output. + public init(unifiedDiff: String) { + source = .unified(unifiedDiff) + } + + /// Renders old/new replacement pairs as a removed-then-added diff. + public init(hunks: [PreviewFile.EditHunk]) { + source = .hunks(hunks) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + switch source { + case .unified(let diff): + unifiedRows(diff) + case .hunks(let hunks): + hunkRows(hunks) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + // MARK: - Unified diff + + @ViewBuilder + private func unifiedRows(_ diff: String) -> some View { + let lines = diff.components(separatedBy: .newlines) + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + diffRow( + text: line.isEmpty ? " " : line, + color: unifiedColor(line), + background: unifiedBackground(line) + ) + } + } + + // MARK: - Edit hunks + + @ViewBuilder + private func hunkRows(_ hunks: [PreviewFile.EditHunk]) -> some View { + ForEach(Array(hunks.enumerated()), id: \.offset) { index, hunk in + if index > 0 { + Divider().padding(.vertical, 4) + } + let removed = hunk.oldString + .components(separatedBy: .newlines) + .map { ("- " + $0, ClaudeTheme.statusError, ClaudeTheme.statusError.opacity(0.06)) } + let added = hunk.newString + .components(separatedBy: .newlines) + .map { ("+ " + $0, ClaudeTheme.statusSuccess, ClaudeTheme.statusSuccess.opacity(0.06)) } + ForEach(Array((removed + added).enumerated()), id: \.offset) { _, item in + diffRow(text: item.0, color: item.1, background: item.2) + } + } + } + + // MARK: - Shared row + + private func diffRow(text: String, color: Color, background: Color) -> some View { + ChatTextContentView( + text, + size: ClaudeTheme.messageSize(12), + design: .monospaced, + color: color + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(background) + } + + private func unifiedColor(_ line: String) -> Color { + if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess } + if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError } + if line.hasPrefix("@@") { return ClaudeTheme.accent } + return ClaudeTheme.textPrimary + } + + private func unifiedBackground(_ line: String) -> Color { + if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess.opacity(0.06) } + if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError.opacity(0.06) } + if line.hasPrefix("@@") { return ClaudeTheme.accent.opacity(0.08) } + return Color.clear + } +} diff --git a/Packages/Sources/RxCodeChatKit/FeatureTips.swift b/Packages/Sources/RxCodeChatKit/FeatureTips.swift new file mode 100644 index 0000000..604878f --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/FeatureTips.swift @@ -0,0 +1,22 @@ +import SwiftUI +import TipKit + +enum ChatFeatureTips { + struct PlanModeTip: Tip { + var title: Text { + Text("Start in plan mode") + } + + var message: Text? { + Text("Use the Add menu or Shift-Tab to ask the agent for a read-only plan before edits begin.") + } + + var image: Image? { + Image(systemName: "checklist") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } +} diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift index d904b5a..5778532 100644 --- a/Packages/Sources/RxCodeChatKit/InputBarView.swift +++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import TipKit import UniformTypeIdentifiers import RxCodeCore @@ -246,6 +247,7 @@ struct InputBarView: View { .fixedSize() .help(windowState.sessionPlanMode ? "Plan mode is on — Add menu" : "Add — attach file or toggle plan mode") .accessibilityIdentifier("composer-add-menu") + .popoverTip(ChatFeatureTips.PlanModeTip(), arrowEdge: .top) .fileImporter( isPresented: $showFilePicker, allowedContentTypes: [.item], diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 2b2e331..ebd4c4c 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -12,9 +12,6 @@ struct MarkdownContentView: View { let showsTrailingCursor: Bool let isCursorVisible: Bool - /// `true` while the chat list is scrolling — see `markdownTextSelection`. - @Environment(\.chatListScrollActive) private var isScrollActive - init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) { self.text = text self.showsTrailingCursor = showsTrailingCursor @@ -37,7 +34,15 @@ struct MarkdownContentView: View { ) .textual.headingStyle(RxCodeHeadingStyle()) .textual.codeBlockStyle(RxCodeBlockStyle()) - .markdownTextSelection(enabled: !isScrollActive) + // Keep Textual's text-selection overlay permanently disabled. + // When enabled it installs a geometry-dependent + // `onChange(of: AnyTextLayoutCollection)` that fires many times + // per frame while the chat List scrolls, dropping frames and + // making the scroll bumpy. Toggling it per scroll-phase is worse: + // flipping selectability swaps Textual's view-tree branch and + // rebuilds every visible markdown row. Whole-message and + // per-code-block Copy buttons cover copying instead. + .textual.textSelection(.disabled) .frame(maxWidth: .infinity, alignment: .leading) } @@ -52,36 +57,6 @@ struct MarkdownContentView: View { } } -// MARK: - Text Selection Toggle - -extension EnvironmentValues { - /// `true` while the chat message list is actively scrolling. - /// - /// Markdown rows read this to drop Textual's text-selection overlay - /// mid-scroll — see `View.markdownTextSelection(enabled:)`. - @Entry var chatListScrollActive: Bool = false -} - -private extension View { - /// Applies Textual text selection, gated by `enabled`. - /// - /// Textual's selection overlay installs a per-message `Text.LayoutKey` - /// preference observer whose `onChange` mutates an `@Observable` model on - /// every layout pass. While a `List` scrolls, that fires repeatedly within - /// a single frame ("onChange(of: AnyTextLayoutCollection) ... tried to - /// update multiple times per frame"), dropping frames and making the - /// scroll bumpy. Suspending selection during scroll removes the overlay - /// entirely; it is restored the instant the list settles. - @ViewBuilder - func markdownTextSelection(enabled: Bool) -> some View { - if enabled { - textual.textSelection(.enabled) - } else { - textual.textSelection(.disabled) - } - } -} - // MARK: - Markdown Preprocessing /// Applies bare-URL auto-linking and link sanitization, skipping fenced code blocks diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index ea9b55d..21af677 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -17,9 +17,6 @@ struct MessageListView: View { @State private var readyTask: Task? @State private var anchor = AutoScrollAnchor() @State private var isSessionReady = false - /// Tracks active scrolling so markdown rows can suspend Textual's - /// text-selection overlay mid-scroll (avoids per-frame layout cycles). - @State private var isScrollActive = false private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") private static let bottomAnchorID = "message-list-bottom-anchor" @@ -71,7 +68,6 @@ struct MessageListView: View { .contentMargins(.top, 16, for: .scrollContent) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 0) - .environment(\.chatListScrollActive, isScrollActive) .opacity(isSessionReady ? 1 : 0) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollSample.self) { geo in @@ -86,11 +82,6 @@ struct MessageListView: View { scrollToBottomDebounced(proxy) } } - .onScrollPhaseChange { _, newPhase in - // Suspend Textual text selection while the list is in motion and - // restore it the instant scrolling settles back to `.idle`. - isScrollActive = newPhase != .idle - } .task(id: windowState.currentSessionId) { let sid = windowState.currentSessionId ?? "" Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") diff --git a/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift b/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift index 2a95614..5eec29d 100644 --- a/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift +++ b/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift @@ -18,6 +18,9 @@ public struct BackendSendRequest: Sendable { public let hookSettingsPath: String? /// Path to the Claude MCP config JSON written for this turn. Claude-only. public let mcpClaudeConfigPath: String? + /// Extra text appended to the agent's system prompt for this turn — e.g. the + /// accumulated briefing for the project's current branch. Claude-only. + public let extraSystemPrompt: String? /// `-c` overrides handed to the Codex app-server child. Codex-only. public let mcpCodexOverrides: [String] /// JSON-RPC payload for ACP's `session/new` `mcpServers` parameter. @@ -39,6 +42,7 @@ public struct BackendSendRequest: Sendable { planMode: Bool = false, hookSettingsPath: String? = nil, mcpClaudeConfigPath: String? = nil, + extraSystemPrompt: String? = nil, mcpCodexOverrides: [String] = [], acpMCPServers: [JSONValue] = [], acpSpec: ACPClientSpec? = nil, @@ -54,6 +58,7 @@ public struct BackendSendRequest: Sendable { self.planMode = planMode self.hookSettingsPath = hookSettingsPath self.mcpClaudeConfigPath = mcpClaudeConfigPath + self.extraSystemPrompt = extraSystemPrompt self.mcpCodexOverrides = mcpCodexOverrides self.acpMCPServers = acpMCPServers self.acpSpec = acpSpec diff --git a/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift b/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift index 636518f..b308c90 100644 --- a/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift +++ b/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift @@ -13,6 +13,7 @@ public enum BackendCapability: String, Sendable, Hashable, CaseIterable, Codable case attachments case hooks case mcpServers + case skills } public typealias CapabilitySet = Set @@ -26,16 +27,16 @@ public extension AgentProvider { case .claudeCode: return [ .askUserQuestion, .todos, .planMode, .fileEdit, .hooks, - .mcpServers, .attachments, .customSlashCommands, .getUsage, + .mcpServers, .skills, .attachments, .customSlashCommands, .getUsage, ] case .codex: return [ .askUserQuestion, .todos, .planMode, .fileEdit, - .mcpServers, .attachments, .getUsage, + .mcpServers, .skills, .attachments, .getUsage, ] case .acp: return [ - .planMode, .fileEdit, .mcpServers, .attachments, .getUsage, + .planMode, .fileEdit, .mcpServers, .skills, .attachments, .getUsage, ] } } diff --git a/Packages/Sources/RxCodeCore/Models/GitHubModels.swift b/Packages/Sources/RxCodeCore/Models/GitHubModels.swift index 32503a1..965cb17 100644 --- a/Packages/Sources/RxCodeCore/Models/GitHubModels.swift +++ b/Packages/Sources/RxCodeCore/Models/GitHubModels.swift @@ -83,6 +83,20 @@ public struct DeviceCodeResponse: Codable, Sendable { } } +// MARK: - Custom Git Repository + +public struct CustomRepo: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public var name: String + public var cloneURL: String + + public init(id: UUID = UUID(), name: String, cloneURL: String) { + self.id = id + self.name = name + self.cloneURL = cloneURL + } +} + // MARK: - Device Flow: Access Token Response public struct AccessTokenResponse: Codable, Sendable { diff --git a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift index e62f15b..c9574ef 100644 --- a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift +++ b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift @@ -8,17 +8,20 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { public let category: String public let homepage: String public let marketplace: String + public let marketplaceSource: MarketplaceSource? public let sourceType: SourceType public let skillPaths: [String] public init(name: String, description: String, author: String, category: String, - homepage: String, marketplace: String, sourceType: SourceType, skillPaths: [String]) { + homepage: String, marketplace: String, marketplaceSource: MarketplaceSource? = nil, + sourceType: SourceType, skillPaths: [String]) { self.name = name self.description = description self.author = author self.category = category self.homepage = homepage self.marketplace = marketplace + self.marketplaceSource = marketplaceSource self.sourceType = sourceType self.skillPaths = skillPaths } @@ -28,6 +31,7 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { case url case gitSubdir = "git-subdir" case skillsBundle = "skills-bundle" + case agentSkill = "agent-skill" } public var categoryLabel: String { @@ -39,6 +43,7 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { case "agent-skills": return "Agent Skills" case "knowledge-work": return "Knowledge Work" case "financial-services": return "Financial Services" + case "codex-curated": return "Codex Curated" default: return category.replacingOccurrences(of: "-", with: " ").capitalized } } @@ -49,12 +54,127 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { case "anthropic-agent-skills": return "Agent Skills" case "knowledge-work-plugins": return "Knowledge Work" case "financial-services-plugins": return "Financial Services" + case "openai-skills-curated": return "Codex Curated" default: return marketplace } } public var installCommand: String { - "/plugin install \(name)@\(marketplace)" + "Install \(name) from \(marketplace)" + } +} + +public struct MarketplaceSource: Codable, Sendable, Hashable { + public let owner: String + public let repo: String + public let ref: String? + + public init(owner: String, repo: String, ref: String? = nil) { + self.owner = owner + self.repo = repo + self.ref = ref + } + + public var codexSource: String { + if let ref, !ref.isEmpty { + return "\(owner)/\(repo)@\(ref)" + } + return "\(owner)/\(repo)" + } +} + +public struct MarketplaceCustomSource: Codable, Sendable, Hashable, Identifiable { + public var id: String { source.codexSource } + public var source: MarketplaceSource + public var defaultCategory: String + public var addedAt: Date + + public init( + source: MarketplaceSource, + defaultCategory: String = "custom", + addedAt: Date = Date() + ) { + self.source = source + self.defaultCategory = defaultCategory + self.addedAt = addedAt + } + + public var displayName: String { + source.codexSource + } +} + +public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable { + public var id: String { "\(marketplace)/\(name)" } + public var name: String + public var marketplace: String + public var summary: String? + public var category: String? + public var marketplaceSource: MarketplaceSource? + public var sourceType: MarketplacePlugin.SourceType? + public var skillPaths: [String]? + public var instructions: String? + public var installedAt: Date + public var isGloballyEnabled: Bool + public var enabledProviders: Set + + public init( + name: String, + marketplace: String, + summary: String? = nil, + category: String? = nil, + marketplaceSource: MarketplaceSource?, + sourceType: MarketplacePlugin.SourceType? = nil, + skillPaths: [String]? = nil, + instructions: String? = nil, + installedAt: Date = Date(), + isGloballyEnabled: Bool = true, + enabledProviders: Set = Set(AgentProvider.allCases) + ) { + self.name = name + self.marketplace = marketplace + self.summary = summary + self.category = category + self.marketplaceSource = marketplaceSource + self.sourceType = sourceType + self.skillPaths = skillPaths + self.instructions = instructions + self.installedAt = installedAt + self.isGloballyEnabled = isGloballyEnabled + self.enabledProviders = enabledProviders + } + + public func isEnabled(for provider: AgentProvider) -> Bool { + isGloballyEnabled && enabledProviders.contains(provider) + } +} + +public struct MarketplacePluginConfiguration: Codable, Sendable, Equatable { + public var version: Int + public var plugins: [MarketplacePluginRecord] + public var customSources: [MarketplaceCustomSource] + + public init( + version: Int = 2, + plugins: [MarketplacePluginRecord] = [], + customSources: [MarketplaceCustomSource] = [] + ) { + self.version = version + self.plugins = plugins + self.customSources = customSources + } + + private enum CodingKeys: String, CodingKey { + case version + case plugins + case customSources + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + plugins = try container.decodeIfPresent([MarketplacePluginRecord].self, forKey: .plugins) ?? [] + customSources = try container.decodeIfPresent([MarketplaceCustomSource].self, forKey: .customSources) ?? [] } } diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index 78c1cfd..0bdb113 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -247,6 +247,119 @@ public enum GitHelper { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } + + // MARK: - Uncommitted changes + + /// Which side of the working tree an uncommitted change lives on. + public enum GitChangeKind: Sendable { + case staged + case unstaged + case untracked + } + + /// One uncommitted file in the working tree, with its unified diff. + public struct GitChange: Sendable { + public let displayPath: String + public let statusChar: String + public let kind: GitChangeKind + public let unifiedDiff: String + public let truncated: Bool + } + + /// Maximum number of diff lines kept per file before truncation. + private static let maxDiffLines = 800 + + /// Returns every uncommitted change in the working tree at `repoPath`: + /// staged, unstaged, and untracked files, each with its unified diff. A + /// file modified both in the index and the worktree yields two entries + /// (one `.staged`, one `.unstaged`). Returns nil when `repoPath` is not a + /// git repository. + public static func uncommittedChanges(at repoPath: String) async -> [GitChange]? { + guard let statusRaw = await run( + ["status", "--porcelain=v1", "-z"], + at: repoPath + ) else { + return nil + } + + var changes: [GitChange] = [] + // Porcelain `-z` records are NUL-terminated; renames/copies append a + // second NUL-terminated token for the original path. + let tokens = statusRaw.split(separator: "\0", omittingEmptySubsequences: false).map(String.init) + var i = 0 + while i < tokens.count { + let entry = tokens[i] + i += 1 + guard entry.count >= 3 else { continue } + let chars = Array(entry) + let indexChar = chars[0] // staged side + let worktreeChar = chars[1] // worktree side + let displayPath = String(entry.dropFirst(3)) + + let isUntracked = (indexChar == "?" && worktreeChar == "?") + let isRename = indexChar == "R" || worktreeChar == "R" + if isRename, i < tokens.count { + i += 1 // skip the rename's original-path token + } + + if isUntracked { + let diff = await untrackedDiff(displayPath: displayPath, repoPath: repoPath) + changes.append(GitChange( + displayPath: displayPath, + statusChar: "?", + kind: .untracked, + unifiedDiff: diff.text, + truncated: diff.truncated + )) + } else if worktreeChar != " " { + let raw = await run(["diff", "--no-color", "--", displayPath], at: repoPath) ?? "" + let clipped = clipDiff(raw) + changes.append(GitChange( + displayPath: displayPath, + statusChar: String(worktreeChar), + kind: .unstaged, + unifiedDiff: clipped.text, + truncated: clipped.truncated + )) + } + + if !isUntracked, indexChar != " " { + let raw = await run(["diff", "--cached", "--no-color", "--", displayPath], at: repoPath) ?? "" + let clipped = clipDiff(raw) + changes.append(GitChange( + displayPath: displayPath, + statusChar: String(indexChar), + kind: .staged, + unifiedDiff: clipped.text, + truncated: clipped.truncated + )) + } + } + return changes + } + + /// Builds an all-added pseudo-diff for an untracked file by reading its + /// contents. Binary or unreadable files yield an empty diff. + private static func untrackedDiff( + displayPath: String, + repoPath: String + ) async -> (text: String, truncated: Bool) { + let absolute = (repoPath as NSString).appendingPathComponent(displayPath) + guard let content = try? String(contentsOf: URL(fileURLWithPath: absolute), encoding: .utf8) else { + return ("", false) + } + let lines = content.components(separatedBy: "\n") + let capped = lines.count > maxDiffLines + let kept = capped ? Array(lines.prefix(maxDiffLines)) : lines + return (kept.map { "+" + $0 }.joined(separator: "\n"), capped) + } + + /// Clips a unified diff to `maxDiffLines` lines. + private static func clipDiff(_ diff: String) -> (text: String, truncated: Bool) { + let lines = diff.components(separatedBy: "\n") + guard lines.count > maxDiffLines else { return (diff, false) } + return (lines.prefix(maxDiffLines).joined(separator: "\n"), true) + } } #endif diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index c458c2a..1a826dd 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -11,6 +11,7 @@ public enum Payload: Sendable { case pairAck(PairAckPayload) case unpair(UnpairPayload) case apnsToken(APNsTokenPayload) + case liveActivityToken(LiveActivityTokenPayload) case requestSnapshot(RequestSnapshotPayload) case snapshot(SnapshotPayload) case settingsUpdate(MobileSettingsUpdatePayload) @@ -23,6 +24,8 @@ public enum Payload: Sendable { case threadActionRequest(ThreadActionRequestPayload) case loadMoreMessages(LoadMoreMessagesRequestPayload) case moreMessages(MoreMessagesPayload) + case threadChangesRequest(ThreadChangesRequestPayload) + case threadChangesResult(ThreadChangesResultPayload) case searchRequest(SearchRequestPayload) case searchResults(SearchResultsPayload) case notification(NotificationPayload) @@ -42,6 +45,20 @@ public enum Payload: Sendable { case runProfileRunRequest(RunProfileRunRequestPayload) case runProfileStopRequest(RunProfileStopRequestPayload) case runTaskUpdate(RunTaskUpdatePayload) + case skillCatalogRequest(SkillCatalogRequestPayload) + case skillCatalogResult(SkillCatalogResultPayload) + case skillMutationRequest(SkillMutationRequestPayload) + case skillMutationResult(SkillMutationResultPayload) + case skillSourceMutationRequest(SkillSourceMutationRequestPayload) + case skillSourceMutationResult(SkillSourceMutationResultPayload) + case acpRegistryRequest(ACPRegistryRequestPayload) + case acpRegistryResult(ACPRegistryResultPayload) + case acpMutationRequest(ACPMutationRequestPayload) + case acpMutationResult(ACPMutationResultPayload) + case mcpConfigRequest(MCPConfigRequestPayload) + case mcpConfigResult(MCPConfigResultPayload) + case mcpMutationRequest(MCPMutationRequestPayload) + case mcpMutationResult(MCPMutationResultPayload) case ping(PingPayload) case pong(PongPayload) case unknown(type: String) @@ -54,6 +71,7 @@ public extension Payload { case .pairAck: return "pair_ack" case .unpair: return "unpair" case .apnsToken: return "apns_token" + case .liveActivityToken: return "live_activity_token" case .requestSnapshot: return "request_snapshot" case .snapshot: return "snapshot" case .settingsUpdate: return "settings_update" @@ -66,6 +84,8 @@ public extension Payload { case .threadActionRequest: return "thread_action_request" case .loadMoreMessages: return "load_more_messages" case .moreMessages: return "more_messages" + case .threadChangesRequest: return "thread_changes_request" + case .threadChangesResult: return "thread_changes_result" case .searchRequest: return "search_request" case .searchResults: return "search_results" case .notification: return "notification" @@ -85,6 +105,20 @@ public extension Payload { case .runProfileRunRequest: return "run_profile_run_request" case .runProfileStopRequest: return "run_profile_stop_request" case .runTaskUpdate: return "run_task_update" + case .skillCatalogRequest: return "skill_catalog_request" + case .skillCatalogResult: return "skill_catalog_result" + case .skillMutationRequest: return "skill_mutation_request" + case .skillMutationResult: return "skill_mutation_result" + case .skillSourceMutationRequest: return "skill_source_mutation_request" + case .skillSourceMutationResult: return "skill_source_mutation_result" + case .acpRegistryRequest: return "acp_registry_request" + case .acpRegistryResult: return "acp_registry_result" + case .acpMutationRequest: return "acp_mutation_request" + case .acpMutationResult: return "acp_mutation_result" + case .mcpConfigRequest: return "mcp_config_request" + case .mcpConfigResult: return "mcp_config_result" + case .mcpMutationRequest: return "mcp_mutation_request" + case .mcpMutationResult: return "mcp_mutation_result" case .ping: return "ping" case .pong: return "pong" case .unknown(let type): return type @@ -134,6 +168,50 @@ public struct APNsTokenPayload: Codable, Sendable { } } +/// Mobile → desktop: ActivityKit push tokens for the job Live Activity. A +/// single payload reports either the device-wide push-to-start token, a +/// per-activity update token, or both. The desktop stores them per paired +/// device so it can remotely start, update, and end Live Activities over APNs. +public struct LiveActivityTokenPayload: Codable, Sendable { + /// Device-wide push-to-start token (iOS 17.2+). Lets the desktop start a + /// Live Activity for a new job remotely. `nil` when this payload only + /// reports a per-activity update token. + public let pushToStartTokenHex: String? + /// Per-activity update token returned by `Activity.pushTokenUpdates`. + /// `nil` when this payload only reports a push-to-start token. + public let activityTokenHex: String? + /// Identifier of the `Activity` the update token belongs to. + public let activityID: String? + /// The job (chat session) the activity tracks. + public let sessionID: String? + /// `true` when the user dismissed the Live Activity on the device. The + /// desktop then forgets the activity so the next stream of the same session + /// starts a fresh one instead of pushing to a token that no longer renders. + public let activityDismissed: Bool? + /// `true` when the foregrounded device started the Live Activity itself + /// with `Activity.request`. Reported the instant the activity is created — + /// well before its per-activity push token, which APNs can take several + /// seconds to mint — so the desktop can cancel its deferred push-to-start + /// and never spawn a duplicate activity. + public let activityStartedLocally: Bool? + + public init( + pushToStartTokenHex: String? = nil, + activityTokenHex: String? = nil, + activityID: String? = nil, + sessionID: String? = nil, + activityDismissed: Bool? = nil, + activityStartedLocally: Bool? = nil + ) { + self.pushToStartTokenHex = pushToStartTokenHex + self.activityTokenHex = activityTokenHex + self.activityID = activityID + self.sessionID = sessionID + self.activityDismissed = activityDismissed + self.activityStartedLocally = activityStartedLocally + } +} + public struct RequestSnapshotPayload: Codable, Sendable { public let activeSessionID: String? public init(activeSessionID: String? = nil) { @@ -637,6 +715,487 @@ public struct RunTaskUpdatePayload: Codable, Sendable { } } +// MARK: - Skills / ACP / MCP remote management + +/// Mobile asks the desktop for the skill marketplace catalog. `forceRefresh` +/// bypasses the desktop's 5-minute marketplace cache. +public struct SkillCatalogRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let forceRefresh: Bool + + public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { + self.clientRequestID = clientRequestID + self.forceRefresh = forceRefresh + } +} + +/// One marketplace plugin flattened from the desktop's `MarketplacePlugin` +/// plus its current install state. `id` mirrors `MarketplacePlugin.id`. +public struct MobileSkillPlugin: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let name: String + public let summary: String + public let author: String + public let category: String + public let categoryLabel: String + public let marketplace: String + public let marketplaceLabel: String + public let homepage: String + public let isInstalled: Bool + + public init( + id: String, + name: String, + summary: String, + author: String, + category: String, + categoryLabel: String, + marketplace: String, + marketplaceLabel: String, + homepage: String, + isInstalled: Bool + ) { + self.id = id + self.name = name + self.summary = summary + self.author = author + self.category = category + self.categoryLabel = categoryLabel + self.marketplace = marketplace + self.marketplaceLabel = marketplaceLabel + self.homepage = homepage + self.isInstalled = isInstalled + } +} + +public struct MobileSkillSource: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} + +public struct SkillCatalogResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources + } +} + +/// Mobile asks the desktop to install or remove a marketplace skill. `pluginID` +/// is the catalog id; the desktop re-resolves the authoritative plugin from its +/// own freshly-fetched catalog. +public struct SkillMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case install + case uninstall + } + + public let clientRequestID: UUID + public let operation: Operation + public let pluginID: String + + public init(clientRequestID: UUID = UUID(), operation: Operation, pluginID: String) { + self.clientRequestID = clientRequestID + self.operation = operation + self.pluginID = pluginID + } +} + +public struct SkillMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: SkillMutationRequestPayload.Operation + public let pluginID: String + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + operation: SkillMutationRequestPayload.Operation, + pluginID: String, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.pluginID = pluginID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources + } +} + +public struct SkillSourceMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case add + case remove + } + + public let clientRequestID: UUID + public let operation: Operation + public let sourceID: String? + public let gitURL: String? + public let ref: String? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + sourceID: String? = nil, + gitURL: String? = nil, + ref: String? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.sourceID = sourceID + self.gitURL = gitURL + self.ref = ref + } +} + +public struct SkillSourceMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: SkillSourceMutationRequestPayload.Operation + public let sourceID: String? + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + operation: SkillSourceMutationRequestPayload.Operation, + sourceID: String? = nil, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.sourceID = sourceID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources + } +} + +/// Mobile asks the desktop for the ACP agent registry plus installed clients. +public struct ACPRegistryRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let forceRefresh: Bool + + public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { + self.clientRequestID = clientRequestID + self.forceRefresh = forceRefresh + } +} + +/// A registry agent flattened from the desktop's `ACPRegistryAgent`, plus +/// whether a matching client is already installed locally. +public struct MobileACPRegistryAgent: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let name: String + public let version: String + public let summary: String + public let authors: [String] + public let license: String? + public let website: String? + public let iconURL: String? + public let isInstalled: Bool + public let hasBinary: Bool + public let hasNpx: Bool + public let hasUvx: Bool + + public init( + id: String, + name: String, + version: String, + summary: String, + authors: [String] = [], + license: String? = nil, + website: String? = nil, + iconURL: String? = nil, + isInstalled: Bool, + hasBinary: Bool, + hasNpx: Bool, + hasUvx: Bool + ) { + self.id = id + self.name = name + self.version = version + self.summary = summary + self.authors = authors + self.license = license + self.website = website + self.iconURL = iconURL + self.isInstalled = isInstalled + self.hasBinary = hasBinary + self.hasNpx = hasNpx + self.hasUvx = hasUvx + } +} + +/// An installed ACP client mirrored from the desktop's `ACPClientSpec`. +public struct MobileACPClient: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let registryId: String? + public let displayName: String + public let enabled: Bool + public let launchKind: String + public let modelCount: Int + public let iconURL: String? + + public init( + id: String, + registryId: String? = nil, + displayName: String, + enabled: Bool, + launchKind: String, + modelCount: Int, + iconURL: String? = nil + ) { + self.id = id + self.registryId = registryId + self.displayName = displayName + self.enabled = enabled + self.launchKind = launchKind + self.modelCount = modelCount + self.iconURL = iconURL + } +} + +public struct ACPRegistryResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let registryAgents: [MobileACPRegistryAgent] + public let installedClients: [MobileACPClient] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + registryAgents: [MobileACPRegistryAgent] = [], + installedClients: [MobileACPClient] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.registryAgents = registryAgents + self.installedClients = installedClients + } +} + +/// Mobile asks the desktop to install an ACP agent from the registry, remove an +/// installed client, or toggle a client's enabled flag. +public struct ACPMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case install + case uninstall + case setEnabled + } + + public let clientRequestID: UUID + public let operation: Operation + public let registryAgentID: String? + public let clientID: String? + public let enabled: Bool? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + registryAgentID: String? = nil, + clientID: String? = nil, + enabled: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.registryAgentID = registryAgentID + self.clientID = clientID + self.enabled = enabled + } +} + +public struct ACPMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: ACPMutationRequestPayload.Operation + public let ok: Bool + public let errorMessage: String? + public let registryAgents: [MobileACPRegistryAgent] + public let installedClients: [MobileACPClient] + + public init( + clientRequestID: UUID, + operation: ACPMutationRequestPayload.Operation, + ok: Bool, + errorMessage: String? = nil, + registryAgents: [MobileACPRegistryAgent] = [], + installedClients: [MobileACPClient] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.ok = ok + self.errorMessage = errorMessage + self.registryAgents = registryAgents + self.installedClients = installedClients + } +} + +/// Mobile asks the desktop for the configured global MCP servers. +public struct MCPConfigRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + + public init(clientRequestID: UUID = UUID()) { + self.clientRequestID = clientRequestID + } +} + +/// A plain key/value pair for MCP environment variables and headers. The +/// desktop's `MCPKeyValue` carries a non-Codable UUID, so the wire uses this. +public struct MobileMCPKeyValue: Codable, Sendable, Equatable, Hashable { + public let key: String + public let value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } +} + +/// One global MCP server flattened from the desktop's `MCPServerRecord`. +public struct MobileMCPServer: Codable, Sendable, Identifiable, Equatable { + public var id: String { name } + + public let name: String + public let transport: String + public let url: String? + public let command: String? + public let args: [String] + public let env: [MobileMCPKeyValue] + public let headers: [MobileMCPKeyValue] + public let isGloballyEnabled: Bool + public let endpoint: String + + public init( + name: String, + transport: String, + url: String? = nil, + command: String? = nil, + args: [String] = [], + env: [MobileMCPKeyValue] = [], + headers: [MobileMCPKeyValue] = [], + isGloballyEnabled: Bool, + endpoint: String + ) { + self.name = name + self.transport = transport + self.url = url + self.command = command + self.args = args + self.env = env + self.headers = headers + self.isGloballyEnabled = isGloballyEnabled + self.endpoint = endpoint + } +} + +public struct MCPConfigResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let servers: [MobileMCPServer] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + servers: [MobileMCPServer] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.servers = servers + } +} + +/// Mobile asks the desktop to add/upsert, remove, or toggle a global MCP server. +public struct MCPMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case add + case remove + case setEnabled + } + + public let clientRequestID: UUID + public let operation: Operation + public let serverName: String + public let server: MobileMCPServer? + public let enabled: Bool? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + serverName: String, + server: MobileMCPServer? = nil, + enabled: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.serverName = serverName + self.server = server + self.enabled = enabled + } +} + +public struct MCPMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: MCPMutationRequestPayload.Operation + public let serverName: String + public let ok: Bool + public let errorMessage: String? + public let servers: [MobileMCPServer] + + public init( + clientRequestID: UUID, + operation: MCPMutationRequestPayload.Operation, + serverName: String, + ok: Bool, + errorMessage: String? = nil, + servers: [MobileMCPServer] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.serverName = serverName + self.ok = ok + self.errorMessage = errorMessage + self.servers = servers + } +} + public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { public var id: String { "\(projectId.uuidString)::\(branch)" } @@ -1203,6 +1762,117 @@ public struct SearchResultsPayload: Codable, Sendable { } } +// MARK: - Thread changes + +/// Mobile-initiated request for the change overview of a thread: every file +/// edited in the thread session plus the project's uncommitted git changes. +/// The desktop is the authoritative source for both (SwiftData edit history and +/// the working tree), so it builds the whole `ThreadChangesResultPayload`. +public struct ThreadChangesRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + + public init(clientRequestID: UUID = UUID(), sessionID: String) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + } +} + +/// One old/new replacement pair. Wire form of `PreviewFile.EditHunk`, which is +/// not itself `Codable`. +public struct SyncEditHunk: Codable, Sendable, Equatable { + public let oldString: String + public let newString: String + + public init(oldString: String, newString: String) { + self.oldString = oldString + self.newString = newString + } +} + +/// Aggregated edits to a single file across a whole thread session. Wire form +/// of `FileEditSummary`. +public struct SyncFileEdit: Codable, Sendable, Identifiable { + public var id: String { path } + public let path: String + public let name: String + /// True if any contributing tool was Write — old content was overwritten. + public let containsWrite: Bool + public let hunks: [SyncEditHunk] + + public init(path: String, name: String, containsWrite: Bool, hunks: [SyncEditHunk]) { + self.path = path + self.name = name + self.containsWrite = containsWrite + self.hunks = hunks + } +} + +/// Which side of the working tree a git change lives on. +public enum SyncGitChangeKind: String, Codable, Sendable { + case staged + case unstaged + case untracked +} + +/// One uncommitted file in the project's working tree, with its unified diff. +public struct SyncGitChange: Codable, Sendable, Identifiable { + public var id: String { "\(kind.rawValue):\(displayPath)" } + /// Path relative to the repository root. + public let displayPath: String + /// Porcelain status letter (M/A/D/R/?/…). + public let statusChar: String + public let kind: SyncGitChangeKind + /// Unified diff text. For untracked files this is an all-added diff. + public let unifiedDiff: String + /// True when `unifiedDiff` was clipped because it exceeded the line cap. + public let truncated: Bool + + public init( + displayPath: String, + statusChar: String, + kind: SyncGitChangeKind, + unifiedDiff: String, + truncated: Bool + ) { + self.displayPath = displayPath + self.statusChar = statusChar + self.kind = kind + self.unifiedDiff = unifiedDiff + self.truncated = truncated + } +} + +/// Desktop reply to a `ThreadChangesRequestPayload`: the two datasets backing +/// the mobile "View Changes" sheet. +public struct ThreadChangesResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// False when the request could not be served (e.g. not a git repository). + public let ok: Bool + public let errorMessage: String? + /// Every file edited in the thread session. + public let turnEdits: [SyncFileEdit] + /// Uncommitted git changes in the session's project. + public let uncommitted: [SyncGitChange] + + public init( + clientRequestID: UUID, + sessionID: String, + ok: Bool, + errorMessage: String? = nil, + turnEdits: [SyncFileEdit], + uncommitted: [SyncGitChange] + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.ok = ok + self.errorMessage = errorMessage + self.turnEdits = turnEdits + self.uncommitted = uncommitted + } +} + public struct NotificationPayload: Codable, Sendable { public enum Kind: String, Codable, Sendable { case responseComplete @@ -1393,6 +2063,7 @@ extension Payload: Codable { case pairAck = "pair_ack" case unpair case apnsToken = "apns_token" + case liveActivityToken = "live_activity_token" case requestSnapshot = "request_snapshot" case snapshot case settingsUpdate = "settings_update" @@ -1405,6 +2076,8 @@ extension Payload: Codable { case threadActionRequest = "thread_action_request" case loadMoreMessages = "load_more_messages" case moreMessages = "more_messages" + case threadChangesRequest = "thread_changes_request" + case threadChangesResult = "thread_changes_result" case searchRequest = "search_request" case searchResults = "search_results" case notification @@ -1424,6 +2097,20 @@ extension Payload: Codable { case runProfileRunRequest = "run_profile_run_request" case runProfileStopRequest = "run_profile_stop_request" case runTaskUpdate = "run_task_update" + case skillCatalogRequest = "skill_catalog_request" + case skillCatalogResult = "skill_catalog_result" + case skillMutationRequest = "skill_mutation_request" + case skillMutationResult = "skill_mutation_result" + case skillSourceMutationRequest = "skill_source_mutation_request" + case skillSourceMutationResult = "skill_source_mutation_result" + case acpRegistryRequest = "acp_registry_request" + case acpRegistryResult = "acp_registry_result" + case acpMutationRequest = "acp_mutation_request" + case acpMutationResult = "acp_mutation_result" + case mcpConfigRequest = "mcp_config_request" + case mcpConfigResult = "mcp_config_result" + case mcpMutationRequest = "mcp_mutation_request" + case mcpMutationResult = "mcp_mutation_result" case ping case pong } @@ -1440,6 +2127,7 @@ extension Payload: Codable { 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 .liveActivityToken: self = .liveActivityToken(try container.decode(LiveActivityTokenPayload.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)) @@ -1452,6 +2140,8 @@ extension Payload: Codable { case .threadActionRequest: self = .threadActionRequest(try container.decode(ThreadActionRequestPayload.self, forKey: .data)) case .loadMoreMessages: self = .loadMoreMessages(try container.decode(LoadMoreMessagesRequestPayload.self, forKey: .data)) case .moreMessages: self = .moreMessages(try container.decode(MoreMessagesPayload.self, forKey: .data)) + case .threadChangesRequest: self = .threadChangesRequest(try container.decode(ThreadChangesRequestPayload.self, forKey: .data)) + case .threadChangesResult: self = .threadChangesResult(try container.decode(ThreadChangesResultPayload.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)) @@ -1471,6 +2161,20 @@ extension Payload: Codable { case .runProfileRunRequest: self = .runProfileRunRequest(try container.decode(RunProfileRunRequestPayload.self, forKey: .data)) case .runProfileStopRequest: self = .runProfileStopRequest(try container.decode(RunProfileStopRequestPayload.self, forKey: .data)) case .runTaskUpdate: self = .runTaskUpdate(try container.decode(RunTaskUpdatePayload.self, forKey: .data)) + case .skillCatalogRequest: self = .skillCatalogRequest(try container.decode(SkillCatalogRequestPayload.self, forKey: .data)) + case .skillCatalogResult: self = .skillCatalogResult(try container.decode(SkillCatalogResultPayload.self, forKey: .data)) + case .skillMutationRequest: self = .skillMutationRequest(try container.decode(SkillMutationRequestPayload.self, forKey: .data)) + case .skillMutationResult: self = .skillMutationResult(try container.decode(SkillMutationResultPayload.self, forKey: .data)) + case .skillSourceMutationRequest: self = .skillSourceMutationRequest(try container.decode(SkillSourceMutationRequestPayload.self, forKey: .data)) + case .skillSourceMutationResult: self = .skillSourceMutationResult(try container.decode(SkillSourceMutationResultPayload.self, forKey: .data)) + case .acpRegistryRequest: self = .acpRegistryRequest(try container.decode(ACPRegistryRequestPayload.self, forKey: .data)) + case .acpRegistryResult: self = .acpRegistryResult(try container.decode(ACPRegistryResultPayload.self, forKey: .data)) + case .acpMutationRequest: self = .acpMutationRequest(try container.decode(ACPMutationRequestPayload.self, forKey: .data)) + case .acpMutationResult: self = .acpMutationResult(try container.decode(ACPMutationResultPayload.self, forKey: .data)) + case .mcpConfigRequest: self = .mcpConfigRequest(try container.decode(MCPConfigRequestPayload.self, forKey: .data)) + case .mcpConfigResult: self = .mcpConfigResult(try container.decode(MCPConfigResultPayload.self, forKey: .data)) + case .mcpMutationRequest: self = .mcpMutationRequest(try container.decode(MCPMutationRequestPayload.self, forKey: .data)) + case .mcpMutationResult: self = .mcpMutationResult(try container.decode(MCPMutationResultPayload.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)) } @@ -1483,6 +2187,7 @@ extension Payload: Codable { 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 .liveActivityToken(let p): try container.encode(TypeKey.liveActivityToken.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) @@ -1495,6 +2200,8 @@ extension Payload: Codable { case .threadActionRequest(let p): try container.encode(TypeKey.threadActionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .loadMoreMessages(let p): try container.encode(TypeKey.loadMoreMessages.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .moreMessages(let p): try container.encode(TypeKey.moreMessages.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .threadChangesRequest(let p): try container.encode(TypeKey.threadChangesRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .threadChangesResult(let p): try container.encode(TypeKey.threadChangesResult.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) @@ -1514,6 +2221,20 @@ extension Payload: Codable { case .runProfileRunRequest(let p): try container.encode(TypeKey.runProfileRunRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileStopRequest(let p): try container.encode(TypeKey.runProfileStopRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runTaskUpdate(let p): try container.encode(TypeKey.runTaskUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillCatalogRequest(let p): try container.encode(TypeKey.skillCatalogRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillCatalogResult(let p): try container.encode(TypeKey.skillCatalogResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillMutationRequest(let p): try container.encode(TypeKey.skillMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillMutationResult(let p): try container.encode(TypeKey.skillMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillSourceMutationRequest(let p): try container.encode(TypeKey.skillSourceMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillSourceMutationResult(let p): try container.encode(TypeKey.skillSourceMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpRegistryRequest(let p): try container.encode(TypeKey.acpRegistryRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpRegistryResult(let p): try container.encode(TypeKey.acpRegistryResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpMutationRequest(let p): try container.encode(TypeKey.acpMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpMutationResult(let p): try container.encode(TypeKey.acpMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpConfigRequest(let p): try container.encode(TypeKey.mcpConfigRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpConfigResult(let p): try container.encode(TypeKey.mcpConfigResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpMutationRequest(let p): try container.encode(TypeKey.mcpMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpMutationResult(let p): try container.encode(TypeKey.mcpMutationResult.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/README.md b/README.md index 15ee75e..ecc3f96 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Same agents, no terminal required. | **Git Status** | Sidebar Git status summary with changed-file counts, branch display, and local/remote branch switching. | | **GitHub Integration** | OAuth device flow, Keychain token storage, SSH key management, repository browsing, and cloning. | | **Memo Panel** | Per-project rich-text memo pad with headings, lists, checkboxes, links, and persistent storage. | -| **Skill Marketplace** | Browse and install official Anthropic plugins, refreshed with a 5-minute cache. | +| **Skill Marketplace** | Browse and install OpenAI Agent Skills and compatible skill catalogs from Settings, refreshed with a 5-minute cache and enabled for supported coding agents. | | **Themes and Font Controls** | Six accent themes plus independent font size controls for the interface and message area. | | **Focus Mode** | Optional focused chat layout that can be enabled from Settings. | | **Notifications** | Optional system notifications with response previews while RxCode is in the background. | diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index ad08977..8c0f973 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -13,6 +13,9 @@ 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 */; }; + DF22D8282FBE025C00E3ABFD /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF22D8272FBE025C00E3ABFD /* WidgetKit.framework */; }; + DF22D82A2FBE025C00E3ABFD /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF22D8292FBE025C00E3ABFD /* SwiftUI.framework */; }; + DF22D8372FBE025D00E3ABFD /* RxCodeWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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 */; }; @@ -50,6 +53,13 @@ remoteGlobalIDString = E67335372F7356F600FD26C7; remoteInfo = RxCode; }; + DF22D8352FBE025D00E3ABFD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E67335302F7356F600FD26C7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DF22D8252FBE025C00E3ABFD; + remoteInfo = RxCodeWidgetExtension; + }; DF230B602FBC7368008929A6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = E67335302F7356F600FD26C7 /* Project object */; @@ -81,6 +91,7 @@ dstSubfolderSpec = 13; files = ( DF230BB42FBC9001008929A6 /* RxCodeMobileNotificationService.appex in Embed App Extensions */, + DF22D8372FBE025D00E3ABFD /* RxCodeWidgetExtension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -95,6 +106,9 @@ 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A9993BB72A5307039A88B729 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTestPlan.xctestplan; sourceTree = ""; }; + DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RxCodeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DF22D8272FBE025C00E3ABFD /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + DF22D8292FBE025C00E3ABFD /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 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; }; @@ -107,6 +121,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + DF22D83B2FBE025D00E3ABFD /* Exceptions for "RxCodeWidget" folder in "RxCodeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */; + }; DF230B772FBC7368008929A6 /* Exceptions for "RxCodeMobile" folder in "RxCodeMobile" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -121,9 +142,26 @@ ); target = DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */; }; + DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + RxCodeJobActivity.swift, + RxCodeWidgetData.swift, + ); + target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */, + DF22D83B2FBE025D00E3ABFD /* Exceptions for "RxCodeWidget" folder in "RxCodeWidgetExtension" target */, + ); + path = RxCodeWidget; + sourceTree = ""; + }; DF230B502FBC7367008929A6 /* RxCodeMobile */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -176,6 +214,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF22D8232FBE025C00E3ABFD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DF22D82A2FBE025C00E3ABFD /* SwiftUI.framework in Frameworks */, + DF22D8282FBE025C00E3ABFD /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DF230B4C2FBC7367008929A6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -250,6 +297,8 @@ isa = PBXGroup; children = ( 50F6C20C7EE8F07B95128612 /* OS X */, + DF22D8272FBE025C00E3ABFD /* WidgetKit.framework */, + DF22D8292FBE025C00E3ABFD /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -274,6 +323,7 @@ DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */, DF230B622FBC7368008929A6 /* RxCodeMobileTests */, DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */, + DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */, E67335392F7356F600FD26C7 /* Products */, 609E8EE085862BD7D5B4012F /* Frameworks */, 1525FE6BFB6F06A3F00B92D3 /* RxCodeTests */, @@ -290,6 +340,7 @@ DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */, DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */, DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */, + DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -340,6 +391,28 @@ productReference = 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF22D8382FBE025D00E3ABFD /* Build configuration list for PBXNativeTarget "RxCodeWidgetExtension" */; + buildPhases = ( + DF22D8222FBE025C00E3ABFD /* Sources */, + DF22D8232FBE025C00E3ABFD /* Frameworks */, + DF22D8242FBE025C00E3ABFD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */, + ); + name = RxCodeWidgetExtension; + packageProductDependencies = ( + ); + productName = RxCodeWidgetExtension; + productReference = DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DF230B4E2FBC7367008929A6 /* RxCodeMobile */ = { isa = PBXNativeTarget; buildConfigurationList = DF230B782FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobile" */; @@ -353,6 +426,7 @@ ); dependencies = ( DF230BB72FBC9001008929A6 /* PBXTargetDependency */, + DF22D8362FBE025D00E3ABFD /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DF230B502FBC7367008929A6 /* RxCodeMobile */, @@ -477,6 +551,9 @@ LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 2630; TargetAttributes = { + DF22D8252FBE025C00E3ABFD = { + CreatedOnToolsVersion = 26.3; + }; DF230B4E2FBC7367008929A6 = { CreatedOnToolsVersion = 26.3; }; @@ -525,6 +602,7 @@ DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */, DF230B5E2FBC7368008929A6 /* RxCodeMobileTests */, DF230B682FBC7368008929A6 /* RxCodeMobileUITests */, + DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */, ); }; /* End PBXProject section */ @@ -544,6 +622,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF22D8242FBE025C00E3ABFD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DF230B4D2FBC7367008929A6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -604,6 +689,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF22D8222FBE025C00E3ABFD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DF230B4B2FBC7367008929A6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -654,6 +746,11 @@ target = E67335372F7356F600FD26C7 /* RxCode */; targetProxy = 35C1B17CDEF83F212F648418 /* PBXContainerItemProxy */; }; + DF22D8362FBE025D00E3ABFD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */; + targetProxy = DF22D8352FBE025D00E3ABFD /* PBXContainerItemProxy */; + }; DF230B612FBC7368008929A6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; @@ -728,6 +825,73 @@ }; name = Release; }; + DF22D8392FBE025D00E3ABFD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = RxCodeWidget/RxCodeWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RxCodeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + 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.RxCodeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DF22D83A2FBE025D00E3ABFD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = RxCodeWidget/RxCodeWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RxCodeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + 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.RxCodeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + 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; + }; DF230B712FBC7368008929A6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1167,6 +1331,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DF22D8382FBE025D00E3ABFD /* Build configuration list for PBXNativeTarget "RxCodeWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF22D8392FBE025D00E3ABFD /* Debug */, + DF22D83A2FBE025D00E3ABFD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DF230B782FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme index 47ad7a7..483cebc 100644 --- a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme +++ b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> + + + + + + + + = [] var marketplacePluginStates: [String: PluginInstallStatus] = [:] + var marketplaceCustomSources: [MarketplaceCustomSource] = [] + var marketplaceSourceError: String? // MARK: - Onboarding @@ -1203,6 +1209,20 @@ final class AppState { } mobileSyncObservers.append(searchObserver) + let threadChangesObserver = center.addObserver( + forName: .mobileSyncThreadChangesRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ThreadChangesRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileThreadChangesRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(threadChangesObserver) + let branchOpObserver = center.addObserver( forName: .mobileSyncBranchOpRequested, object: nil, @@ -1313,6 +1333,104 @@ final class AppState { } mobileSyncObservers.append(planDecisionObserver) + let skillCatalogObserver = center.addObserver( + forName: .mobileSyncSkillCatalogRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillCatalogRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillCatalogRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillCatalogObserver) + + let skillMutationObserver = center.addObserver( + forName: .mobileSyncSkillMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillMutationObserver) + + let skillSourceMutationObserver = center.addObserver( + forName: .mobileSyncSkillSourceMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillSourceMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillSourceMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillSourceMutationObserver) + + let acpRegistryObserver = center.addObserver( + forName: .mobileSyncACPRegistryRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ACPRegistryRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileACPRegistryRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(acpRegistryObserver) + + let acpMutationObserver = center.addObserver( + forName: .mobileSyncACPMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ACPMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileACPMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(acpMutationObserver) + + let mcpConfigObserver = center.addObserver( + forName: .mobileSyncMCPConfigRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? MCPConfigRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileMCPConfigRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(mcpConfigObserver) + + let mcpMutationObserver = center.addObserver( + forName: .mobileSyncMCPMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? MCPMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileMCPMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(mcpMutationObserver) + observeMobileSnapshotInputs() } @@ -1760,6 +1878,395 @@ final class AppState { if ok { scheduleMobileSnapshotBroadcast() } } + // MARK: - Mobile: Skills / ACP / MCP remote management + + /// Error for malformed remote skill/ACP/MCP requests; its description is + /// surfaced verbatim to the mobile client. + private enum MobileRemoteConfigError: LocalizedError { + case invalidRequest(String) + + var errorDescription: String? { + switch self { + case .invalidRequest(let detail): return detail + } + } + } + + /// The marketplace catalog flattened into wire DTOs with current install + /// state. `forceRefresh` bypasses the 5-minute marketplace cache. + private func mobileSkillPlugins(forceRefresh: Bool = false) async -> [MobileSkillPlugin] { + let catalog = await marketplace.fetchCatalog(forceRefresh: forceRefresh) + let installed = await marketplace.installedPluginNames() + return catalog.map { plugin in + MobileSkillPlugin( + id: plugin.id, + name: plugin.name, + summary: plugin.description, + author: plugin.author, + category: plugin.category, + categoryLabel: plugin.categoryLabel, + marketplace: plugin.marketplace, + marketplaceLabel: plugin.marketplaceLabel, + homepage: plugin.homepage, + isInstalled: installed.contains(plugin.name) + ) + } + } + + private func mobileSkillSources() async -> [MobileSkillSource] { + await marketplace.customSources().map { source in + MobileSkillSource(id: source.id, displayName: source.displayName) + } + } + + private func handleMobileSkillCatalogRequest(_ request: SkillCatalogRequestPayload, fromHex: String) async { + let plugins = await mobileSkillPlugins(forceRefresh: request.forceRefresh) + let sources = await mobileSkillSources() + let result = SkillCatalogResultPayload( + clientRequestID: request.clientRequestID, + ok: true, + errorMessage: nil, + plugins: plugins, + sources: sources + ) + await MobileSyncService.shared.send(.skillCatalogResult(result), toHex: fromHex) + } + + private func handleMobileSkillMutationRequest(_ request: SkillMutationRequestPayload, fromHex: String) async { + let catalog = await marketplace.fetchCatalog() + guard let plugin = catalog.first(where: { $0.id == request.pluginID }) else { + let result = SkillMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + pluginID: request.pluginID, + ok: false, + errorMessage: "Skill not found in the marketplace catalog.", + plugins: await mobileSkillPlugins(), + sources: await mobileSkillSources() + ) + await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) + return + } + + var ok = true + var errorMessage: String? + do { + switch request.operation { + case .install: + try await marketplace.installPlugin(plugin) + marketplaceInstalledNames.insert(plugin.name) + marketplacePluginStates[plugin.id] = .installed + case .uninstall: + try await marketplace.uninstallPlugin(plugin) + marketplaceInstalledNames.remove(plugin.name) + marketplacePluginStates[plugin.id] = .notInstalled + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] skill mutation failed plugin=\(plugin.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok { + let verb = request.operation == .install ? "installed" : "removed" + await NotificationService.shared.postRemoteConfigChanged( + title: "Skill \(verb) remotely", + body: plugin.name + ) + } + + let result = SkillMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + pluginID: request.pluginID, + ok: ok, + errorMessage: errorMessage, + plugins: await mobileSkillPlugins(), + sources: await mobileSkillSources() + ) + await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) + } + + private func handleMobileSkillSourceMutationRequest(_ request: SkillSourceMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var sourceID = request.sourceID + var bannerTitle: String? + var bannerBody: String? + + do { + switch request.operation { + case .add: + guard let gitURL = request.gitURL, + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw MobileRemoteConfigError.invalidRequest("Missing Git repository URL.") + } + let source = try await marketplace.addCustomGitSource(url: gitURL, ref: request.ref) + sourceID = source.id + marketplaceCustomSources = await marketplace.customSources() + bannerTitle = "Skill Git source added remotely" + bannerBody = source.displayName + case .remove: + let currentSources = await marketplace.customSources() + guard let sourceID = request.sourceID, + let source = currentSources.first(where: { $0.id == sourceID }) else { + throw MobileRemoteConfigError.invalidRequest("Skill Git source not found.") + } + try await marketplace.removeCustomGitSource(source) + marketplaceCustomSources = await marketplace.customSources() + bannerTitle = "Skill Git source removed remotely" + bannerBody = source.displayName + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] skill source mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + let plugins = await mobileSkillPlugins(forceRefresh: true) + let sources = await mobileSkillSources() + marketplaceCatalog = await marketplace.fetchCatalog() + marketplaceInstalledNames = await marketplace.installedPluginNames() + + if ok, let bannerTitle, let bannerBody { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + } + + let result = SkillSourceMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + sourceID: sourceID, + ok: ok, + errorMessage: errorMessage, + plugins: plugins, + sources: sources + ) + await MobileSyncService.shared.send(.skillSourceMutationResult(result), toHex: fromHex) + } + + private func mobileACPRegistryAgents() -> [MobileACPRegistryAgent] { + let installedRegistryIDs = Set(acpClients.compactMap(\.registryId)) + return (acpRegistry?.agents ?? []).map { agent in + MobileACPRegistryAgent( + id: agent.id, + name: agent.name, + version: agent.version, + summary: agent.description, + authors: agent.authors ?? [], + license: agent.license, + website: agent.website, + iconURL: agent.icon, + isInstalled: installedRegistryIDs.contains(agent.id), + hasBinary: agent.distribution.binary?[ACPPlatform.current] != nil, + hasNpx: agent.distribution.npx != nil, + hasUvx: agent.distribution.uvx != nil + ) + } + } + + private func mobileACPClients() -> [MobileACPClient] { + acpClients.map { spec in + MobileACPClient( + id: spec.id, + registryId: spec.registryId, + displayName: spec.displayName, + enabled: spec.enabled, + launchKind: spec.launch.displayKind, + modelCount: spec.models.count, + iconURL: spec.iconURL + ) + } + } + + private func handleMobileACPRegistryRequest(_ request: ACPRegistryRequestPayload, fromHex: String) async { + await refreshACPRegistry(forceRefresh: request.forceRefresh) + let ok = acpRegistry != nil + let result = ACPRegistryResultPayload( + clientRequestID: request.clientRequestID, + ok: ok, + errorMessage: ok ? nil : "Could not load the ACP agent registry.", + registryAgents: mobileACPRegistryAgents(), + installedClients: mobileACPClients() + ) + await MobileSyncService.shared.send(.acpRegistryResult(result), toHex: fromHex) + } + + private func handleMobileACPMutationRequest(_ request: ACPMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var bannerTitle: String? + var bannerBody: String? + do { + switch request.operation { + case .install: + guard let agentID = request.registryAgentID else { + throw MobileRemoteConfigError.invalidRequest("Missing registry agent id.") + } + if acpRegistry == nil { await refreshACPRegistry() } + guard let agent = acpRegistry?.agents.first(where: { $0.id == agentID }) else { + throw MobileRemoteConfigError.invalidRequest("Agent not found in the registry.") + } + let spec = try await installACPClient(from: agent) + addACPClient(spec) + bannerTitle = "ACP agent installed remotely" + bannerBody = agent.name + case .uninstall: + guard let clientID = request.clientID, + let client = acpClients.first(where: { $0.id == clientID }) + else { + throw MobileRemoteConfigError.invalidRequest("Installed client not found.") + } + removeACPClient(id: clientID) + bannerTitle = "ACP agent removed remotely" + bannerBody = client.displayName + case .setEnabled: + guard let clientID = request.clientID, + let enabled = request.enabled, + var client = acpClients.first(where: { $0.id == clientID }) + else { + throw MobileRemoteConfigError.invalidRequest("Installed client not found.") + } + client.enabled = enabled + updateACPClient(client) + bannerTitle = "ACP agent \(enabled ? "enabled" : "disabled") remotely" + bannerBody = client.displayName + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] acp mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok, let bannerTitle, let bannerBody { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + } + + let result = ACPMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + ok: ok, + errorMessage: errorMessage, + registryAgents: mobileACPRegistryAgents(), + installedClients: mobileACPClients() + ) + await MobileSyncService.shared.send(.acpMutationResult(result), toHex: fromHex) + } + + private func mobileMCPServer(_ record: MCPServerRecord) -> MobileMCPServer { + let env = record.env + .sorted { $0.key < $1.key } + .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } + let headers = record.headers + .sorted { $0.key < $1.key } + .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } + let endpoint: String + if record.transport == .stdio { + endpoint = ([record.command ?? ""] + record.args) + .filter { !$0.isEmpty } + .joined(separator: " ") + } else { + endpoint = record.url ?? "" + } + return MobileMCPServer( + name: record.name, + transport: record.transport.rawValue, + url: record.url, + command: record.command, + args: record.args, + env: env, + headers: headers, + isGloballyEnabled: record.isGloballyEnabled, + endpoint: endpoint + ) + } + + private func mobileMCPServers() async throws -> [MobileMCPServer] { + try await mcp.globalRecords().map { mobileMCPServer($0) } + } + + private func mcpServerSpec(from server: MobileMCPServer) -> MCPServerSpec { + MCPServerSpec( + name: server.name, + transport: MCPTransport(rawValue: server.transport) ?? .stdio, + url: server.url ?? "", + headers: server.headers.map { MCPKeyValue(key: $0.key, value: $0.value) }, + command: server.command ?? "", + args: server.args, + env: server.env.map { MCPKeyValue(key: $0.key, value: $0.value) } + ) + } + + private func handleMobileMCPConfigRequest(_ request: MCPConfigRequestPayload, fromHex: String) async { + do { + let servers = try await mobileMCPServers() + let result = MCPConfigResultPayload( + clientRequestID: request.clientRequestID, + ok: true, + errorMessage: nil, + servers: servers + ) + await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) + } catch { + let result = MCPConfigResultPayload( + clientRequestID: request.clientRequestID, + ok: false, + errorMessage: error.localizedDescription, + servers: [] + ) + await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) + } + } + + private func handleMobileMCPMutationRequest(_ request: MCPMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var bannerTitle: String? + do { + switch request.operation { + case .add: + guard let server = request.server else { + throw MobileRemoteConfigError.invalidRequest("Missing server definition.") + } + try await mcp.add(spec: mcpServerSpec(from: server), scope: .user, projectPath: nil) + bannerTitle = "MCP server saved remotely" + case .remove: + try await mcp.remove(name: request.serverName, scope: .user) + bannerTitle = "MCP server removed remotely" + case .setEnabled: + guard let enabled = request.enabled else { + throw MobileRemoteConfigError.invalidRequest("Missing enabled flag.") + } + try await mcp.setGlobalEnabled(name: request.serverName, enabled: enabled) + bannerTitle = "MCP server \(enabled ? "enabled" : "disabled") remotely" + } + await refreshMCPServers() + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] mcp mutation failed server=\(request.serverName, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok, let bannerTitle { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: request.serverName) + } + + var servers: [MobileMCPServer] = [] + do { + servers = try await mobileMCPServers() + } catch { + logger.error("[MobileSync] failed reading mcp servers for reply: \(error.localizedDescription, privacy: .public)") + } + let result = MCPMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + serverName: request.serverName, + ok: ok, + errorMessage: errorMessage, + servers: servers + ) + await MobileSyncService.shared.send(.mcpMutationResult(result), toHex: fromHex) + } + private func mobileFolderTreeRoot(for request: FolderTreeRequestPayload) throws -> RemoteFolderNode { let depth = max(0, min(request.depth, 2)) guard let path = request.path?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -2764,6 +3271,74 @@ final class AppState { ) } + /// Builds the change overview for the mobile "View Changes" sheet: every + /// file edited in the thread session plus the project's uncommitted git + /// changes. Replies with a `threadChangesResult`. + private func handleMobileThreadChangesRequest( + _ request: ThreadChangesRequestPayload, + fromHex hex: String + ) async { + let resolvedID = resolveCurrentSessionId(request.sessionID) + + // This Turn: every file edited in the thread session (SwiftData history). + let turnEdits = threadStore.fetchFileEdits(sessionId: resolvedID).map { edit -> SyncFileEdit in + let summary = edit.toSummary() + return SyncFileEdit( + path: summary.path, + name: summary.name, + containsWrite: summary.containsWrite, + hunks: summary.hunks.map { + SyncEditHunk(oldString: $0.oldString, newString: $0.newString) + } + ) + } + + func reply(ok: Bool, error: String?, uncommitted: [SyncGitChange]) async { + await MobileSyncService.shared.send( + .threadChangesResult(ThreadChangesResultPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + ok: ok, + errorMessage: error, + turnEdits: turnEdits, + uncommitted: uncommitted + )), + toHex: hex + ) + } + + // Uncommitted: the session's project working tree. + let projectPath = allSessionSummaries + .first(where: { $0.id == resolvedID }) + .flatMap { summary in projects.first(where: { $0.id == summary.projectId })?.path } + + guard let projectPath, !projectPath.isEmpty else { + await reply(ok: false, error: "This thread has no associated project folder.", uncommitted: []) + return + } + + guard let gitChanges = await GitHelper.uncommittedChanges(at: projectPath) else { + await reply(ok: false, error: "This project is not a git repository.", uncommitted: []) + return + } + + let uncommitted = gitChanges.map { change -> SyncGitChange in + let kind: SyncGitChangeKind = switch change.kind { + case .staged: .staged + case .unstaged: .unstaged + case .untracked: .untracked + } + return SyncGitChange( + displayPath: change.displayPath, + statusChar: change.statusChar, + kind: kind, + unifiedDiff: change.unifiedDiff, + truncated: change.truncated + ) + } + await reply(ok: true, error: nil, uncommitted: uncommitted) + } + /// User-triggered full reindex of every thread. Wipes cached embeddings, /// then re-embeds every thread. Updates `reindexProgress` so the UI can /// render a counter. @@ -3261,6 +3836,16 @@ final class AppState { ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment + // Supply MobileSyncService with desktop-side context for the mobile + // job Live Activity and home-screen widget pushes. + MobileSyncService.shared.projectNameResolver = { [weak self] id in + self?.projects.first { $0.id == id }?.name + } + MobileSyncService.shared.usageSnapshotProvider = { [weak self] in + (self?.latestRateLimitUsage?.fiveHourPercent, + self?.latestCodexRateLimitUsage?.fiveHourPercent) + } + await refreshAgentInstallations() projects = await persistence.loadProjects() @@ -3277,6 +3862,9 @@ final class AppState { _ = await github.loadToken() } + customRepos = await persistence.loadCustomRepos() + marketplaceCustomSources = await marketplace.customSources() + // Sidebar threads are now sourced from the local SwiftData store. // CLI session files are no longer surfaced in the sidebar list — the // CLI is still the transcript backend (replay on thread open), but @@ -4245,6 +4833,24 @@ final class AppState { } } + /// Wrap a branch briefing into a system-prompt section the agent can use as + /// background context. The briefing is auto-generated from earlier threads, + /// so it is framed as advisory rather than authoritative. + private static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String { + let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return """ + # Current branch briefing + + The notes below are an accumulated briefing of recent work on this \ + project's current branch (`\(branch)`). They are auto-generated from \ + previous chat threads — treat them as background context for the user's \ + request, and be aware they may be incomplete or slightly out of date. + + \(trimmed) + """ + } + private func processStream( streamId: UUID, prompt: String, @@ -4272,13 +4878,25 @@ final class AppState { // Resolve per-backend send-request fields (MCP injection, ACP client // spec, model split) before dispatching through the unified protocol. var mcpClaudeConfigPath: String? = nil + var extraSystemPrompt: String? = nil var mcpCodexOverrides: [String] = [] var acpMCPServers: [JSONValue] = [] var acpSpec: ACPClientSpec? = nil + var resolvedPrompt = prompt var resolvedModel: String? = model var resolvedSendMode: PermissionMode = permissionMode var earlyStream: AsyncStream? = nil + func appendExtraSystemPrompt(_ context: String) { + let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if let existing = extraSystemPrompt, !existing.isEmpty { + extraSystemPrompt = "\(existing)\n\n\(trimmed)" + } else { + extraSystemPrompt = trimmed + } + } + switch agentProvider { case .claudeCode: // Allocate a per-session IDE-MCP port so the Claude agent can call @@ -4291,8 +4909,24 @@ final class AppState { ) let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge) + // Surface the accumulated briefing for the project's current branch + // to the agent as background context via `--append-system-prompt`. + if let branch = await GitHelper.currentBranch(at: cwd), + let briefing = threadStore.branchBriefingItem(projectId: projectId, branch: branch) { + extraSystemPrompt = Self.branchBriefingSystemPrompt( + branch: branch, + briefing: briefing.briefing + ) + } + if let skillContext = await marketplace.promptContext(for: .claudeCode) { + appendExtraSystemPrompt(skillContext) + } case .codex: mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd) + mcpCodexOverrides += await marketplace.codexConfigOverrides() + if let skillContext = await marketplace.promptContext(for: .codex) { + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(prompt)" + } resolvedSendMode = registerMode case .acp: // Allocate a per-session IDE-MCP port so the ACP agent can call @@ -4308,6 +4942,9 @@ final class AppState { projectPath: cwd, bridgeCommand: bridge ) + if let skillContext = await marketplace.promptContext(for: .acp) { + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(prompt)" + } // `model` may be a composite `::` key (from the picker) // or a bare model id (from a per-session override). let split = acpSelectionParts(for: model) @@ -4342,7 +4979,7 @@ final class AppState { } else { let request = BackendSendRequest( streamId: streamId, - prompt: prompt, + prompt: resolvedPrompt, cwd: cwd, sessionId: cliSessionId, model: resolvedModel, @@ -4351,6 +4988,7 @@ final class AppState { planMode: permissionMode == .plan, hookSettingsPath: hookSettingsPath, mcpClaudeConfigPath: mcpClaudeConfigPath, + extraSystemPrompt: extraSystemPrompt, mcpCodexOverrides: mcpCodexOverrides, acpMCPServers: acpMCPServers, acpSpec: acpSpec, @@ -5796,6 +6434,50 @@ final class AppState { await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) } + func loadCustomRepos() async { + customRepos = await persistence.loadCustomRepos() + } + + func addCustomRepo(url: String, name: String, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + if fm.fileExists(atPath: clonePath) { + throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(name)' already exists in ~/RxCode"]) + } + try await github.cloneRepo(from: url, to: clonePath) + let repo = CustomRepo(name: name, cloneURL: url) + customRepos.append(repo) + try await persistence.saveCustomRepos(customRepos) + await addAndSelectProject(name: name, path: clonePath, gitHubRepo: nil, in: window) + } + + func cloneCustomRepo(_ repo: CustomRepo, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(repo.name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + if fm.fileExists(atPath: clonePath) { + throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(repo.name)' already exists in ~/RxCode"]) + } + try await github.cloneRepo(from: repo.cloneURL, to: clonePath) + await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: nil, in: window) + } + + func removeCustomRepo(_ repo: CustomRepo) async { + customRepos.removeAll { $0.id == repo.id } + do { + try await persistence.saveCustomRepos(customRepos) + } catch { + logger.error("Failed to save custom repos: \(error.localizedDescription)") + } + } + // MARK: - View Convenience API func startNewChat(in window: WindowState) { @@ -6809,13 +7491,19 @@ final class AppState { func loadMarketplace(forceRefresh: Bool = false) async { marketplaceLoading = true + marketplaceSourceError = nil defer { marketplaceLoading = false } async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh) async let installed = marketplace.installedPluginNames() - marketplaceCatalog = await catalog - marketplaceInstalledNames = await installed + let fetchedCatalog = await catalog + let installedNames = await installed + await marketplace.importInstalledPlugins(catalog: fetchedCatalog, installedNames: installedNames) + + marketplaceCatalog = fetchedCatalog + marketplaceInstalledNames = installedNames + marketplaceCustomSources = await marketplace.customSources() } func installMarketplacePlugin(_ plugin: MarketplacePlugin) async { @@ -6840,6 +7528,36 @@ final class AppState { } } + @discardableResult + func addMarketplaceGitSource(url: String, ref: String?) async -> Bool { + marketplaceSourceError = nil + do { + _ = try await marketplace.addCustomGitSource(url: url, ref: ref) + marketplaceCustomSources = await marketplace.customSources() + await loadMarketplace(forceRefresh: true) + return true + } catch { + marketplaceSourceError = error.localizedDescription + logger.error("Failed to add marketplace Git source: \(error.localizedDescription)") + return false + } + } + + @discardableResult + func removeMarketplaceGitSource(_ source: MarketplaceCustomSource) async -> Bool { + marketplaceSourceError = nil + do { + try await marketplace.removeCustomGitSource(source) + marketplaceCustomSources = await marketplace.customSources() + await loadMarketplace(forceRefresh: true) + return true + } catch { + marketplaceSourceError = error.localizedDescription + logger.error("Failed to remove marketplace Git source: \(error.localizedDescription)") + return false + } + } + // MARK: - Attachment Management func addAttachment(_ attachment: Attachment, in window: WindowState) { diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 017dff2..d7bfd3c 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -1,6 +1,7 @@ -import SwiftUI -import RxCodeCore import RxCodeChatKit +import RxCodeCore +import SwiftUI +import TipKit // MARK: - FocusedValues @@ -37,6 +38,13 @@ struct RxCodeApp: App { @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true private let updateService = UpdateService.shared + init() { + try? Tips.configure([ + .displayFrequency(.immediate), + .datastoreLocation(.applicationDefault), + ]) + } + var body: some Scene { WindowGroup { MainWindowRoot(appState: appState) @@ -438,7 +446,7 @@ private struct MenuBarUsageBar: View { switch percent { case ..<60: return ClaudeTheme.accent case ..<85: return .orange - default: return .red + default: return .red } } @@ -545,7 +553,7 @@ struct ProjectWindowRoot: View { // running per-window setup. State-restoration can spawn this window // before the main window has finished booting. while !appState.isInitialized { - try? await Task.sleep(nanoseconds: 50_000_000) + try? await Task.sleep(nanoseconds: 50000000) } windowState.isProjectWindow = true appState.setupChatBridge(chatBridge, for: windowState) diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index 8fa5edb..39caaba 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -473,6 +473,7 @@ actor ClaudeCodeServer { effort: String? = nil, hookSettingsPath: String? = nil, mcpConfigPath: String? = nil, + extraSystemPrompt: String? = nil, permissionMode: PermissionMode = .default ) -> AsyncStream { let stdin = Pipe() @@ -502,6 +503,7 @@ actor ClaudeCodeServer { effort: effort, hookSettingsPath: hookSettingsPath, mcpConfigPath: mcpConfigPath, + extraSystemPrompt: extraSystemPrompt, permissionMode: permissionMode, stdinPipe: stdin, stdoutPipe: stdout, @@ -800,6 +802,7 @@ actor ClaudeCodeServer { effort: String?, hookSettingsPath: String?, mcpConfigPath: String?, + extraSystemPrompt: String?, permissionMode: PermissionMode ) -> [String] { var args: [String] = [ @@ -834,9 +837,21 @@ actor ClaudeCodeServer { if let mcpConfigPath { args += ["--strict-mcp-config", "--mcp-config", mcpConfigPath] - // The `rxcode-ide` MCP server is part of this config — tell the - // agent the IDE multi-agent / introspection tools exist. - args += ["--append-system-prompt", Self.ideToolsSystemPrompt] + } + + // Assemble the system-prompt additions for this turn into a single + // `--append-system-prompt` value (the CLI honours one occurrence): + // - IDE tools blurb, when the `rxcode-ide` MCP server is wired in. + // - Caller-supplied context, e.g. the current branch briefing. + var systemPromptSections: [String] = [] + if mcpConfigPath != nil { + systemPromptSections.append(Self.ideToolsSystemPrompt) + } + if let extraSystemPrompt, !extraSystemPrompt.isEmpty { + systemPromptSections.append(extraSystemPrompt) + } + if !systemPromptSections.isEmpty { + args += ["--append-system-prompt", systemPromptSections.joined(separator: "\n\n")] } if let sessionId { @@ -871,6 +886,7 @@ actor ClaudeCodeServer { effort: String? = nil, hookSettingsPath: String?, mcpConfigPath: String?, + extraSystemPrompt: String? = nil, permissionMode: PermissionMode = .default, stdinPipe: Pipe, stdoutPipe: Pipe, @@ -887,6 +903,7 @@ actor ClaudeCodeServer { effort: effort, hookSettingsPath: hookSettingsPath, mcpConfigPath: mcpConfigPath, + extraSystemPrompt: extraSystemPrompt, permissionMode: permissionMode ) let environment = await resolvedEnvironment() @@ -1219,6 +1236,7 @@ extension ClaudeCodeServer: AgentBackend { effort: request.effort, hookSettingsPath: request.hookSettingsPath, mcpConfigPath: request.mcpClaudeConfigPath, + extraSystemPrompt: request.extraSystemPrompt, permissionMode: request.permissionMode ) } diff --git a/RxCode/Services/GitHubService.swift b/RxCode/Services/GitHubService.swift index db1d574..8dc83af 100644 --- a/RxCode/Services/GitHubService.swift +++ b/RxCode/Services/GitHubService.swift @@ -282,6 +282,32 @@ actor GitHubService { logger.info("Cloned \(repo.fullName, privacy: .public) to \(path, privacy: .public)") } + func cloneRepo(from url: String, to path: String) async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["clone", url, path] + process.environment = ProcessInfo.processInfo.environment + + let stderrPipe = Pipe() + process.standardError = stderrPipe + + try process.run() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in + continuation.resume() + } + } + + guard process.terminationStatus == 0 else { + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "unknown error" + throw GitHubError.cloneFailed(stderr) + } + + logger.info("Cloned repo from \(url, privacy: .public) to \(path, privacy: .public)") + } + // MARK: - Private Helpers private func apiRequest( diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift index 9197101..cfe98f4 100644 --- a/RxCode/Services/MCPService.swift +++ b/RxCode/Services/MCPService.swift @@ -43,6 +43,14 @@ actor MCPService { .map { makeInfo(record: $0, projectPath: projectPath) } } + /// Full server records sorted by name. Unlike `list`, this exposes the + /// command/args/env/headers needed to render an editable form (e.g. on the + /// mobile MCP screen). + func globalRecords() async throws -> [MCPServerRecord] { + try loadConfig().servers + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + func get(name: String, projectPath: String?) async throws -> MCPServerDetail { guard let record = try loadConfig().servers.first(where: { $0.name == name }) else { throw MCPError.parseFailure("MCP server '\(name)' not found") diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index d3810dc..0bb1683 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -2,16 +2,30 @@ import Foundation import RxCodeCore import os -/// Fetches the marketplace catalog from Anthropic's GitHub repositories -/// and handles plugin installation/uninstallation via Claude Code CLI. +/// Fetches skill/plugin catalogs and keeps RxCode-owned install state. +/// Provider-specific config is materialized at launch time where possible. actor MarketplaceService { private let logger = Logger(subsystem: "com.claudework", category: "MarketplaceService") + enum CustomSourceError: LocalizedError { + case invalidGitHubURL + case duplicateSource + + var errorDescription: String? { + switch self { + case .invalidGitHubURL: + return "Enter a GitHub repository URL such as https://github.com/owner/repo." + case .duplicateSource: + return "This Git source is already included." + } + } + } /// Cached catalog with TTL. private var cachedCatalog: [MarketplacePlugin] = [] private var cacheDate: Date? private let cacheTTL: TimeInterval = 300 // 5 minutes + private let configURL = AppSupport.bundleScopedURL.appendingPathComponent("skills.json") /// Source repositories to scan. private static let sourceRepos: [(owner: String, repo: String, defaultCategory: String)] = [ @@ -20,6 +34,7 @@ actor MarketplaceService { ("anthropics", "knowledge-work-plugins", "knowledge-work"), ("anthropics", "financial-services-plugins", "financial-services"), ] + private static let openAISkillsSource = MarketplaceSource(owner: "openai", repo: "skills") // MARK: - Fetch Catalog @@ -34,11 +49,20 @@ actor MarketplaceService { var allPlugins: [MarketplacePlugin] = [] await withTaskGroup(of: [MarketplacePlugin].self) { group in - for source in Self.sourceRepos { + group.addTask { + await self.fetchOpenAISkills() + } + let customSources = loadCustomSources() + let repoSources = Self.sourceRepos.map { + (source: MarketplaceSource(owner: $0.owner, repo: $0.repo), defaultCategory: $0.defaultCategory) + } + customSources.map { + (source: $0.source, defaultCategory: $0.defaultCategory) + } + + for source in repoSources { group.addTask { await self.fetchRepoPlugins( - owner: source.owner, - repo: source.repo, + source: source.source, defaultCategory: source.defaultCategory ) } @@ -57,10 +81,64 @@ actor MarketplaceService { return allPlugins } + private func fetchOpenAISkills() async -> [MarketplacePlugin] { + let apiURL = "https://api.github.com/repos/openai/skills/contents/skills/.curated?ref=main" + guard let url = URL(string: apiURL) else { return [] } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let entries = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } + + let names = entries.compactMap { entry -> String? in + guard (entry["type"] as? String) == "dir" else { return nil } + return entry["name"] as? String + } + + var skills: [MarketplacePlugin] = [] + await withTaskGroup(of: MarketplacePlugin?.self) { group in + for name in names { + group.addTask { await self.fetchOpenAISkill(named: name) } + } + for await skill in group { + if let skill { skills.append(skill) } + } + } + return skills + } catch { + logger.warning("Failed to fetch OpenAI skills catalog: \(error.localizedDescription)") + return [] + } + } + + private func fetchOpenAISkill(named name: String) async -> MarketplacePlugin? { + let path = "skills/.curated/\(name)" + guard let instructions = await fetchRawSkillInstructions(source: Self.openAISkillsSource, path: path) else { + return nil + } + + let metadata = parseSkillMetadata(instructions) + return MarketplacePlugin( + name: metadata.name ?? name, + description: metadata.description ?? "", + author: "OpenAI", + category: "codex-curated", + homepage: "https://github.com/openai/skills/tree/main/\(path)", + marketplace: "openai-skills-curated", + marketplaceSource: Self.openAISkillsSource, + sourceType: .agentSkill, + skillPaths: [path] + ) + } + // MARK: - Fetch Repository - private func fetchRepoPlugins(owner: String, repo: String, defaultCategory: String) async -> [MarketplacePlugin] { - let catalogURL = "https://raw.githubusercontent.com/\(owner)/\(repo)/main/.claude-plugin/marketplace.json" + private func fetchRepoPlugins(source: MarketplaceSource, defaultCategory: String) async -> [MarketplacePlugin] { + let ref = source.ref?.isEmpty == false ? source.ref! : "main" + let catalogURL = "https://raw.githubusercontent.com/\(source.owner)/\(source.repo)/\(ref)/.claude-plugin/marketplace.json" guard let url = URL(string: catalogURL) else { return [] } do { @@ -69,16 +147,16 @@ actor MarketplaceService { httpResponse.statusCode == 200 else { return [] } - return parseMarketplaceCatalog(data: data, owner: owner, repo: repo, defaultCategory: defaultCategory) + return parseMarketplaceCatalog(data: data, source: source, defaultCategory: defaultCategory) } catch { - logger.warning("Failed to fetch catalog from \(owner)/\(repo): \(error.localizedDescription)") + logger.warning("Failed to fetch catalog from \(source.codexSource, privacy: .public): \(error.localizedDescription)") return [] } } // MARK: - Parse Catalog - private func parseMarketplaceCatalog(data: Data, owner: String, repo: String, defaultCategory: String) -> [MarketplacePlugin] { + private func parseMarketplaceCatalog(data: Data, source: MarketplaceSource, defaultCategory: String) -> [MarketplacePlugin] { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let marketplaceName = json["name"] as? String, let plugins = json["plugins"] as? [[String: Any]] else { @@ -86,7 +164,9 @@ actor MarketplaceService { } let ownerInfo = json["owner"] as? [String: Any] - let defaultAuthor = ownerInfo?["name"] as? String ?? owner + let defaultAuthor = ownerInfo?["name"] as? String ?? source.owner + + let marketplaceSource = source return plugins.compactMap { entry -> MarketplacePlugin? in guard let name = entry["name"] as? String else { return nil } @@ -131,26 +211,122 @@ actor MarketplaceService { category: category, homepage: homepage, marketplace: marketplaceName, + marketplaceSource: marketplaceSource, sourceType: sourceType, skillPaths: skillPaths ) } } - // MARK: - Installation (via Claude Code CLI) + // MARK: - Custom Git Sources + + func customSources() -> [MarketplaceCustomSource] { + loadCustomSources() + } + + func addCustomGitSource(url rawURL: String, ref rawRef: String? = nil) throws -> MarketplaceCustomSource { + let source = try parseGitHubSource(url: rawURL, ref: rawRef) + let normalizedSource = MarketplaceCustomSource(source: source) + var config = try loadConfig() + + let builtInSources = Self.sourceRepos.map { MarketplaceSource(owner: $0.owner, repo: $0.repo) } + if builtInSources.contains(where: { sameSource($0, source) }) || + config.customSources.contains(where: { sameSource($0.source, source) }) { + throw CustomSourceError.duplicateSource + } + + config.customSources.append(normalizedSource) + try saveConfig(config) + cachedCatalog = [] + cacheDate = nil + return normalizedSource + } + + func removeCustomGitSource(_ source: MarketplaceCustomSource) throws { + var config = try loadConfig() + config.customSources.removeAll { $0.id == source.id } + try saveConfig(config) + cachedCatalog = [] + cacheDate = nil + } + + private func loadCustomSources() -> [MarketplaceCustomSource] { + (try? loadConfig().customSources) ?? [] + } - /// Retrieve the list of installed plugin names. + private func parseGitHubSource(url rawURL: String, ref rawRef: String?) throws -> MarketplaceSource { + let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + let ref = rawRef?.trimmingCharacters(in: .whitespacesAndNewlines) + let explicitRef = ref?.isEmpty == false ? ref : nil + + if let match = regexMatch(trimmed, #"^git@github\.com:([^/\s]+)/([^/\s]+?)(?:\.git)?/?$"#) { + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef) + } + + if let match = regexMatch(trimmed, #"^https?://github\.com/([^/\s]+)/([^/\s]+?)(?:\.git)?/?$"#) { + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef) + } + + if let match = regexMatch(trimmed, #"^https?://github\.com/([^/\s]+)/([^/\s]+)/tree/([^/\s]+)(?:/.*)?$"#) { + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef ?? match[2]) + } + + if let match = regexMatch(trimmed, #"^([^/\s]+)/([^/\s@]+)(?:@([^/\s]+))?$"#) { + let parsedRef = match.count > 2 && !match[2].isEmpty ? match[2] : nil + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef ?? parsedRef) + } + + throw CustomSourceError.invalidGitHubURL + } + + private func regexMatch(_ value: String, _ pattern: String) -> [String]? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(value.startIndex.. Bool { + lhs.owner.lowercased() == rhs.owner.lowercased() && + lhs.repo.lowercased() == rhs.repo.lowercased() && + normalizedRef(lhs.ref) == normalizedRef(rhs.ref) + } + + private func normalizedRef(_ ref: String?) -> String { + let trimmed = ref?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed! : "main" + } + + // MARK: - Installation + + /// Retrieve installed plugin names from RxCode state, plus legacy Claude installs. func installedPluginNames() async -> Set { + var names = Set((try? loadConfig().plugins.map(\.name)) ?? []) + names.formUnion(await installedClaudePluginNames()) + return names + } + + private func installedClaudePluginNames() async -> Set { let (output, exitCode) = await runCLI(["plugin", "list", "--json"]) guard exitCode == 0, let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - return installedPluginNamesFromDisk() + return installedClaudePluginNamesFromDisk() } return Set(json.compactMap { $0["name"] as? String }) } - private func installedPluginNamesFromDisk() -> Set { + private func installedClaudePluginNamesFromDisk() -> Set { let home = FileManager.default.homeDirectoryForCurrentUser.path let fm = FileManager.default var names: Set = [] @@ -162,23 +338,253 @@ actor MarketplaceService { return names } - /// Install a plugin by running `claude plugin install @` + func importInstalledPlugins(catalog: [MarketplacePlugin], installedNames: Set) async { + do { + var config = try loadConfig() + var changed = false + for plugin in catalog where installedNames.contains(plugin.name) { + if let index = config.plugins.firstIndex(where: { $0.id == plugin.id }) { + var shouldFetchInstructions = config.plugins[index].instructions == nil + if config.plugins[index].summary != plugin.description { + config.plugins[index].summary = plugin.description + changed = true + } + if config.plugins[index].category != plugin.category { + config.plugins[index].category = plugin.category + changed = true + } + if config.plugins[index].marketplaceSource != plugin.marketplaceSource { + config.plugins[index].marketplaceSource = plugin.marketplaceSource + changed = true + } + if config.plugins[index].sourceType != plugin.sourceType { + config.plugins[index].sourceType = plugin.sourceType + changed = true + } + if config.plugins[index].skillPaths != plugin.skillPaths { + config.plugins[index].skillPaths = plugin.skillPaths + shouldFetchInstructions = true + changed = true + } + if shouldFetchInstructions, + let instructions = await skillInstructions(for: plugin) { + config.plugins[index].instructions = instructions + changed = true + } + } else { + config.plugins.append(MarketplacePluginRecord( + name: plugin.name, + marketplace: plugin.marketplace, + summary: plugin.description, + category: plugin.category, + marketplaceSource: plugin.marketplaceSource, + sourceType: plugin.sourceType, + skillPaths: plugin.skillPaths, + instructions: await skillInstructions(for: plugin) + )) + changed = true + } + } + + if changed { + try saveConfig(config) + } + } catch { + logger.warning("Failed to import installed marketplace plugins: \(error.localizedDescription)") + } + } + + /// Install into RxCode-owned state and mirror to Claude Code when available. func installPlugin(_ plugin: MarketplacePlugin) async throws { + var config = try loadConfig() + let instructions = await skillInstructions(for: plugin) + let record = MarketplacePluginRecord( + name: plugin.name, + marketplace: plugin.marketplace, + summary: plugin.description, + category: plugin.category, + marketplaceSource: plugin.marketplaceSource, + sourceType: plugin.sourceType, + skillPaths: plugin.skillPaths, + instructions: instructions + ) + + if let index = config.plugins.firstIndex(where: { $0.id == record.id }) { + config.plugins[index].isGloballyEnabled = true + config.plugins[index].enabledProviders = Set(AgentProvider.allCases) + config.plugins[index].marketplaceSource = plugin.marketplaceSource + config.plugins[index].summary = plugin.description + config.plugins[index].category = plugin.category + config.plugins[index].sourceType = plugin.sourceType + config.plugins[index].skillPaths = plugin.skillPaths + config.plugins[index].instructions = instructions + } else { + config.plugins.append(record) + } + try saveConfig(config) + let installArg = "\(plugin.name)@\(plugin.marketplace)" let (_, exitCode) = await runCLI(["plugin", "install", installArg]) - guard exitCode == 0 else { - throw MarketplaceError.installFailed(installArg) + if exitCode != 0 { + logger.warning("Claude plugin mirror install failed for \(installArg, privacy: .public)") } - logger.info("Installed plugin: \(plugin.name, privacy: .public) from \(plugin.marketplace, privacy: .public)") + logger.info("Installed skill: \(plugin.name, privacy: .public) from \(plugin.marketplace, privacy: .public)") } - /// Uninstall a plugin by running `claude plugin uninstall ` + /// Remove from RxCode-owned state and mirror to Claude Code when available. func uninstallPlugin(_ plugin: MarketplacePlugin) async throws { + var config = try loadConfig() + config.plugins.removeAll { $0.id == plugin.id || $0.name == plugin.name } + try saveConfig(config) + let (_, exitCode) = await runCLI(["plugin", "uninstall", plugin.name]) - guard exitCode == 0 else { - throw MarketplaceError.uninstallFailed(plugin.name) + if exitCode != 0 { + logger.warning("Claude plugin mirror uninstall failed for \(plugin.name, privacy: .public)") + } + logger.info("Uninstalled skill: \(plugin.name, privacy: .public)") + } + + func codexConfigOverrides() async -> [String] { + do { + let config = try loadConfig() + let records = config.plugins + .filter { $0.isEnabled(for: .codex) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + var pairs: [String] = [] + var emittedMarketplaces: Set = [] + + for record in records { + if let source = record.marketplaceSource, + !emittedMarketplaces.contains(record.marketplace) { + emittedMarketplaces.insert(record.marketplace) + let marketplaceKey = "marketplaces.\(tomlKey(record.marketplace))" + // Codex's `source_type` enum only accepts `git` or `local`. + pairs += ["-c", "\(marketplaceKey).source_type=\(tomlString("git"))"] + pairs += ["-c", "\(marketplaceKey).source=\(tomlString(source.codexSource))"] + } + + let pluginId = "\(record.name)@\(record.marketplace)" + pairs += ["-c", "plugins.\(tomlKey(pluginId)).enabled=true"] + } + if !pairs.isEmpty { + pairs = ["--enable", "plugins"] + pairs + } + return pairs + } catch { + logger.warning("Failed to build Codex skill overrides: \(error.localizedDescription)") + return [] + } + } + + func promptContext(for provider: AgentProvider) async -> String? { + do { + let records = try loadConfig().plugins + .filter { $0.isEnabled(for: provider) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + guard !records.isEmpty else { return nil } + + var lines = ["# Installed RxCode skills\nUse the following installed skills when they match the user's request."] + var remainingBudget = 18_000 + for record in records { + let detail = (record.instructions ?? record.summary)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let clipped = String(detail.prefix(max(0, min(remainingBudget, 6_000)))) + if detail.isEmpty { + lines.append("## \(record.name)\nSource: \(record.marketplace)") + } else { + lines.append("## \(record.name)\nSource: \(record.marketplace)\n\n\(clipped)") + remainingBudget -= clipped.count + if remainingBudget <= 0 { break } + } + } + return lines.joined(separator: "\n\n") + } catch { + logger.warning("Failed to build skill prompt context: \(error.localizedDescription)") + return nil + } + } + + private func skillInstructions(for plugin: MarketplacePlugin) async -> String? { + guard let source = plugin.marketplaceSource else { return nil } + for path in plugin.skillPaths { + if let instructions = await fetchRawSkillInstructions(source: source, path: path) { + return instructions + } } - logger.info("Uninstalled plugin: \(plugin.name, privacy: .public)") + return nil + } + + private func fetchRawSkillInstructions(source: MarketplaceSource, path: String) async -> String? { + let cleanPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let ref = source.ref?.isEmpty == false ? source.ref! : "main" + let rawURL = "https://raw.githubusercontent.com/\(source.owner)/\(source.repo)/\(ref)/\(cleanPath)/SKILL.md" + guard let url = URL(string: rawURL) else { return nil } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + return String(data: data, encoding: .utf8) + } catch { + logger.warning("Failed to fetch skill instructions at \(rawURL, privacy: .public): \(error.localizedDescription)") + return nil + } + } + + private func parseSkillMetadata(_ text: String) -> (name: String?, description: String?) { + guard text.hasPrefix("---"), + let end = text.dropFirst(3).range(of: "---") else { + return (nil, nil) + } + + let frontMatter = text[text.index(text.startIndex, offsetBy: 3).. String { + var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count >= 2, + let first = trimmed.first, + let last = trimmed.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") { + trimmed.removeFirst() + trimmed.removeLast() + } + return trimmed + } + + // MARK: - RxCode Config + + private func loadConfig() throws -> MarketplacePluginConfiguration { + let fm = FileManager.default + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + guard fm.fileExists(atPath: configURL.path) else { + return MarketplacePluginConfiguration() + } + let data = try Data(contentsOf: configURL) + return try JSONDecoder().decode(MarketplacePluginConfiguration.self, from: data) + } + + private func saveConfig(_ config: MarketplacePluginConfiguration) throws { + let fm = FileManager.default + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: configURL, options: [.atomic]) } // MARK: - CLI Runner @@ -208,6 +614,21 @@ actor MarketplaceService { } } + private func tomlKey(_ key: String) -> String { + if key.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil { + return key + } + return tomlString(key) + } + + private func tomlString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" + } + // MARK: - Errors enum MarketplaceError: LocalizedError { diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index a8d56fe..47e9d1c 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -6,6 +6,14 @@ import RxCodeCore import RxCodeSync import os.log +/// One per-activity Live Activity push token registered by a paired mobile. +/// The desktop targets `update`/`end` pushes at `token`, scoped to `sessionID`. +struct LiveActivityTokenRef: Codable, Sendable, Hashable { + var activityID: String + var sessionID: String + var token: String +} + /// One paired mobile device. Persisted to /// `~/Library/Application Support/RxCode/paired_devices.json`. struct PairedDevice: Codable, Identifiable, Sendable, Hashable { @@ -14,6 +22,12 @@ struct PairedDevice: Codable, Identifiable, Sendable, Hashable { var platform: String var apnsToken: String? var apnsEnvironment: String? + /// Device-wide Live Activity push-to-start token (iOS 17.2+). Lets the + /// desktop spawn a job Live Activity remotely. Optional for wire/forward + /// compatibility with paired-device files written before Live Activities. + var liveActivityStartToken: String? + /// Per-activity Live Activity update tokens, one per running job activity. + var liveActivityTokens: [LiveActivityTokenRef]? var pairedAt: Date var lastSeen: Date? @@ -77,6 +91,33 @@ final class MobileSyncService: ObservableObject { /// because AppState owns the storage layer and the streaming loop. private weak var appState: AnyObject? + // MARK: - Live Activity & widget state + + /// Resolves a project's display name for Live Activity attributes. Set by + /// `AppState` after initialization; `nil` before that. + var projectNameResolver: (@MainActor (UUID) -> String?)? + /// Supplies the current Claude Code / Codex 5-hour usage for the widget + /// background push. Set by `AppState`; `nil` before that. + var usageSnapshotProvider: (@MainActor () -> (cc: Double?, codex: Double?))? + + /// Session ids currently streaming — the live job count for the widget. + private var streamingSessionIDs: Set = [] + /// Every job tracked by the single aggregate Live Activity: those still + /// running plus recently finished ones, in start order. + private var trackedJobs: [JobContent] = [] + /// `true` once a foregrounded device reported it started the activity + /// locally; suppresses the push-to-start until the activity goes away. + private var jobsActivityLocallyStarted = false + /// Signature of the content-state last pushed, so an update only fires on + /// a real change rather than on every session event. + private var lastPushedJobsSignature = "" + /// Pending deferred push-to-start. The push-to-start is delayed briefly so + /// a foregrounded device can start the activity locally instead; this task + /// is cancelled once a device reports it did. + private var pendingStartTask: Task? + /// Last widget job count pushed, so a widget push only fires on a change. + private var lastWidgetJobCount: Int = -1 + init() { // Persisted relay URL or sensible default for self-host. let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") @@ -396,6 +437,7 @@ final class MobileSyncService: ObservableObject { ) await client.broadcast(.sessionUpdate(payload)) } + updateJobTracking(sessionID: sessionID, kind: kind, isStreaming: isStreaming, summary: summary) } /// Mirror the desktop's current `AskUserQuestion` queue to every paired @@ -413,6 +455,320 @@ final class MobileSyncService: ObservableObject { } } + // MARK: - Live Activity & widget push + + /// Fold a session update into the streaming-job set and the aggregate Live + /// Activity, then push any resulting Live Activity / widget changes. + /// Called for every `broadcastSessionUpdate`. + private func updateJobTracking( + sessionID: String, + kind: SessionUpdatePayload.Kind, + isStreaming: Bool?, + summary: RxCodeSync.SessionSummary? + ) { + let streaming: Bool? + switch kind { + case .streamingStarted: streaming = true + case .streamingFinished: streaming = false + default: streaming = isStreaming + } + if let streaming { + if streaming { streamingSessionIDs.insert(sessionID) } + else { streamingSessionIDs.remove(sessionID) } + } + // Summaries carry title/progress/todos — they drive the Live Activity. + if let summary { + foldSummaryIntoJobs(summary) + pushJobsActivity() + } + pushWidgetUpdateIfJobCountChanged() + } + + /// Merge one session summary into `trackedJobs`. + /// + /// A running session is inserted or updated. A finished session updates + /// the job only if it is already tracked, and is otherwise ignored — the + /// aggregate activity follows jobs it saw start. When a new job begins + /// while every tracked job is already done, the previous (acknowledged) + /// batch is cleared so the activity starts a fresh list. + private func foldSummaryIntoJobs(_ summary: RxCodeSync.SessionSummary) { + let content = makeJobContent(from: summary) + if let idx = trackedJobs.firstIndex(where: { $0.sessionID == summary.id }) { + trackedJobs[idx] = content + } else if summary.isStreaming { + if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) { + trackedJobs.removeAll() + lastPushedJobsSignature = "" + } + trackedJobs.append(content) + } + pruneTrackedJobs() + } + + /// Cap the tracked-job list, dropping the oldest finished jobs first so a + /// long-lived device never accumulates an unbounded history. + private func pruneTrackedJobs() { + let cap = 6 + while trackedJobs.count > cap { + if let doneIdx = trackedJobs.firstIndex(where: \.isDone) { + trackedJobs.remove(at: doneIdx) + } else { + trackedJobs.removeFirst() + } + } + } + + private func makeJobContent(from summary: RxCodeSync.SessionSummary) -> JobContent { + JobContent( + sessionID: summary.id, + title: summary.title, + projectName: projectNameResolver?(summary.projectId) ?? "", + todoDone: summary.progress?.done ?? 0, + todoTotal: summary.progress?.total ?? 0, + currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm, + isDone: !summary.isStreaming + ) + } + + // MARK: Aggregate Live Activity + + /// Concatenated per-job signatures — identifies a distinct rendered state. + private var jobsSignature: String { + trackedJobs.map(\.signature).joined(separator: ";") + } + + /// `true` once every tracked job has finished. + private var allJobsDone: Bool { + !trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone) + } + + /// `true` when some paired device has registered the aggregate activity's + /// update token — i.e. the activity exists and can be pushed `update`s. + private var hasAnyActivityToken: Bool { + pairedDevices.contains { !($0.liveActivityTokens ?? []).isEmpty } + } + + /// Drive the single aggregate Live Activity from `trackedJobs`. + /// + /// The activity is created once with a push-to-start and then reused for + /// the lifetime of the device session: it is never ended or auto-dismissed + /// by the desktop, only updated. One activity for every job keeps re-runs + /// off the scarce iOS push-to-start budget; the user dismisses it. + private func pushJobsActivity() { + guard !trackedJobs.isEmpty else { return } + let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 + if hasAnyActivityToken { + let signature = jobsSignature + guard signature != lastPushedJobsSignature else { + logger.debug("[LiveActivity] jobs activity unchanged — skip update") + return + } + lastPushedJobsSignature = signature + logger.info("[LiveActivity] jobs activity update jobs=\(self.trackedJobs.count, privacy: .public) running=\(self.trackedJobs.filter { !$0.isDone }.count, privacy: .public)") + sendJobsActivityUpdate(staleAfter: staleAfter) + } else if jobsActivityLocallyStarted { + // The activity exists locally; its update token has not been + // minted yet. The first push goes out when that token registers. + logger.debug("[LiveActivity] jobs activity started locally — awaiting update token") + } else { + scheduleJobsActivityStart() + } + } + + /// Schedule the push-to-start after a short delay. A foregrounded device + /// starts the activity itself (no push-to-start budget) and reports it + /// within a second or two, cancelling this task. Only a backgrounded + /// device ends up actually receiving the push-to-start. + private func scheduleJobsActivityStart() { + guard pendingStartTask == nil else { return } + logger.info("[LiveActivity] scheduling jobs activity push-to-start in 5s") + pendingStartTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self else { return } + self.pendingStartTask = nil + guard !self.trackedJobs.isEmpty else { return } + guard !self.hasAnyActivityToken, !self.jobsActivityLocallyStarted else { + self.logger.info("[LiveActivity] push-to-start skipped — a device already has the activity") + return + } + self.sendJobsActivityStart() + } + } + + /// Cancel a pending push-to-start — the activity already exists. + private func cancelJobsActivityStart() { + if pendingStartTask != nil { + pendingStartTask?.cancel() + pendingStartTask = nil + logger.debug("[LiveActivity] pending push-to-start cancelled") + } + } + + /// Push a `start` for the aggregate activity to every device with a + /// push-to-start token. + private func sendJobsActivityStart() { + let devices = pairedDevices.filter { ($0.liveActivityStartToken?.isEmpty == false) } + guard !devices.isEmpty else { + logger.warning("[LiveActivity] start skipped — no paired device has a push-to-start token (pairedDevices=\(self.pairedDevices.count, privacy: .public))") + return + } + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] start skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + let now = Date() + let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "start", + "content-state": jobsContentStateDict(at: now), + "attributes-type": "RxCodeJobActivityAttributes", + "attributes": [String: Any](), + "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), + ]] + lastPushedJobsSignature = jobsSignature + logger.info("[LiveActivity] start jobs activity devices=\(devices.count, privacy: .public) jobs=\(self.trackedJobs.count, privacy: .public)") + for device in devices { + guard let token = device.liveActivityStartToken else { continue } + logger.info("[LiveActivity] start → posting push startTokenPrefix=\(String(token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: token, pushType: "liveactivity", + apnsPayload: payload, collapseID: nil, device: device, pushURL: pushURL) + } + } + } + + /// Push an `update` for the aggregate activity. `staleAfter` sets when the + /// activity dims — long for the terminal all-done state, which stays on + /// screen until the user dismisses it. No `end` is ever sent. + private func sendJobsActivityUpdate(staleAfter: TimeInterval) { + let now = Date() + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "update", + "content-state": jobsContentStateDict(at: now), + "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), + ]] + pushToActivityTokens(payload: payload) + } + + /// Build the ActivityKit `content-state` dict. Field names mirror + /// `RxCodeJobActivityAttributes.ContentState` in the widget target. + private func jobsContentStateDict(at date: Date) -> [String: Any] { + let jobs: [[String: Any]] = trackedJobs.map { job in + var dict: [String: Any] = [ + "id": job.sessionID, + "phase": job.isDone ? "done" : "running", + "title": job.title, + "projectName": job.projectName, + "todoDone": job.todoDone, + "todoTotal": job.todoTotal, + ] + if let step = job.currentStep, !step.isEmpty { + dict["currentStep"] = step + } + return dict + } + return ["jobs": jobs, "updatedAt": date.timeIntervalSince1970] + } + + /// Push a Live Activity payload to every registered aggregate-activity + /// token (one per paired device). + private func pushToActivityTokens(payload: [String: Any]) { + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] update skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + var matched = 0 + for device in pairedDevices { + for ref in (device.liveActivityTokens ?? []) { + matched += 1 + logger.info("[LiveActivity] push → activity token activity=\(ref.activityID, privacy: .public) tokenPrefix=\(String(ref.token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: ref.token, pushType: "liveactivity", + apnsPayload: payload, collapseID: "rxcode-jobs-activity", + device: device, pushURL: pushURL) + } + } + } + if matched == 0 { + logger.warning("[LiveActivity] update has no registered activity token — the mobile never reported one") + } + } + + private func pushWidgetUpdateIfJobCountChanged() { + guard streamingSessionIDs.count != lastWidgetJobCount else { return } + pushWidgetUpdate() + } + + /// Push the current ongoing-job count and agent usage to every paired + /// device as a silent background notification, refreshing the home-screen + /// widget. Also called by `AppState` when rate-limit usage refreshes. + func pushWidgetUpdate() { + let jobCount = streamingSessionIDs.count + lastWidgetJobCount = jobCount + let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } + guard !devices.isEmpty, let pushURL = Self.pushEndpointURL(from: relayURL) else { return } + let usage = usageSnapshotProvider?() + var widget: [String: Any] = [ + "jobs": jobCount, + "updatedAt": Date().timeIntervalSince1970, + ] + if let cc = usage?.cc { widget["cc"] = cc } + if let codex = usage?.codex { widget["codex"] = codex } + let payload: [String: Any] = ["aps": ["content-available": 1], "widget": widget] + for device in devices { + guard let token = device.apnsToken else { continue } + Task { + await postRawPush(deviceToken: token, pushType: "background", + apnsPayload: payload, collapseID: "rxcode-widget", + device: device, pushURL: pushURL) + } + } + } + + /// POST a raw (Live Activity or background) push to the relay `/push` + /// endpoint. Failures are logged and swallowed — these are best-effort. + private func postRawPush( + deviceToken: String, + pushType: String, + apnsPayload: [String: Any], + collapseID: String?, + device: PairedDevice, + pushURL: URL + ) async { + var bodyDict: [String: Any] = [ + "device_token": deviceToken, + "push_type": pushType, + "apns_payload": apnsPayload, + ] + if let collapseID { bodyDict["collapse_id"] = collapseID } + do { + let httpBody = try JSONSerialization.data(withJSONObject: bodyDict) + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { return } + guard (200..<300).contains(http.statusCode) else { + logger.error("[Push] \(pushType, privacy: .public) relay rejected status=\(http.statusCode, privacy: .public) body=\(Self.responseBodyString(data), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + return + } + if let pushResponse = try? JSONDecoder().decode(APNsPushResponse.self, from: data) { + if (200..<300).contains(pushResponse.statusCode) { + logger.info("[Push] \(pushType, privacy: .public) accepted apnsStatus=\(pushResponse.statusCode, privacy: .public) apnsID=\(pushResponse.apnsID ?? "", privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } else { + logger.error("[Push] \(pushType, privacy: .public) apns rejected status=\(pushResponse.statusCode, privacy: .public) reason=\(pushResponse.reason, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } + } else { + logger.info("[Push] \(pushType, privacy: .public) relay accepted httpStatus=\(http.statusCode, privacy: .public) (no APNs detail in response) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } + } catch { + logger.error("[Push] \(pushType, privacy: .public) failed deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + // MARK: - Event dispatch private func handle(event: RelayClient.Event) { @@ -452,6 +808,54 @@ final class MobileSyncService: ObservableObject { } 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 .liveActivityToken(let t): + guard let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) else { + logger.warning("[LiveActivity] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + return + } + if let startToken = t.pushToStartTokenHex, !startToken.isEmpty { + pairedDevices[idx].liveActivityStartToken = startToken + logger.info("[LiveActivity] push-to-start token registered mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } + if t.activityDismissed == true { + // The user swiped the aggregate Live Activity away. Forget + // this device's update token; once no device tracks the + // activity the next job push-to-starts a fresh one. + if var refs = pairedDevices[idx].liveActivityTokens { + refs.removeAll { t.activityID == nil || $0.activityID == t.activityID } + pairedDevices[idx].liveActivityTokens = refs.isEmpty ? nil : refs + } + if !hasAnyActivityToken { + jobsActivityLocallyStarted = false + lastPushedJobsSignature = "" + cancelJobsActivityStart() + } + logger.info("[LiveActivity] aggregate activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } else if t.activityStartedLocally == true { + // A foregrounded device started the activity itself with + // `Activity.request` and reported it the instant it was + // created — long before APNs mints the update token. Cancel + // the deferred push-to-start so iOS never spawns a duplicate. + jobsActivityLocallyStarted = true + cancelJobsActivityStart() + logger.info("[LiveActivity] device started aggregate activity locally mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) — deferred push-to-start cancelled") + } else if let activityToken = t.activityTokenHex, !activityToken.isEmpty, + let activityID = t.activityID { + // One aggregate activity per device — replace any prior token. + pairedDevices[idx].liveActivityTokens = [ + LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) + ] + logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + cancelJobsActivityStart() + // Push the latest known state straight away so a freshly + // started activity isn't left blank until the next change. + if !trackedJobs.isEmpty { + lastPushedJobsSignature = jobsSignature + sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) + } + } + pairedDevices[idx].lastSeen = .now + savePairedDevices() case .requestSnapshot(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "request_snapshot") else { return } logger.info("[MobileSync] snapshot requested by mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) activeSession=\(req.activeSessionID ?? "", privacy: .public)") @@ -520,6 +924,13 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .threadChangesRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_changes_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncThreadChangesRequested, + 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 ?? "" @@ -596,6 +1007,62 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .skillCatalogRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_catalog_request") else { return } + logger.info("[MobileSync] skill catalog requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillCatalogRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .skillMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_mutation_request") else { return } + logger.info("[MobileSync] skill mutation requested operation=\(req.operation.rawValue, privacy: .public) plugin=\(req.pluginID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .skillSourceMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_source_mutation_request") else { return } + logger.info("[MobileSync] skill source mutation requested operation=\(req.operation.rawValue, privacy: .public) source=\(req.sourceID ?? req.gitURL ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillSourceMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .acpRegistryRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_registry_request") else { return } + logger.info("[MobileSync] acp registry requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncACPRegistryRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .acpMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_mutation_request") else { return } + logger.info("[MobileSync] acp mutation requested operation=\(req.operation.rawValue, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncACPMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .mcpConfigRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_config_request") else { return } + logger.info("[MobileSync] mcp config requested mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncMCPConfigRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .mcpMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_mutation_request") else { return } + logger.info("[MobileSync] mcp mutation requested operation=\(req.operation.rawValue, privacy: .public) server=\(req.serverName, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncMCPMutationRequested, + 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) } @@ -744,6 +1211,28 @@ private struct APNsPushResponse: Codable { } } +/// Latest content the desktop knows for one job in the aggregate Live +/// Activity. Stored in `MobileSyncService.trackedJobs` in start order. +private struct JobContent { + var sessionID: String + var title: String + var projectName: String + var todoDone: Int + var todoTotal: Int + var currentStep: String? + /// `true` once the job has finished. It shows the "done" phase but stays + /// in the aggregate list so the activity can report the completed batch. + var isDone: Bool + + /// Identifies a distinct rendered state for one job, so an update only + /// pushes on a real change rather than on every session event. Includes + /// `title` so the activity refreshes when the desktop swaps in an + /// AI-summarized title. + var signature: String { + "\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")" + } +} + extension Notification.Name { static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") @@ -753,6 +1242,7 @@ extension Notification.Name { static let mobileSyncThreadActionRequested = Notification.Name("mobileSync.threadActionRequested") static let mobileSyncLoadMoreMessagesRequested = Notification.Name("mobileSync.loadMoreMessagesRequested") static let mobileSyncSearchRequested = Notification.Name("mobileSync.searchRequested") + static let mobileSyncThreadChangesRequested = Notification.Name("mobileSync.threadChangesRequested") static let mobileSyncSettingsUpdateReceived = Notification.Name("mobileSync.settingsUpdateReceived") static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") static let mobileSyncQuestionAnswerReceived = Notification.Name("mobileSync.questionAnswerReceived") @@ -763,4 +1253,11 @@ extension Notification.Name { static let mobileSyncRunProfileMutationRequested = Notification.Name("mobileSync.runProfileMutationRequested") static let mobileSyncRunProfileRunRequested = Notification.Name("mobileSync.runProfileRunRequested") static let mobileSyncRunProfileStopRequested = Notification.Name("mobileSync.runProfileStopRequested") + static let mobileSyncSkillCatalogRequested = Notification.Name("mobileSync.skillCatalogRequested") + static let mobileSyncSkillMutationRequested = Notification.Name("mobileSync.skillMutationRequested") + static let mobileSyncSkillSourceMutationRequested = Notification.Name("mobileSync.skillSourceMutationRequested") + static let mobileSyncACPRegistryRequested = Notification.Name("mobileSync.acpRegistryRequested") + static let mobileSyncACPMutationRequested = Notification.Name("mobileSync.acpMutationRequested") + static let mobileSyncMCPConfigRequested = Notification.Name("mobileSync.mcpConfigRequested") + static let mobileSyncMCPMutationRequested = Notification.Name("mobileSync.mcpMutationRequested") } diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index 851359c..d8a87d0 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -183,6 +183,36 @@ final class NotificationService: NSObject { } } + /// Post a local banner after a paired mobile device remotely changed the + /// desktop's skill / ACP / MCP configuration. Silently no-ops if the user + /// has not authorized notifications. + func postRemoteConfigChanged(title: String, body: String) async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional: + break + default: + return + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: "remote-config-\(UUID().uuidString)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.error("Failed to post remote config notification: \(error.localizedDescription)") + } + } + /// Post a "response complete" notification. Silently no-ops if unauthorized. /// Mobile fan-out always runs; the local macOS banner is skipped when /// `postLocalBanner` is false (e.g. the desktop app is foregrounded). diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index d30b81e..10de2df 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -239,6 +239,18 @@ actor PersistenceService { AppSupport.bundleScopedURL.appendingPathComponent("acp_registry.json") } + // MARK: - Custom Git Repositories + + func saveCustomRepos(_ repos: [CustomRepo]) throws { + let url = baseURL.appendingPathComponent("custom_repos.json") + try encode(repos, to: url) + } + + func loadCustomRepos() -> [CustomRepo] { + let url = baseURL.appendingPathComponent("custom_repos.json") + return decode([CustomRepo].self, from: url) ?? [] + } + // MARK: - GitHub User Cache func saveGitHubUser(_ user: GitHubUser) throws { diff --git a/RxCode/Views/Chat/GitHubSheet.swift b/RxCode/Views/Chat/GitHubSheet.swift index 2d72df5..6eadba6 100644 --- a/RxCode/Views/Chat/GitHubSheet.swift +++ b/RxCode/Views/Chat/GitHubSheet.swift @@ -8,18 +8,23 @@ struct GitHubSheet: View { @State private var showLoginSheet = false @State private var searchText = "" @State private var cloningRepo: String? + @State private var selectedTab = 0 + @State private var customRepoURL = "" + @State private var customRepoName = "" + @State private var isAddingCustomRepo = false + @State private var cloningCustomRepo: String? var body: some View { VStack(spacing: 0) { // Title bar HStack { - Text("GitHub") + Text("Git Repositories") .font(.headline) .foregroundStyle(ClaudeTheme.textPrimary) Spacer() - if appState.isLoggedIn, let user = appState.gitHubUser { + if selectedTab == 0, appState.isLoggedIn, let user = appState.gitHubUser { Text("@\(user.login)") .font(.caption) .foregroundStyle(ClaudeTheme.textSecondary) @@ -48,13 +53,22 @@ struct GitHubSheet: View { .padding(.horizontal, 16) .padding(.vertical, 12) + // Tab picker + Picker("Source", selection: $selectedTab) { + Text("GitHub").tag(0) + Text("Custom").tag(1) + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 8) + ClaudeThemeDivider() // Content - if appState.isLoggedIn { - repoContent + if selectedTab == 0 { + githubContent } else { - connectPrompt + customContent } } .frame(width: 480, height: 520) @@ -70,6 +84,18 @@ struct GitHubSheet: View { } } + // MARK: - GitHub Content + + private var githubContent: some View { + Group { + if appState.isLoggedIn { + repoContent + } else { + connectPrompt + } + } + } + // MARK: - Connect Prompt private var connectPrompt: some View { @@ -140,6 +166,159 @@ struct GitHubSheet: View { } } + // MARK: - Custom Content + + private var customContent: some View { + VStack(spacing: 0) { + // Add button + HStack { + Button { + withAnimation { + isAddingCustomRepo = true + } + } label: { + Label("Add Repository", systemImage: "plus") + } + .buttonStyle(ClaudeAccentButtonStyle()) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + + ClaudeThemeDivider() + + if isAddingCustomRepo { + addCustomRepoForm + } + + if appState.customRepos.isEmpty, !isAddingCustomRepo { + customEmptyState + } else { + customRepoList + } + } + } + + private var addCustomRepoForm: some View { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Git URL") + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + TextField("https://github.com/owner/repo.git or git@github.com:owner/repo.git", text: $customRepoURL) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Project Name") + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + TextField("my-project", text: $customRepoName) + .textFieldStyle(.roundedBorder) + } + + HStack { + Button("Cancel") { + withAnimation { + isAddingCustomRepo = false + customRepoURL = "" + customRepoName = "" + } + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + + Spacer() + + Button { + Task { await cloneCustomRepo() } + } label: { + if cloningCustomRepo != nil { + ProgressView() + .controlSize(.small) + } else { + Text("Clone & Add") + } + } + .buttonStyle(ClaudeAccentButtonStyle()) + .disabled(customRepoURL.isEmpty || customRepoName.isEmpty || cloningCustomRepo != nil) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var customEmptyState: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "archivebox") + .font(.system(size: 32)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("No custom repositories") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + Text("Add a Git repository by URL") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var customRepoList: some View { + List { + ForEach(appState.customRepos) { repo in + HStack(spacing: 10) { + Image(systemName: "archivebox.fill") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(repo.name) + .font(.body) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + Text(repo.cloneURL) + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + + Spacer() + + if cloningCustomRepo == repo.name { + ProgressView() + .controlSize(.small) + } else if isCustomRepoAdded(repo) { + Label("Added", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(ClaudeTheme.statusSuccess) + } else { + Button { + Task { await cloneCustomRepo(repo) } + } label: { + Label("Clone", systemImage: "plus.circle") + .font(.caption) + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + } + + Button(role: .destructive) { + Task { await appState.removeCustomRepo(repo) } + } label: { + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 2) + } + } + .listStyle(.plain) + } + private var loadingState: some View { VStack(spacing: 10) { Spacer() @@ -227,6 +406,10 @@ struct GitHubSheet: View { appState.projects.contains { $0.gitHubRepo == repo.fullName } } + private func isCustomRepoAdded(_ repo: CustomRepo) -> Bool { + appState.projects.contains { $0.path.contains(repo.name) } + } + private func cloneRepo(_ repo: GitHubRepo) async { cloningRepo = repo.fullName do { @@ -237,6 +420,37 @@ struct GitHubSheet: View { } cloningRepo = nil } + + private func cloneCustomRepo(_ repo: CustomRepo? = nil) async { + if let repo { + cloningCustomRepo = repo.name + do { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(repo.name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + try await appState.cloneCustomRepo(repo, in: windowState) + } catch { + windowState.errorMessage = "Clone failed: \(error.localizedDescription)" + windowState.showError = true + } + cloningCustomRepo = nil + } else { + cloningCustomRepo = customRepoName + do { + try await appState.addCustomRepo(url: customRepoURL, name: customRepoName, in: windowState) + customRepoURL = "" + customRepoName = "" + isAddingCustomRepo = false + } catch { + windowState.errorMessage = "Clone failed: \(error.localizedDescription)" + windowState.showError = true + } + cloningCustomRepo = nil + } + } } #Preview { diff --git a/RxCode/Views/Chat/SkillMarketView.swift b/RxCode/Views/Chat/SkillMarketView.swift index 7ba9b5c..0121f5f 100644 --- a/RxCode/Views/Chat/SkillMarketView.swift +++ b/RxCode/Views/Chat/SkillMarketView.swift @@ -1,14 +1,14 @@ -import SwiftUI import RxCodeCore +import SwiftUI /// Skill marketplace panel — displayed as an overlay or embedded in a settings tab. struct SkillMarketView: View { @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState @Environment(\.dismiss) private var dismiss @State private var searchText = "" @State private var selectedFilter = "All" @State private var selectedPlugin: MarketplacePlugin? + @State private var showGitSourceSheet = false /// When true, strips overlay-specific styling (rounded corners, shadow, fixed frame) /// and hides the close button so the view can be embedded in a parent container. @@ -30,14 +30,14 @@ struct SkillMarketView: View { } .sheet(item: $selectedPlugin) { plugin in PluginDetailView( - plugin: plugin, - isInstalled: appState.marketplaceInstalledNames.contains(plugin.name), - installStatus: appState.marketplacePluginStates[plugin.id] ?? .notInstalled, - onInstall: {}, - onUninstall: {} + plugin: plugin ) .focusable(false) } + .sheet(isPresented: $showGitSourceSheet) { + AddSkillGitSourceSheet() + .focusable(false) + } } private var marketplaceContent: some View { @@ -45,6 +45,10 @@ struct SkillMarketView: View { headerBar Divider() searchAndFilterBar + if !appState.marketplaceCustomSources.isEmpty || appState.marketplaceSourceError != nil { + Divider() + customSourcesBar + } Divider() pluginGrid } @@ -63,6 +67,16 @@ struct SkillMarketView: View { Spacer() + Button { + showGitSourceSheet = true + } label: { + Label("Add Git Source", systemImage: "plus") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Add a skill catalog from a GitHub repository") + Button { Task { await appState.loadMarketplace(forceRefresh: true) } } label: { @@ -127,6 +141,45 @@ struct SkillMarketView: View { .padding(.vertical, 10) } + @ViewBuilder + private var customSourcesBar: some View { + if !appState.marketplaceCustomSources.isEmpty || appState.marketplaceSourceError != nil { + HStack(spacing: 8) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + + if appState.marketplaceCustomSources.isEmpty { + Text("No custom Git sources") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + } else { + Text("\(appState.marketplaceCustomSources.count) custom Git source\(appState.marketplaceCustomSources.count == 1 ? "" : "s")") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .foregroundStyle(.secondary) + } + + if let error = appState.marketplaceSourceError { + Text(error) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(Color.red) + .lineLimit(1) + } + + Spacer() + + Button("Manage") { + showGitSourceSheet = true + } + .font(.system(size: ClaudeTheme.size(12))) + .buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor).opacity(0.45)) + } + } + private func filterChip(_ label: String) -> some View { Button { selectedFilter = label @@ -203,9 +256,9 @@ struct SkillMarketView: View { let query = searchText.lowercased() plugins = plugins.filter { $0.name.lowercased().contains(query) || - $0.description.lowercased().contains(query) || - $0.author.lowercased().contains(query) || - $0.category.lowercased().contains(query) + $0.description.lowercased().contains(query) || + $0.author.lowercased().contains(query) || + $0.category.lowercased().contains(query) } } @@ -221,6 +274,161 @@ struct SkillMarketView: View { } } +// MARK: - Add Git Source + +struct AddSkillGitSourceSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var gitURL = "" + @State private var ref = "" + @State private var isAdding = false + @State private var localError: String? + + private var canAdd: Bool { + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isAdding + } + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Add Git Skill Source") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + Text("Use a GitHub repository that exposes .claude-plugin/marketplace.json.") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + + Divider() + + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text("GitHub Repository") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + TextField("https://github.com/owner/repo", text: $gitURL) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(13))) + } + + VStack(alignment: .leading, spacing: 5) { + Text("Branch or Ref") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + TextField("main", text: $ref) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(13))) + } + + if let error = localError ?? appState.marketplaceSourceError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(Color.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button { + Task { await addSource() } + } label: { + if isAdding { + ProgressView() + .controlSize(.small) + } else { + Text("Add Source") + } + } + .buttonStyle(.borderedProminent) + .disabled(!canAdd) + } + } + .padding(20) + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Custom Sources") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + if appState.marketplaceCustomSources.isEmpty { + Text("No custom Git sources added.") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(appState.marketplaceCustomSources) { source in + HStack(spacing: 10) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(source.displayName) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .lineLimit(1) + .textSelection(.enabled) + + Spacer() + + Button(role: .destructive) { + Task { await appState.removeMarketplaceGitSource(source) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + } + } + } + } + } + .padding(20) + // Fill the remaining vertical space so the list scrolls internally + // instead of pushing the pinned header off the top of the sheet. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame(width: 520, height: 560) + } + + private func addSource() async { + isAdding = true + localError = nil + let ok = await appState.addMarketplaceGitSource( + url: gitURL, + ref: ref.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : ref + ) + isAdding = false + if ok { + gitURL = "" + ref = "" + } else { + localError = appState.marketplaceSourceError + } + } +} + // MARK: - Plugin Card (for grid) struct PluginCard: View { @@ -337,15 +545,17 @@ struct PluginCard: View { struct PluginDetailView: View { let plugin: MarketplacePlugin - let isInstalled: Bool - let installStatus: PluginInstallStatus - let onInstall: () -> Void - let onUninstall: () -> Void @Environment(\.dismiss) private var dismiss @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @State private var terminalState: InteractiveTerminalState? + + private var isInstalled: Bool { + appState.marketplaceInstalledNames.contains(plugin.name) + } + + private var installStatus: PluginInstallStatus { + appState.marketplacePluginStates[plugin.id] ?? .notInstalled + } var body: some View { VStack(spacing: 0) { @@ -422,11 +632,11 @@ struct PluginDetailView: View { // Install command VStack(alignment: .leading, spacing: 6) { - Text("Install Command") + Text("Agent Availability") .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - Text(plugin.installCommand) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + Text("Installed skills are managed by RxCode, sourced from OpenAI Agent Skills and compatible catalogs, and enabled for Claude Code, Codex, and ACP agents where supported.") + .font(.system(size: ClaudeTheme.size(12))) .foregroundStyle(.secondary) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) @@ -438,12 +648,6 @@ struct PluginDetailView: View { } } .frame(width: 620, height: 500) - .sheet(item: $terminalState) { terminal in - InteractiveTerminalPopup(state: terminal) - .onDisappear { - Task { await appState.loadMarketplace() } - } - } } @ViewBuilder @@ -462,20 +666,10 @@ struct PluginDetailView: View { @ViewBuilder private var removeButton: some View { Button("Remove") { - terminalState = InteractiveTerminalState( - title: "Uninstall \(plugin.name)", - executable: "/bin/zsh", - arguments: ["-il"], - initialCommand: "claude plugin uninstall \(plugin.name)", - reportToChat: false - ) + Task { await appState.uninstallMarketplacePlugin(plugin) } } .font(.system(size: ClaudeTheme.size(12), weight: .medium)) .foregroundStyle(Color.red) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(Color.red.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } @ViewBuilder @@ -495,31 +689,18 @@ struct PluginDetailView: View { removeButton case .failed: Button("Retry") { - terminalState = InteractiveTerminalState( - title: "Install \(plugin.name)", - executable: "/bin/zsh", - arguments: ["-il"], - initialCommand: "claude plugin install \(plugin.name)@\(plugin.marketplace)", - reportToChat: false - ) + Task { await appState.installMarketplacePlugin(plugin) } } .buttonStyle(.borderedProminent) default: Button("Install") { - terminalState = InteractiveTerminalState( - title: "Install \(plugin.name)", - executable: "/bin/zsh", - arguments: ["-il"], - initialCommand: "claude plugin install \(plugin.name)@\(plugin.marketplace)", - reportToChat: false - ) + Task { await appState.installMarketplacePlugin(plugin) } } .buttonStyle(.borderedProminent) } } } - #Preview { SkillMarketView() .environment(AppState()) diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index b3d0c8f..f068ab8 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -2,6 +2,7 @@ import AppKit import RxCodeChatKit import RxCodeCore import SwiftUI +import TipKit import UniformTypeIdentifiers struct MainView: View { @@ -202,6 +203,7 @@ struct MainView: View { Image(systemName: "magnifyingglass") } .help("Search Threads (⌘K)") + .popoverTip(RxCodeTips.GlobalSearchTip(), arrowEdge: .top) } ToolbarItem(placement: .navigation) { @@ -588,6 +590,7 @@ struct ChatToolbarControls: View { .fixedSize() .help("Model: \(effectiveProvider.displayName) · \(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))") .accessibilityIdentifier("provider-model-menu") + .popoverTip(RxCodeTips.AgentSelectionTip(), arrowEdge: .top) Menu { Section("Effort Picker") { diff --git a/RxCode/Views/Settings/ACPClientSettingsTab.swift b/RxCode/Views/Settings/ACPClientSettingsTab.swift index 8c1187e..3d37e3f 100644 --- a/RxCode/Views/Settings/ACPClientSettingsTab.swift +++ b/RxCode/Views/Settings/ACPClientSettingsTab.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit private enum ACPClientSettingsPage: String, CaseIterable, Identifiable { case installed @@ -105,6 +106,7 @@ struct ACPClientSettingsTab: View { .labelsHidden() .frame(width: 240) .frame(maxWidth: .infinity, alignment: .center) + .popoverTip(RxCodeTips.ACPTip(), arrowEdge: .top) } // MARK: - Installed diff --git a/RxCode/Views/Settings/MCPSettingsTab.swift b/RxCode/Views/Settings/MCPSettingsTab.swift index 18c953c..d6a1457 100644 --- a/RxCode/Views/Settings/MCPSettingsTab.swift +++ b/RxCode/Views/Settings/MCPSettingsTab.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit struct MCPSettingsTab: View { @Environment(AppState.self) private var appState @@ -85,6 +86,7 @@ struct MCPSettingsTab: View { .font(.system(size: ClaudeTheme.size(12))) } .buttonStyle(.borderedProminent) + .popoverTip(RxCodeTips.MCPTip(), arrowEdge: .top) } } diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 2e07cda..fedae54 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -2,6 +2,7 @@ import SwiftUI import CoreImage.CIFilterBuiltins import RxCodeCore import RxCodeSync +import TipKit /// How the relay server is chosen in the Mobile settings tab. private enum RelayMode: Hashable { @@ -243,6 +244,7 @@ struct MobileSettingsTab: View { .buttonStyle(.borderedProminent) .disabled(sync.connectionState != .connected) .help(sync.connectionState == .connected ? "" : "Connect to the relay before pairing a device.") + .popoverTip(RxCodeTips.MobileConnectionTip(), arrowEdge: .top) } if sync.pairedDevices.isEmpty { diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 1b236a4..977f677 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI import RxCodeCore import RxCodeChatKit +import TipKit // MARK: - Settings Sheet @@ -40,23 +41,29 @@ struct SettingsView: View { } .tag(3) + SkillMarketView(isEmbedded: true) + .tabItem { + Label("Skill Marketplace", systemImage: "brain.head.profile") + } + .tag(4) + MCPSettingsTab() .tabItem { Label("MCP", systemImage: "puzzlepiece.extension") } - .tag(4) + .tag(5) ACPClientSettingsTab() .tabItem { Label("ACP Clients", systemImage: "link.circle") } - .tag(5) + .tag(6) MobileSettingsTab() .tabItem { Label("Mobile", systemImage: "iphone.gen3") } - .tag(6) + .tag(7) } .frame(width: 680, height: 620) .focusable(false) @@ -84,7 +91,6 @@ struct GeneralSettingsTab: View { @Environment(AppState.self) private var appState @Binding var showUserManual: Bool @Binding var showOnboarding: Bool - @State private var showSkillMarket = false @State private var showThemePicker = false @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true @@ -103,7 +109,6 @@ struct GeneralSettingsTab: View { searchIndexSection Divider() VStack(alignment: .leading, spacing: 8) { - skillMarketSection onboardingSection helpSection sourceCodeSection @@ -318,44 +323,6 @@ struct GeneralSettingsTab: View { } } - // MARK: - Skill Market Section - - private var skillMarketSection: some View { - Button { - showSkillMarket = true - } label: { - HStack(spacing: 10) { - Image(systemName: "brain.head.profile") - .font(.system(size: ClaudeTheme.size(14))) - .frame(width: 20) - VStack(alignment: .leading, spacing: 1) { - Text("Skill Marketplace") - .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(.primary) - Text("Browse and manage Claude Code skills") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "arrow.up.right.square") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .sheet(isPresented: $showSkillMarket) { - SkillMarketView(isEmbedded: false) - } - } - // MARK: - Source Code Section private var sourceCodeSection: some View { @@ -684,6 +651,7 @@ struct ChatSettingsTab: View { } .pickerStyle(.menu) .fixedSize() + .popoverTip(RxCodeTips.SummarizationModelTip(), arrowEdge: .trailing) .onChange(of: appState.summarizationProvider) { _, newValue in guard newValue == .openAI, appState.openAISummarizationModels.isEmpty else { return } Task { await appState.refreshOpenAISummarizationModels() } diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index ea2f9c5..d98d770 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit // MARK: - ProjectTreeView @@ -178,6 +179,7 @@ private struct SummarySidebarSection: View { } .buttonStyle(.plain) .help("Open project branch briefing") + .popoverTip(RxCodeTips.BriefingTip(), arrowEdge: .trailing) } } } diff --git a/RxCode/Views/Tips/RxCodeTips.swift b/RxCode/Views/Tips/RxCodeTips.swift new file mode 100644 index 0000000..38e3566 --- /dev/null +++ b/RxCode/Views/Tips/RxCodeTips.swift @@ -0,0 +1,130 @@ +import SwiftUI +import TipKit + +enum RxCodeTips { + struct AgentSelectionTip: Tip { + var title: Text { + Text("Choose the agent for this thread") + } + + var message: Text? { + Text("Switch between Claude Code, Codex, and installed ACP agents without changing the global default.") + } + + var image: Image? { + Image(systemName: "sparkles") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct MCPTip: Tip { + var title: Text { + Text("Add tools with MCP") + } + + var message: Text? { + Text("Manage MCP servers once in RxCode, then enable or disable them globally or per project.") + } + + var image: Image? { + Image(systemName: "puzzlepiece.extension") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct ACPTip: Tip { + var title: Text { + Text("Install ACP agents") + } + + var message: Text? { + Text("Use the registry to add Agent Client Protocol tools, then pick them from the thread model menu.") + } + + var image: Image? { + Image(systemName: "link.circle") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct MobileConnectionTip: Tip { + var title: Text { + Text("Connect the mobile companion") + } + + var message: Text? { + Text("Pair an iPhone or iPad to review threads, answer approvals, and send messages to your desktop agent.") + } + + var image: Image? { + Image(systemName: "iphone.gen3") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct SummarizationModelTip: Tip { + var title: Text { + Text("Set the summarization model") + } + + var message: Text? { + Text("Keep summaries on the thread model, or route titles and briefings through an OpenAI-compatible endpoint.") + } + + var image: Image? { + Image(systemName: "text.badge.checkmark") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct BriefingTip: Tip { + var title: Text { + Text("Open the branch briefing") + } + + var message: Text? { + Text("Briefing rolls recent thread summaries into a branch-focused project update.") + } + + var image: Image? { + Image(systemName: "text.page") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct GlobalSearchTip: Tip { + var title: Text { + Text("Search every thread") + } + + var message: Text? { + Text("Press Command-K or use the toolbar search button to find past work across projects.") + } + + var image: Image? { + Image(systemName: "magnifyingglass") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } +} diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift index 8e708fa..058a8e9 100644 --- a/RxCode/Views/UserManualView.swift +++ b/RxCode/Views/UserManualView.swift @@ -539,11 +539,11 @@ enum ManualTopic: String, CaseIterable, Identifiable { [ ManualSection( title: "Skill Marketplace", - body: "Click the brain icon (🧠) in the toolbar to browse the MCP plugin catalog published on Anthropic's GitHub. Plugins can be filtered by category or searched by name, description, or author." + body: "Open Settings and select Skill Marketplace to browse OpenAI Agent Skills and compatible skill catalogs. Skills can be filtered by category or searched by name, description, or author." ), ManualSection( - title: "Installing Plugins", - body: "Click a plugin to view its details, then press Install. An interactive terminal popup opens and runs the install command automatically.", + title: "Installing Skills", + body: "Click a skill to view its details, then press Install. RxCode stores installed skills in its own settings and enables them for supported coding agents.", items: [ KeyValueItem(key: "clock", value: "Not installed", symbolName: "clock", symbolColor: .secondary), KeyValueItem(key: "arrow.down", value: "Installing…", symbolName: "arrow.down.circle", symbolColor: .accentColor), diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 6bb8523..940ebdb 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -56,6 +56,35 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent logger.error("[APNs] registration failed: \(error.localizedDescription, privacy: .public)") } + /// Handles a silent background push carrying a fresh home-screen widget + /// snapshot. The desktop sends these (`push_type=background`) whenever the + /// ongoing-job count or agent usage changes; the payload is plaintext + /// because WidgetKit data is low-sensitivity and not user-facing text. + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async + -> UIBackgroundFetchResult { + guard let widget = userInfo["widget"] as? [String: Any] else { + return .noData + } + // Merge into the existing snapshot so a job-count-only push doesn't + // wipe the usage figures (and vice versa). + var snapshot = RxCodeWidgetStore.load() + if let jobs = (widget["jobs"] as? NSNumber)?.intValue { + snapshot.jobCount = jobs + } + if let cc = (widget["cc"] as? NSNumber)?.doubleValue { + snapshot.ccUsagePercent = cc + } + if let codex = (widget["codex"] as? NSNumber)?.doubleValue { + snapshot.codexUsagePercent = codex + } + snapshot.updatedAt = (widget["updatedAt"] as? NSNumber)?.doubleValue + ?? Date().timeIntervalSince1970 + RxCodeWidgetStore.save(snapshot) + logger.info("[Widget] background push applied jobs=\(snapshot.jobCount, privacy: .public)") + return .newData + } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let content = notification.request.content diff --git a/RxCodeMobile/Info.plist b/RxCodeMobile/Info.plist index ea09a11..5e68e6b 100644 --- a/RxCodeMobile/Info.plist +++ b/RxCodeMobile/Info.plist @@ -4,6 +4,10 @@ ITSAppUsesNonExemptEncryption + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + NSCameraUsageDescription Scan a QR code shown by RxCode on your Mac to pair this device. NSLocalNetworkUsageDescription diff --git a/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements index 3a4a5a4..e7b7ab6 100644 --- a/RxCodeMobile/RxCodeMobile.entitlements +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.security.application-groups + + group.app.rxlab.rxcodemobile + com.apple.developer.associated-domains applinks:code.rxlab.app diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index d6900f0..960ad57 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -1,13 +1,22 @@ import SwiftUI import RxCodeCore +import TipKit @main struct RxCodeMobileApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var state = MobileAppState() @State private var windowState = WindowState() + @State private var liveActivityCoordinator = MobileLiveActivityCoordinator() @Environment(\.scenePhase) private var scenePhase + init() { + try? Tips.configure([ + .displayFrequency(.immediate), + .datastoreLocation(.applicationDefault), + ]) + } + var body: some Scene { WindowGroup { RootView() @@ -15,6 +24,7 @@ struct RxCodeMobileApp: App { .environment(windowState) .onAppear { appDelegate.mobileState = state + liveActivityCoordinator.bind(state: state) state.start() } .onOpenURL { url in diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 05301e8..561ca63 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -72,6 +72,47 @@ final class MobileAppState: ObservableObject { @Published var runTasks: [MobileRunTaskSnapshot] = [] @Published var inFlightRunProfileRequests: Set = [] @Published var lastRunProfileError: String? + + // MARK: - Remote desktop config: Skills + + /// Marketplace catalog mirrored from the desktop, with per-plugin install + /// state. Populated lazily when the skill screen opens. + @Published var skillCatalog: [MobileSkillPlugin] = [] + @Published var skillCatalogLoading = false + @Published var skillCatalogError: String? + @Published var skillSources: [MobileSkillSource] = [] + /// Plugin ids with an in-flight install/uninstall request — drives per-row + /// spinners. + @Published var inFlightSkillMutations: Set = [] + @Published var inFlightSkillSourceMutations: Set = [] + @Published var lastSkillError: String? + /// The latest catalog request id, so a stale reply is discarded. + private var pendingSkillCatalogRequestID: UUID? + private var skillSourceMutationKeys: [UUID: String] = [:] + + // MARK: - Remote desktop config: ACP agent clients + + @Published var acpRegistryAgents: [MobileACPRegistryAgent] = [] + @Published var acpInstalledClients: [MobileACPClient] = [] + @Published var acpRegistryLoading = false + @Published var acpRegistryError: String? + /// Registry-agent ids or installed-client ids with an in-flight mutation. + @Published var inFlightACPMutations: Set = [] + @Published var lastACPError: String? + private var pendingACPRegistryRequestID: UUID? + /// Maps an ACP mutation request id to the identity key tracked in + /// `inFlightACPMutations`, so the result clears the right row. + private var acpMutationKeys: [UUID: String] = [:] + + // MARK: - Remote desktop config: MCP servers + + @Published var mcpServers: [MobileMCPServer] = [] + @Published var mcpConfigLoading = false + @Published var mcpConfigError: String? + /// Server names with an in-flight add/remove/toggle request. + @Published var inFlightMCPMutations: Set = [] + @Published var lastMCPError: String? + private var pendingMCPConfigRequestID: UUID? /// 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 = [] @@ -108,6 +149,13 @@ final class MobileAppState: ObservableObject { private var pendingSearchID: UUID? private var searchDebounceTask: Task? + /// Backing data for the thread "View Changes" sheet — thread file edits and + /// uncommitted git changes for one thread. Nil until first loaded; carries + /// its own `sessionID` so a stale result for another thread is ignored. + @Published var threadChanges: ThreadChangesResultPayload? + @Published var isLoadingThreadChanges: Bool = false + private var pendingThreadChangesID: UUID? + @Published var remoteFolderRoot: RemoteFolderNode? @Published var remoteFolderIsLoading = false @Published var remoteFolderError: String? @@ -671,6 +719,25 @@ final class MobileAppState: ObservableObject { } } + /// Requests the change overview (thread file edits + uncommitted git + /// changes) for `sessionID` from the desktop. The reply lands in + /// `threadChanges` via the `threadChangesResult` payload. + func requestThreadChanges(sessionID: String) async { + guard isPaired else { return } + let requestID = UUID() + pendingThreadChangesID = requestID + isLoadingThreadChanges = true + let payload = ThreadChangesRequestPayload(clientRequestID: requestID, sessionID: sessionID) + do { + try await client.send(.threadChangesRequest(payload), toHex: pairedDesktopPubkey) + } catch { + if pendingThreadChangesID == requestID { + pendingThreadChangesID = nil + isLoadingThreadChanges = false + } + } + } + func respondToPermission(allow: Bool, denyReason: String? = nil) async { guard let pending = pendingPermission else { return } let payload = PermissionResponsePayload( @@ -870,6 +937,37 @@ final class MobileAppState: ObservableObject { Task { await reportAPNsTokenIfPending() } } + // MARK: - Live Activity & widget + + /// Forward a Live Activity push token (a push-to-start token, a per-activity + /// update token, or both) to every paired desktop so it can drive the job + /// Live Activity over APNs. Called by `MobileLiveActivityCoordinator`. + func sendLiveActivityToken(_ payload: LiveActivityTokenPayload) async { + guard !pairedDesktops.isEmpty else { return } + for desktop in pairedDesktops { + do { + try await client.send(.liveActivityToken(payload), toHex: desktop.pubkeyHex) + logger.info("[LiveActivity] token reported startToken=\(payload.pushToStartTokenHex != nil, privacy: .public) activityToken=\(payload.activityTokenHex != nil, privacy: .public) startedLocally=\(payload.activityStartedLocally == true, privacy: .public) dismissed=\(payload.activityDismissed == true, privacy: .public) session=\(payload.sessionID ?? "", privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public)") + } catch { + logger.error("[LiveActivity] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Recompute the home-screen widget snapshot from the current mirrored + /// state and persist it into the shared App Group container. Cheap to call + /// often — `RxCodeWidgetStore` reloads WidgetKit timelines on a real change. + func refreshWidgetData() { + let jobCount = sessions.filter { $0.isStreaming }.count + let snapshot = RxCodeWidgetData( + jobCount: jobCount, + ccUsagePercent: desktopUsage?.claudeCode?.fiveHourPercent, + codexUsagePercent: desktopUsage?.codex?.fiveHourPercent, + updatedAt: Date().timeIntervalSince1970 + ) + RxCodeWidgetStore.save(snapshot) + } + /// Routes a tapped APNs notification to its thread. Called by `AppDelegate`'s /// `didReceive` handler; `RootView` observes `pendingDeepLink` and navigates. func openThreadFromNotification(sessionID: String, projectID: UUID?) { @@ -946,6 +1044,315 @@ final class MobileAppState: ObservableObject { activeSessionID = nil pendingPermission = nil pendingQuestions = [] + skillCatalog = [] + skillCatalogLoading = false + skillCatalogError = nil + skillSources = [] + inFlightSkillMutations = [] + inFlightSkillSourceMutations = [] + lastSkillError = nil + pendingSkillCatalogRequestID = nil + skillSourceMutationKeys = [:] + acpRegistryAgents = [] + acpInstalledClients = [] + acpRegistryLoading = false + acpRegistryError = nil + inFlightACPMutations = [] + lastACPError = nil + pendingACPRegistryRequestID = nil + acpMutationKeys = [:] + mcpServers = [] + mcpConfigLoading = false + mcpConfigError = nil + inFlightMCPMutations = [] + lastMCPError = nil + pendingMCPConfigRequestID = nil + } + + // MARK: - Remote desktop configuration + + /// Timeout after which a stuck remote config request is cleared and an + /// error surfaced. ACP installs download a binary, so they get longer. + private static let remoteConfigTimeout: Duration = .seconds(20) + private static let acpInstallTimeout: Duration = .seconds(90) + + /// Runs `perform` on the main actor after `timeout`. Callers use it to + /// expire a request that never received a reply (relay dropped, etc.). + private func scheduleTimeout( + _ timeout: Duration, + perform: @escaping (MobileAppState) -> Void + ) { + Task { [weak self] in + try? await Task.sleep(for: timeout) + guard let self else { return } + perform(self) + } + } + + // Skills + + func requestSkillCatalog(forceRefresh: Bool = false) async { + guard isPaired else { + skillCatalogError = "Connect a Mac to browse skills." + return + } + let payload = SkillCatalogRequestPayload(forceRefresh: forceRefresh) + pendingSkillCatalogRequestID = payload.clientRequestID + skillCatalogLoading = true + skillCatalogError = nil + do { + try await client.send(.skillCatalogRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingSkillCatalogRequestID == payload.clientRequestID { + s.pendingSkillCatalogRequestID = nil + s.skillCatalogLoading = false + s.skillCatalogError = "Request timed out. Check your Mac and try again." + } + } + } catch { + skillCatalogLoading = false + skillCatalogError = "Failed to request skills: \(error.localizedDescription)" + if pendingSkillCatalogRequestID == payload.clientRequestID { + pendingSkillCatalogRequestID = nil + } + } + } + + func installSkill(_ pluginID: String) async { + await mutateSkill(pluginID, operation: .install) + } + + func uninstallSkill(_ pluginID: String) async { + await mutateSkill(pluginID, operation: .uninstall) + } + + func addSkillGitSource(url: String, ref: String?) async { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { + lastSkillError = "Enter a GitHub repository URL." + return + } + let trimmedRef = ref?.trimmingCharacters(in: .whitespacesAndNewlines) + let key = "add:\(trimmedURL)" + await mutateSkillSource( + key: key, + operation: .add, + gitURL: trimmedURL, + ref: trimmedRef?.isEmpty == false ? trimmedRef : nil + ) + } + + func removeSkillGitSource(_ sourceID: String) async { + await mutateSkillSource(key: sourceID, operation: .remove, sourceID: sourceID) + } + + private func mutateSkill(_ pluginID: String, operation: SkillMutationRequestPayload.Operation) async { + guard isPaired else { + lastSkillError = "Connect a Mac first." + return + } + guard !inFlightSkillMutations.contains(pluginID) else { return } + let payload = SkillMutationRequestPayload(operation: operation, pluginID: pluginID) + inFlightSkillMutations.insert(pluginID) + lastSkillError = nil + do { + try await client.send(.skillMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.inFlightSkillMutations.remove(pluginID) != nil { + s.lastSkillError = "Request timed out. Check your Mac and try again." + } + } + } catch { + inFlightSkillMutations.remove(pluginID) + lastSkillError = "Failed to send request: \(error.localizedDescription)" + } + } + + private func mutateSkillSource( + key: String, + operation: SkillSourceMutationRequestPayload.Operation, + sourceID: String? = nil, + gitURL: String? = nil, + ref: String? = nil + ) async { + guard isPaired else { + lastSkillError = "Connect a Mac first." + return + } + guard !inFlightSkillSourceMutations.contains(key) else { return } + let payload = SkillSourceMutationRequestPayload( + operation: operation, + sourceID: sourceID, + gitURL: gitURL, + ref: ref + ) + inFlightSkillSourceMutations.insert(key) + skillSourceMutationKeys[payload.clientRequestID] = key + lastSkillError = nil + do { + try await client.send(.skillSourceMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if let key = s.skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) { + s.inFlightSkillSourceMutations.remove(key) + s.lastSkillError = "Request timed out. Check your Mac and try again." + } + } + } catch { + skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) + inFlightSkillSourceMutations.remove(key) + lastSkillError = "Failed to send request: \(error.localizedDescription)" + } + } + + // ACP agent clients + + func requestACPRegistry(forceRefresh: Bool = false) async { + guard isPaired else { + acpRegistryError = "Connect a Mac to manage agents." + return + } + let payload = ACPRegistryRequestPayload(forceRefresh: forceRefresh) + pendingACPRegistryRequestID = payload.clientRequestID + acpRegistryLoading = true + acpRegistryError = nil + do { + try await client.send(.acpRegistryRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingACPRegistryRequestID == payload.clientRequestID { + s.pendingACPRegistryRequestID = nil + s.acpRegistryLoading = false + s.acpRegistryError = "Request timed out. Check your Mac and try again." + } + } + } catch { + acpRegistryLoading = false + acpRegistryError = "Failed to request agents: \(error.localizedDescription)" + if pendingACPRegistryRequestID == payload.clientRequestID { + pendingACPRegistryRequestID = nil + } + } + } + + func installACPAgent(_ registryAgentID: String) async { + await mutateACP(operation: .install, key: registryAgentID, registryAgentID: registryAgentID) + } + + func uninstallACPClient(_ clientID: String) async { + await mutateACP(operation: .uninstall, key: clientID, clientID: clientID) + } + + func setACPClientEnabled(_ clientID: String, enabled: Bool) async { + await mutateACP(operation: .setEnabled, key: clientID, clientID: clientID, enabled: enabled) + } + + private func mutateACP( + operation: ACPMutationRequestPayload.Operation, + key: String, + registryAgentID: String? = nil, + clientID: String? = nil, + enabled: Bool? = nil + ) async { + guard isPaired else { + lastACPError = "Connect a Mac first." + return + } + guard !inFlightACPMutations.contains(key) else { return } + let payload = ACPMutationRequestPayload( + operation: operation, + registryAgentID: registryAgentID, + clientID: clientID, + enabled: enabled + ) + inFlightACPMutations.insert(key) + acpMutationKeys[payload.clientRequestID] = key + lastACPError = nil + let timeout = operation == .install ? Self.acpInstallTimeout : Self.remoteConfigTimeout + do { + try await client.send(.acpMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(timeout) { s in + if s.acpMutationKeys.removeValue(forKey: payload.clientRequestID) != nil { + s.inFlightACPMutations.remove(key) + s.lastACPError = "Request timed out. Check your Mac and try again." + } + } + } catch { + acpMutationKeys.removeValue(forKey: payload.clientRequestID) + inFlightACPMutations.remove(key) + lastACPError = "Failed to send request: \(error.localizedDescription)" + } + } + + // MCP servers + + func requestMCPConfig() async { + guard isPaired else { + mcpConfigError = "Connect a Mac to manage MCP servers." + return + } + let payload = MCPConfigRequestPayload() + pendingMCPConfigRequestID = payload.clientRequestID + mcpConfigLoading = true + mcpConfigError = nil + do { + try await client.send(.mcpConfigRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingMCPConfigRequestID == payload.clientRequestID { + s.pendingMCPConfigRequestID = nil + s.mcpConfigLoading = false + s.mcpConfigError = "Request timed out. Check your Mac and try again." + } + } + } catch { + mcpConfigLoading = false + mcpConfigError = "Failed to request MCP servers: \(error.localizedDescription)" + if pendingMCPConfigRequestID == payload.clientRequestID { + pendingMCPConfigRequestID = nil + } + } + } + + func addMCPServer(_ server: MobileMCPServer) async { + await mutateMCP(operation: .add, serverName: server.name, server: server) + } + + func removeMCPServer(_ serverName: String) async { + await mutateMCP(operation: .remove, serverName: serverName) + } + + func setMCPServerEnabled(_ serverName: String, enabled: Bool) async { + await mutateMCP(operation: .setEnabled, serverName: serverName, enabled: enabled) + } + + private func mutateMCP( + operation: MCPMutationRequestPayload.Operation, + serverName: String, + server: MobileMCPServer? = nil, + enabled: Bool? = nil + ) async { + guard isPaired else { + lastMCPError = "Connect a Mac first." + return + } + guard !inFlightMCPMutations.contains(serverName) else { return } + let payload = MCPMutationRequestPayload( + operation: operation, + serverName: serverName, + server: server, + enabled: enabled + ) + inFlightMCPMutations.insert(serverName) + lastMCPError = nil + do { + try await client.send(.mcpMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.inFlightMCPMutations.remove(serverName) != nil { + s.lastMCPError = "Request timed out. Check your Mac and try again." + } + } + } catch { + inFlightMCPMutations.remove(serverName) + lastMCPError = "Failed to send request: \(error.localizedDescription)" + } } // MARK: - Inbound events @@ -1047,12 +1454,14 @@ final class MobileAppState: ObservableObject { } activeSessionID = active } + refreshWidgetData() case .moreMessages(let page): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "more_messages") else { return } applyMoreMessages(page) case .sessionUpdate(let update): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "session_update") else { return } applySessionUpdate(update) + refreshWidgetData() case .permissionRequest(let req): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "permission_request") else { return } pendingPermission = req @@ -1073,6 +1482,12 @@ final class MobileAppState: ObservableObject { searchProjectIDs = results.projectIDs searchThreadHits = results.threadHits isSearching = false + case .threadChangesResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "thread_changes_result") else { return } + guard let pending = pendingThreadChangesID, result.clientRequestID == pending else { return } + pendingThreadChangesID = nil + isLoadingThreadChanges = false + threadChanges = result case .branchOpResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "branch_op_result") else { return } inFlightBranchOps.remove(result.clientRequestID) @@ -1112,6 +1527,27 @@ final class MobileAppState: ObservableObject { case .runTaskUpdate(let update): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_task_update") else { return } upsertRunTask(update.task) + case .skillCatalogResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_catalog_result") else { return } + applySkillCatalogResult(result) + case .skillMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_mutation_result") else { return } + applySkillMutationResult(result) + case .skillSourceMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_source_mutation_result") else { return } + applySkillSourceMutationResult(result) + case .acpRegistryResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_registry_result") else { return } + applyACPRegistryResult(result) + case .acpMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_mutation_result") else { return } + applyACPMutationResult(result) + case .mcpConfigResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_config_result") else { return } + applyMCPConfigResult(result) + case .mcpMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_mutation_result") else { return } + applyMCPMutationResult(result) case .ping: guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } @@ -1137,6 +1573,94 @@ final class MobileAppState: ObservableObject { } } + private func applySkillCatalogResult(_ result: SkillCatalogResultPayload) { + guard pendingSkillCatalogRequestID == result.clientRequestID else { return } + pendingSkillCatalogRequestID = nil + skillCatalogLoading = false + if result.ok { + skillCatalog = result.plugins + skillSources = result.sources + skillCatalogError = nil + } else { + skillCatalogError = result.errorMessage ?? "Failed to load skills." + } + } + + private func applySkillMutationResult(_ result: SkillMutationResultPayload) { + inFlightSkillMutations.remove(result.pluginID) + skillCatalog = result.plugins + skillSources = result.sources + if result.ok { + lastSkillError = nil + } else { + lastSkillError = result.errorMessage ?? "Skill operation failed." + } + } + + private func applySkillSourceMutationResult(_ result: SkillSourceMutationResultPayload) { + if let key = skillSourceMutationKeys.removeValue(forKey: result.clientRequestID) { + inFlightSkillSourceMutations.remove(key) + } + if let sourceID = result.sourceID { + inFlightSkillSourceMutations.remove(sourceID) + } + skillCatalog = result.plugins + skillSources = result.sources + if result.ok { + lastSkillError = nil + } else { + lastSkillError = result.errorMessage ?? "Skill source operation failed." + } + } + + private func applyACPRegistryResult(_ result: ACPRegistryResultPayload) { + guard pendingACPRegistryRequestID == result.clientRequestID else { return } + pendingACPRegistryRequestID = nil + acpRegistryLoading = false + if result.ok { + acpRegistryAgents = result.registryAgents + acpInstalledClients = result.installedClients + acpRegistryError = nil + } else { + acpRegistryError = result.errorMessage ?? "Failed to load the agent registry." + } + } + + private func applyACPMutationResult(_ result: ACPMutationResultPayload) { + if let key = acpMutationKeys.removeValue(forKey: result.clientRequestID) { + inFlightACPMutations.remove(key) + } + acpRegistryAgents = result.registryAgents + acpInstalledClients = result.installedClients + if result.ok { + lastACPError = nil + } else { + lastACPError = result.errorMessage ?? "Agent operation failed." + } + } + + private func applyMCPConfigResult(_ result: MCPConfigResultPayload) { + guard pendingMCPConfigRequestID == result.clientRequestID else { return } + pendingMCPConfigRequestID = nil + mcpConfigLoading = false + if result.ok { + mcpServers = result.servers + mcpConfigError = nil + } else { + mcpConfigError = result.errorMessage ?? "Failed to load MCP servers." + } + } + + private func applyMCPMutationResult(_ result: MCPMutationResultPayload) { + inFlightMCPMutations.remove(result.serverName) + mcpServers = result.servers + if result.ok { + lastMCPError = nil + } else { + lastMCPError = result.errorMessage ?? "MCP operation failed." + } + } + private func upsertRunTask(_ task: MobileRunTaskSnapshot) { if let idx = runTasks.firstIndex(where: { $0.taskId == task.taskId }) { runTasks[idx] = task diff --git a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift new file mode 100644 index 0000000..9e1d4fc --- /dev/null +++ b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift @@ -0,0 +1,316 @@ +// +// MobileLiveActivityCoordinator.swift +// RxCodeMobile +// +// Owns the iOS side of the aggregate job Live Activity push lifecycle. +// +// RxCode shows a *single* Live Activity per device that aggregates every +// in-progress agent job. One activity — no matter how many jobs run — keeps +// the app well within the scarce iOS push-to-start budget. +// +// The paired desktop is what actually starts, updates, and ends the activity +// over APNs (see `MobileSyncService`). This coordinator harvests the two +// kinds of ActivityKit push token and forwards them to the desktop: +// +// 1. `Activity.pushToStartTokenUpdates` — the device-wide push-to-start +// token (iOS 17.2+) that lets the desktop spawn the activity remotely. +// 2. The activity's `pushTokenUpdates` — the update token the desktop +// targets for `update` pushes. +// +// While the app is foregrounded the activity is instead started locally with +// `Activity.request`, which spends no push-to-start budget. +// + +#if os(iOS) +import ActivityKit +import Combine +import Foundation +import RxCodeCore +import RxCodeSync +import UIKit +import os.log + +@MainActor +final class MobileLiveActivityCoordinator { + private weak var state: MobileAppState? + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "LiveActivity") + + /// Latest device-wide push-to-start token. + private var startTokenHex: String? + /// `Activity.id` of the single aggregate activity, once it exists. + private var currentActivityID: String? + /// Latest per-activity update token for the aggregate activity. + private var activityTokenHex: String? + /// `true` once this app started the aggregate activity locally; cleared + /// when the activity is dismissed. + private var locallyStarted = false + /// Activity ids this coordinator is ending on purpose to clear a + /// duplicate. Their `.ended` state must not be reported to the desktop as + /// a user dismissal — the device keeps its surviving activity. + private var intentionallyEndedActivityIDs: Set = [] + private var observationStarted = false + private var cancellables = Set() + + /// Wire the coordinator to the app state. Safe to call once; later calls + /// are ignored. Starts ActivityKit token observation and re-reports tokens + /// whenever the relay reconnects. + func bind(state: MobileAppState) { + self.state = state + guard !observationStarted else { return } + observationStarted = true + startObserving() + state.$connectionState + .receive(on: DispatchQueue.main) + .sink { [weak self] connectionState in + if case .connected = connectionState { + self?.resendTokens() + } + } + .store(in: &cancellables) + // When the app is in the foreground the activity can be started + // locally with `Activity.request` — no push-to-start budget. Watch the + // mirrored session list and start it as soon as the first job begins; + // backgrounded jobs still rely on the desktop's push-to-start. + state.$sessions + .receive(on: DispatchQueue.main) + .sink { [weak self] sessions in + self?.startActivityIfNeeded(for: sessions) + } + .store(in: &cancellables) + } + + private func startObserving() { + guard #available(iOS 16.1, *) else { + logger.info("[LiveActivity] iOS < 16.1 — Live Activities unavailable") + return + } + // ActivityKit silently drops a push-to-start when Live Activities are + // disabled for the app — log the authorization state so a missing + // activity can be told apart from a malformed push. + let authInfo = ActivityAuthorizationInfo() + if #available(iOS 17.2, *) { + logger.info("[LiveActivity] start observing — activitiesEnabled=\(authInfo.areActivitiesEnabled, privacy: .public) frequentPushesEnabled=\(authInfo.frequentPushesEnabled, privacy: .public)") + } else { + logger.info("[LiveActivity] start observing — activitiesEnabled=\(authInfo.areActivitiesEnabled, privacy: .public)") + } + if !authInfo.areActivitiesEnabled { + logger.warning("[LiveActivity] Live Activities are DISABLED in Settings — iOS will drop the desktop's push-to-start; enable them under Settings ▸ RxCode") + } + // Push-to-start token: device-wide, iOS 17.2+. + if #available(iOS 17.2, *) { + Task { [weak self] in + for await tokenData in Activity.pushToStartTokenUpdates { + let hex = tokenData.map { String(format: "%02x", $0) }.joined() + self?.handleStartToken(hex) + } + } + } else { + logger.warning("[LiveActivity] iOS < 17.2 — push-to-start unavailable; the desktop cannot spawn the activity remotely") + } + // Re-attach to an activity already running (e.g. after a relaunch), + // then pick up future ones as ActivityKit reports them. + let existing = Activity.activities + logger.info("[LiveActivity] re-attaching to \(existing.count, privacy: .public) existing activity(ies)") + for activity in existing { + observe(activity) + } + if #available(iOS 16.2, *) { endExtraActivities() } + Task { [weak self] in + for await activity in Activity.activityUpdates { + self?.logger.info("[LiveActivity] activityUpdates reported activity id=\(activity.id, privacy: .public) — push-to-start spawned the activity") + self?.observe(activity) + if #available(iOS 16.2, *) { self?.endExtraActivities() } + } + } + } + + @available(iOS 16.1, *) + private func observe(_ activity: Activity) { + currentActivityID = activity.id + logger.info("[LiveActivity] observing aggregate activity id=\(activity.id, privacy: .public)") + Task { [weak self] in + for await tokenData in activity.pushTokenUpdates { + let hex = tokenData.map { String(format: "%02x", $0) }.joined() + self?.handleActivityToken(activity: activity, hex: hex) + } + } + // The desktop reuses the activity and never ends it itself, so the + // only way it goes away is the user dismissing it. Report that so the + // desktop forgets the activity and the next job push-to-starts a fresh + // one instead of pushing to a dead token. + Task { [weak self] in + for await activityState in activity.activityStateUpdates { + if activityState == .dismissed || activityState == .ended { + self?.handleActivityDismissed(activity, activityState: activityState) + break + } + } + } + } + + private func handleStartToken(_ hex: String) { + guard startTokenHex != hex else { return } + startTokenHex = hex + logger.info("[LiveActivity] push-to-start token \(hex.prefix(8), privacy: .public)…") + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload(pushToStartTokenHex: hex)) + } + } + + @available(iOS 16.1, *) + private func handleActivityToken(activity: Activity, hex: String) { + guard activityTokenHex != hex else { return } + activityTokenHex = hex + currentActivityID = activity.id + logger.info("[LiveActivity] aggregate activity token id=\(activity.id, privacy: .public) \(hex.prefix(8), privacy: .public)…") + let activityID = activity.id + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityTokenHex: hex, activityID: activityID + )) + } + } + + /// The user dismissed (or the system ended) the activity. Drop our local + /// token and tell the desktop so it forgets the activity — the next job + /// will then push-to-start a fresh one. + @available(iOS 16.1, *) + private func handleActivityDismissed( + _ activity: Activity, + activityState: ActivityState + ) { + let activityID = activity.id + if intentionallyEndedActivityIDs.remove(activityID) != nil { + // We ended this activity ourselves to clear a duplicate; the + // surviving activity still stands, so this is not a user dismissal + // and must not be reported to the desktop. + logger.info("[LiveActivity] duplicate activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) — not reporting as dismissal") + return + } + if currentActivityID == activityID { + currentActivityID = nil + activityTokenHex = nil + locallyStarted = false + } + logger.info("[LiveActivity] aggregate activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) — reporting dismissal to desktop") + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityID: activityID, activityDismissed: true + )) + } + } + + // MARK: - Foreground local start + + /// Start the aggregate Live Activity if jobs are running and it does not + /// exist yet — but only while the app is in the foreground, where + /// `Activity.request` works without spending the push-to-start budget. + private func startActivityIfNeeded(for sessions: [SessionSummary]) { + guard #available(iOS 16.2, *) else { return } + guard UIApplication.shared.applicationState == .active else { return } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + // One aggregate activity total — nothing to do if it already exists. + guard Activity.activities.isEmpty, !locallyStarted else { + return + } + let running = sessions.filter(\.isStreaming) + guard !running.isEmpty else { return } + startActivityLocally(running: running) + } + + /// Create the activity in-process and observe it so its push token reaches + /// the desktop, which then drives updates exactly as for a pushed activity. + @available(iOS 16.2, *) + private func startActivityLocally(running: [SessionSummary]) { + let contentState = makeContentState(running: running) + do { + let activity = try Activity.request( + attributes: RxCodeJobActivityAttributes(), + content: ActivityContent( + state: contentState, staleDate: Date().addingTimeInterval(3600) + ), + pushType: .token + ) + locallyStarted = true + currentActivityID = activity.id + logger.info("[LiveActivity] started aggregate activity locally id=\(activity.id, privacy: .public) jobs=\(running.count, privacy: .public) — foreground, no push-to-start needed") + // Tell the desktop right now — before the per-activity push token, + // which APNs can take several seconds to mint — so it cancels the + // deferred push-to-start and never spawns a duplicate activity. + let activityID = activity.id + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityID: activityID, activityStartedLocally: true + )) + } + observe(activity) + endExtraActivities() + } catch { + logger.error("[LiveActivity] local start failed: \(error.localizedDescription, privacy: .public) — desktop push-to-start will cover it") + } + } + + /// Build an aggregate content-state from the currently streaming sessions. + /// The desktop reconciles it with the full picture (including finished + /// jobs) the moment the per-activity token registers. + private func makeContentState(running: [SessionSummary]) -> RxCodeJobActivityAttributes.ContentState { + let jobs = running.map { session in + RxCodeJobActivityAttributes.ContentState.Job( + id: session.id, + phase: .running, + title: session.title, + projectName: state?.projects.first { $0.id == session.projectId }?.name ?? "", + todoDone: session.progress?.done ?? 0, + todoTotal: session.progress?.total ?? 0, + currentStep: session.todos?.first { $0.status == .inProgress }?.activeForm + ) + } + return RxCodeJobActivityAttributes.ContentState( + jobs: jobs, updatedAt: Date().timeIntervalSince1970 + ) + } + + /// Ensure at most one Live Activity exists. iOS can still spawn a second + /// one — the desktop's push-to-start travels over APNs, not the relay, so + /// it can race a foreground local start when the relay briefly drops — so + /// whenever the activity set changes, end every extra activity. + @available(iOS 16.2, *) + private func endExtraActivities() { + let activities = Activity.activities + guard activities.count > 1 else { return } + let keepID = activities.first { $0.id == currentActivityID }?.id ?? activities[0].id + currentActivityID = keepID + for extra in activities where extra.id != keepID { + let extraID = extra.id + logger.warning("[LiveActivity] ending duplicate activity id=\(extraID, privacy: .public) — keeping id=\(keepID, privacy: .public)") + intentionallyEndedActivityIDs.insert(extraID) + Task { await extra.end(nil, dismissalPolicy: .immediate) } + } + } + + /// Re-send every token we hold. Called when the relay reconnects so the + /// desktop's per-device token registry survives a disconnect. + private func resendTokens() { + if let startTokenHex { + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload(pushToStartTokenHex: startTokenHex)) + } + } + if let activityTokenHex, let currentActivityID { + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityTokenHex: activityTokenHex, activityID: currentActivityID + )) + } + } else if locallyStarted, let currentActivityID { + // Local start whose per-activity token has not been minted yet — + // re-assert it so a reconnect still suppresses the push-to-start. + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityID: currentActivityID, activityStartedLocally: true + )) + } + } + } +} +#endif diff --git a/RxCodeMobile/Views/MobileACPClientsView.swift b/RxCodeMobile/Views/MobileACPClientsView.swift new file mode 100644 index 0000000..be1df58 --- /dev/null +++ b/RxCodeMobile/Views/MobileACPClientsView.swift @@ -0,0 +1,180 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// Manage ACP agent clients on the paired desktop: toggle/remove installed +/// clients and install new ones from the agentclientprotocol.com registry. +struct MobileACPClientsView: View { + @EnvironmentObject private var state: MobileAppState + @State private var pendingUninstall: MobileACPClient? + + var body: some View { + List { + if let error = state.acpRegistryError { + errorRow(error) + } + if let error = state.lastACPError { + errorRow(error) + } + + installedSection + registrySection + } + .navigationTitle("Agent Clients") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if state.acpRegistryLoading { + ProgressView() + } + } + } + .refreshable { + await state.requestACPRegistry(forceRefresh: true) + } + .task { + if state.acpRegistryAgents.isEmpty, state.acpInstalledClients.isEmpty { + await state.requestACPRegistry() + } + } + .alert( + "Remove agent?", + isPresented: Binding( + get: { pendingUninstall != nil }, + set: { if !$0 { pendingUninstall = nil } } + ) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let client = pendingUninstall { + Task { await state.uninstallACPClient(client.id) } + } + pendingUninstall = nil + } + } message: { + if let client = pendingUninstall { + Text("This removes \(client.displayName) and its downloaded binary from your Mac.") + } + } + } + + @ViewBuilder + private var installedSection: some View { + if !state.acpInstalledClients.isEmpty { + Section("Installed") { + ForEach(state.acpInstalledClients) { client in + installedRow(client) + } + } + } + } + + private func installedRow(_ client: MobileACPClient) -> some View { + HStack(spacing: 12) { + agentIcon(client.iconURL) + VStack(alignment: .leading, spacing: 2) { + Text(client.displayName).font(.headline) + Text("\(client.launchKind) · \(client.modelCount) model\(client.modelCount == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if state.inFlightACPMutations.contains(client.id) { + ProgressView() + } else { + Toggle("", isOn: Binding( + get: { client.enabled }, + set: { value in Task { await state.setACPClientEnabled(client.id, enabled: value) } } + )) + .labelsHidden() + } + } + .swipeActions { + Button("Remove", role: .destructive) { + pendingUninstall = client + } + } + } + + @ViewBuilder + private var registrySection: some View { + let available = state.acpRegistryAgents.filter { !$0.isInstalled } + if !available.isEmpty { + Section("Available in Registry") { + ForEach(available) { agent in + registryRow(agent) + } + } + } else if state.acpInstalledClients.isEmpty, + !state.acpRegistryLoading, + state.acpRegistryError == nil { + Section { + Text("No agents found.").foregroundStyle(.secondary) + } + } + } + + private func registryRow(_ agent: MobileACPRegistryAgent) -> some View { + HStack(spacing: 12) { + agentIcon(agent.iconURL) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(agent.name).font(.headline) + Text(agent.version).font(.caption).foregroundStyle(.tertiary) + } + if !agent.summary.isEmpty { + Text(agent.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + if let dist = distributionLabel(agent) { + Text(dist).font(.caption2).foregroundStyle(.tertiary) + } + } + Spacer() + if state.inFlightACPMutations.contains(agent.id) { + VStack(spacing: 2) { + ProgressView() + Text("Installing…").font(.caption2).foregroundStyle(.secondary) + } + } else { + Button("Install") { + Task { await state.installACPAgent(agent.id) } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(.vertical, 2) + } + + private func distributionLabel(_ agent: MobileACPRegistryAgent) -> String? { + var kinds: [String] = [] + if agent.hasBinary { kinds.append("binary") } + if agent.hasNpx { kinds.append("npx") } + if agent.hasUvx { kinds.append("uvx") } + return kinds.isEmpty ? nil : kinds.joined(separator: " · ") + } + + private func agentIcon(_ urlString: String?) -> some View { + Group { + if let urlString, let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image.resizable().scaledToFit() + } placeholder: { + Image(systemName: "cpu").foregroundStyle(.secondary) + } + } else { + Image(systemName: "cpu").foregroundStyle(.secondary) + } + } + .frame(width: 28, height: 28) + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } +} diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index d9ddc79..0481f85 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -2,6 +2,7 @@ import SwiftUI import RxCodeCore import RxCodeChatKit import RxCodeSync +import TipKit struct BriefingGroupKey: Hashable { let projectId: UUID @@ -48,6 +49,7 @@ struct MobileBriefingView: View { BriefingCard( group: group, projectName: projectsById[group.projectId]?.name ?? "Unknown Project", + activeJobCount: activeJobCountByProject[group.projectId] ?? 0, namespace: glassNamespace ) } @@ -64,6 +66,7 @@ struct MobileBriefingView: View { .scrollContentBackground(.hidden) } .navigationTitle("Briefing") + .popoverTip(MobileTips.BriefingTip(), arrowEdge: .top) .toolbar { if hasAnyData { ToolbarItem(placement: .topBarTrailing) { @@ -121,6 +124,17 @@ struct MobileBriefingView: View { Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) } + /// Number of active (streaming) jobs per project. `SessionSummary` only + /// carries `projectId`, not a branch, so the count is project-scoped and + /// every branch card for a project shares it. + private var activeJobCountByProject: [UUID: Int] { + var counts: [UUID: Int] = [:] + for session in state.sessions where session.isStreaming { + counts[session.projectId, default: 0] += 1 + } + return counts + } + /// Every briefing group before the project/branch filters are applied. private var allGroups: [GroupedBriefing] { groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) @@ -251,6 +265,7 @@ struct MobileBriefingView: View { private struct BriefingCard: View { let group: GroupedBriefing let projectName: String + let activeJobCount: Int let namespace: Namespace.ID private var threadCount: Int { group.threads.count } @@ -310,6 +325,11 @@ private struct BriefingCard: View { // Footer with metadata HStack(spacing: 12) { + // Active jobs chip — pulses while jobs are running + if activeJobCount > 0 { + ActiveJobsChip(count: activeJobCount) + } + // Thread count chip if threadCount > 0 { HStack(spacing: 4) { @@ -323,7 +343,7 @@ private struct BriefingCard: View { .padding(.vertical, 4) .background(.ultraThinMaterial, in: Capsule()) } - + Spacer(minLength: 0) // Time ago @@ -364,6 +384,38 @@ private struct BriefingCard: View { } } +// MARK: - Active Jobs Chip + +/// Footer chip showing how many jobs are actively streaming for a project. +/// The dot pulses to convey live progress. +private struct ActiveJobsChip: View { + let count: Int + + @State private var isPulsing = false + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + .opacity(isPulsing ? 0.35 : 1) + .scaleEffect(isPulsing ? 0.85 : 1) + Text("\(count) active") + .font(.caption.weight(.medium)) + } + .foregroundStyle(.green) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + .accessibilityLabel("\(count) active jobs") + } +} + func groupBriefings( briefings: [MobileBranchBriefing], threads: [MobileThreadSummary] diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 95967ec..b5176db 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -22,6 +22,7 @@ struct MobileChatView: View { @State private var showingTodoSheet = false @State private var showingRunProfiles = false @State private var showingBrowser = false + @State private var showingChanges = false /// The question request whose sheet is currently presented, if any. @State private var presentedQuestion: PendingQuestionPayload? /// The plan whose review sheet is currently presented, if any. @@ -94,6 +95,12 @@ struct MobileChatView: View { } } } + .sheet(isPresented: $showingChanges) { + ThreadChangesSheet(sessionID: sessionID) + .environmentObject(state) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .fullScreenCover(isPresented: $showingBrowser) { NavigationStack { MobileInAppBrowserView( @@ -194,6 +201,11 @@ struct MobileChatView: View { Label("Run Profiles", systemImage: "play.rectangle") } .disabled(currentProjectID == nil) + Button { + showingChanges = true + } label: { + Label("View Changes", systemImage: "plus.forwardslash.minus") + } Divider() Button { showingRenameSheet = true diff --git a/RxCodeMobile/Views/MobileMCPServersView.swift b/RxCodeMobile/Views/MobileMCPServersView.swift new file mode 100644 index 0000000..fa9e7f9 --- /dev/null +++ b/RxCodeMobile/Views/MobileMCPServersView.swift @@ -0,0 +1,308 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// Manage the global MCP servers configured on the paired desktop. +struct MobileMCPServersView: View { + @EnvironmentObject private var state: MobileAppState + @State private var editing: MobileMCPServer? + @State private var showingAdd = false + @State private var pendingRemoval: MobileMCPServer? + + var body: some View { + List { + if let error = state.mcpConfigError { + errorRow(error) + } + if let error = state.lastMCPError { + errorRow(error) + } + + if state.mcpServers.isEmpty { + emptyOrLoadingRow + } else { + ForEach(state.mcpServers) { server in + row(server) + } + } + } + .navigationTitle("MCP Servers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAdd = true + } label: { + Image(systemName: "plus") + } + } + } + .refreshable { + await state.requestMCPConfig() + } + .task { + if state.mcpServers.isEmpty { + await state.requestMCPConfig() + } + } + .sheet(isPresented: $showingAdd) { + MobileMCPServerFormView(existing: nil) + .environmentObject(state) + } + .sheet(item: $editing) { server in + MobileMCPServerFormView(existing: server) + .environmentObject(state) + } + .alert( + "Remove MCP server?", + isPresented: Binding( + get: { pendingRemoval != nil }, + set: { if !$0 { pendingRemoval = nil } } + ) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let server = pendingRemoval { + Task { await state.removeMCPServer(server.name) } + } + pendingRemoval = nil + } + } message: { + if let server = pendingRemoval { + Text("This removes \(server.name) from your Mac's global MCP configuration.") + } + } + } + + private func row(_ server: MobileMCPServer) -> some View { + Button { + editing = server + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(server.name) + .font(.headline) + .foregroundStyle(.primary) + Text(server.transport.uppercased()) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15), in: Capsule()) + .foregroundStyle(.secondary) + } + if !server.endpoint.isEmpty { + Text(server.endpoint) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + if state.inFlightMCPMutations.contains(server.name) { + ProgressView() + } else { + Toggle("", isOn: Binding( + get: { server.isGloballyEnabled }, + set: { value in Task { await state.setMCPServerEnabled(server.name, enabled: value) } } + )) + .labelsHidden() + } + } + } + .swipeActions { + Button("Remove", role: .destructive) { + pendingRemoval = server + } + } + } + + @ViewBuilder + private var emptyOrLoadingRow: some View { + if state.mcpConfigLoading { + HStack(spacing: 8) { + ProgressView() + Text("Loading…").foregroundStyle(.secondary) + } + } else if state.mcpConfigError == nil { + Text("No MCP servers configured. Tap + to add one.") + .foregroundStyle(.secondary) + } + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } +} + +/// Add or edit a global MCP server. The desktop upserts by name, so editing an +/// existing server reuses the add path; the name field is locked when editing. +struct MobileMCPServerFormView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + + let existing: MobileMCPServer? + + @State private var name = "" + @State private var transport = "stdio" + @State private var command = "" + @State private var url = "" + @State private var args: [DraftField] = [] + @State private var env: [DraftPair] = [] + @State private var headers: [DraftPair] = [] + + private struct DraftField: Identifiable { + let id = UUID() + var value: String + } + + private struct DraftPair: Identifiable { + let id = UUID() + var key: String + var value: String + } + + private var isStdio: Bool { transport == "stdio" } + + private var canSave: Bool { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if isStdio { + return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("Name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .disabled(existing != nil) + Picker("Transport", selection: $transport) { + Text("stdio").tag("stdio") + Text("HTTP").tag("http") + Text("SSE").tag("sse") + } + } + + if isStdio { + Section("Command") { + TextField("Command", text: $command) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + Section("Arguments") { + ForEach($args) { $field in + TextField("Argument", text: $field.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + .onDelete { args.remove(atOffsets: $0) } + Button { + args.append(DraftField(value: "")) + } label: { + Label("Add Argument", systemImage: "plus") + } + } + keyValueSection(title: "Environment Variables", items: $env, addLabel: "Add Variable") + } else { + Section("Endpoint") { + TextField("URL", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + } + keyValueSection(title: "Headers", items: $headers, addLabel: "Add Header") + } + } + .navigationTitle(existing == nil ? "Add MCP Server" : "Edit MCP Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(!canSave) + } + } + .onAppear(perform: load) + } + } + + @ViewBuilder + private func keyValueSection( + title: String, + items: Binding<[DraftPair]>, + addLabel: String + ) -> some View { + Section(title) { + ForEach(items) { $pair in + HStack(spacing: 8) { + TextField("Key", text: $pair.key) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + Divider() + TextField("Value", text: $pair.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + .onDelete { items.wrappedValue.remove(atOffsets: $0) } + Button { + items.wrappedValue.append(DraftPair(key: "", value: "")) + } label: { + Label(addLabel, systemImage: "plus") + } + } + } + + private func load() { + guard let existing else { return } + name = existing.name + transport = existing.transport + command = existing.command ?? "" + url = existing.url ?? "" + args = existing.args.map { DraftField(value: $0) } + env = existing.env.map { DraftPair(key: $0.key, value: $0.value) } + headers = existing.headers.map { DraftPair(key: $0.key, value: $0.value) } + } + + private func save() { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanArgs = args + .map { $0.value.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let cleanEnv = env.compactMap { pair -> MobileMCPKeyValue? in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : MobileMCPKeyValue(key: key, value: pair.value) + } + let cleanHeaders = headers.compactMap { pair -> MobileMCPKeyValue? in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : MobileMCPKeyValue(key: key, value: pair.value) + } + let endpoint = isStdio + ? ([trimmedCommand] + cleanArgs).filter { !$0.isEmpty }.joined(separator: " ") + : trimmedURL + + let server = MobileMCPServer( + name: trimmedName, + transport: transport, + url: isStdio ? nil : trimmedURL, + command: isStdio ? trimmedCommand : nil, + args: isStdio ? cleanArgs : [], + env: isStdio ? cleanEnv : [], + headers: isStdio ? [] : cleanHeaders, + isGloballyEnabled: existing?.isGloballyEnabled ?? true, + endpoint: endpoint + ) + Task { await state.addMCPServer(server) } + dismiss() + } +} diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 3dfc240..449ca64 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -1,6 +1,7 @@ import RxCodeCore import RxCodeSync import SwiftUI +import TipKit struct MobileSettingsView: View { @EnvironmentObject private var state: MobileAppState @@ -34,6 +35,10 @@ struct MobileSettingsView: View { } } + if state.isPaired { + desktopConfigurationSection + } + pairNewSection } .navigationTitle("Settings") @@ -149,6 +154,33 @@ struct MobileSettingsView: View { } } + /// Links to the remote desktop-management screens: skill marketplace, ACP + /// agent clients, and MCP servers. Shown only while paired since every + /// action targets the active Mac. + private var desktopConfigurationSection: some View { + Section { + NavigationLink { + MobileSkillMarketView() + } label: { + Label("Skills", systemImage: "puzzlepiece.extension") + } + NavigationLink { + MobileACPClientsView() + } label: { + Label("Agent Clients", systemImage: "cpu") + } + NavigationLink { + MobileMCPServersView() + } label: { + Label("MCP Servers", systemImage: "server.rack") + } + } header: { + Text("Desktop Configuration") + } footer: { + Text("Install skills and agents, and configure MCP servers on the active Mac.") + } + } + private var pairNewSection: some View { Section { Button { @@ -156,6 +188,7 @@ struct MobileSettingsView: View { } label: { Label("Pair New Mac", systemImage: "plus.circle") } + .popoverTip(MobileTips.PairingTip(), arrowEdge: .top) } } @@ -333,6 +366,7 @@ struct MobileSettingsView: View { } } .pickerStyle(.menu) + .popoverTip(MobileTips.SummarizationTip(), arrowEdge: .top) if settings.summarizationProvider == "openAI" { if !settings.openAISummarizationEndpoint.isEmpty { diff --git a/RxCodeMobile/Views/MobileSkillMarketView.swift b/RxCodeMobile/Views/MobileSkillMarketView.swift new file mode 100644 index 0000000..705d737 --- /dev/null +++ b/RxCodeMobile/Views/MobileSkillMarketView.swift @@ -0,0 +1,306 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// Browse the paired desktop's skill marketplace and install or remove skills +/// remotely. The catalog is fetched lazily when the screen first opens. +struct MobileSkillMarketView: View { + @EnvironmentObject private var state: MobileAppState + @State private var searchText = "" + @State private var selectedFilter = "All" + @State private var showingGitSourceSheet = false + + var body: some View { + List { + if let error = state.skillCatalogError { + errorRow(error) + } + if let error = state.lastSkillError { + errorRow(error) + } + + if state.skillCatalog.isEmpty { + emptyOrLoadingRow + } else if filteredPlugins.isEmpty { + Text("No skills found.").foregroundStyle(.secondary) + } else { + ForEach(groupedCategories, id: \.self) { category in + Section(category) { + ForEach(plugins(in: category)) { plugin in + row(plugin) + } + } + } + } + } + .navigationTitle("Skills") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search skills") + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + filterMenu + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showingGitSourceSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add Git Source") + } + ToolbarItem(placement: .topBarTrailing) { + if state.skillCatalogLoading { + ProgressView() + } + } + } + .refreshable { + await state.requestSkillCatalog(forceRefresh: true) + } + .task { + if state.skillCatalog.isEmpty { + await state.requestSkillCatalog() + } + } + .sheet(isPresented: $showingGitSourceSheet) { + MobileSkillGitSourceSheet() + .environmentObject(state) + } + } + + private var filteredPlugins: [MobileSkillPlugin] { + var plugins = state.skillCatalog + + if selectedFilter == "Installed" { + plugins = plugins.filter(\.isInstalled) + } else if selectedFilter != "All" { + plugins = plugins.filter { $0.marketplaceLabel == selectedFilter } + } + + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return plugins } + return plugins.filter { + $0.name.lowercased().contains(query) + || $0.summary.lowercased().contains(query) + || $0.categoryLabel.lowercased().contains(query) + || $0.marketplaceLabel.lowercased().contains(query) + } + } + + private var availableMarketplaces: [String] { + var counts: [String: Int] = [:] + for plugin in state.skillCatalog { + counts[plugin.marketplaceLabel, default: 0] += 1 + } + return counts.sorted { $0.value > $1.value }.map(\.key) + } + + private var filterMenu: some View { + Menu { + Button { + selectedFilter = "All" + } label: { + if selectedFilter == "All" { + Label("All", systemImage: "checkmark") + } else { + Text("All") + } + } + + Button { + selectedFilter = "Installed" + } label: { + if selectedFilter == "Installed" { + Label("Installed", systemImage: "checkmark") + } else { + Text("Installed") + } + } + + if !availableMarketplaces.isEmpty { + Section("Marketplaces") { + ForEach(availableMarketplaces, id: \.self) { label in + Button { + selectedFilter = label + } label: { + if selectedFilter == label { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + } + } + } label: { + Image(systemName: selectedFilter == "All" ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + } + .accessibilityLabel("Filter Skills") + } + + /// Distinct category labels in display order (alphabetical). + private var groupedCategories: [String] { + Set(filteredPlugins.map(\.categoryLabel)).sorted() + } + + private func plugins(in category: String) -> [MobileSkillPlugin] { + filteredPlugins + .filter { $0.categoryLabel == category } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + @ViewBuilder + private var emptyOrLoadingRow: some View { + if state.skillCatalogLoading { + HStack(spacing: 8) { + ProgressView() + Text("Loading skills…").foregroundStyle(.secondary) + } + } else if state.skillCatalogError == nil { + Text("No skills found.").foregroundStyle(.secondary) + } + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } + + private func row(_ plugin: MobileSkillPlugin) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(plugin.name).font(.headline) + Spacer() + control(plugin) + } + if !plugin.summary.isEmpty { + Text(plugin.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + Text(plugin.marketplaceLabel) + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } + + @ViewBuilder + private func control(_ plugin: MobileSkillPlugin) -> some View { + if state.inFlightSkillMutations.contains(plugin.id) { + ProgressView() + } else if plugin.isInstalled { + Button("Remove", role: .destructive) { + Task { await state.uninstallSkill(plugin.id) } + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button("Install") { + Task { await state.installSkill(plugin.id) } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } +} + +private struct MobileSkillGitSourceSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var gitURL = "" + @State private var ref = "" + + private var canAdd: Bool { + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !state.inFlightSkillSourceMutations.contains(addKey) + } + + private var addKey: String { + "add:\(gitURL.trimmingCharacters(in: .whitespacesAndNewlines))" + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("https://github.com/owner/repo", text: $gitURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + TextField("main", text: $ref) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } header: { + Text("Git Source") + } footer: { + Text("Use a GitHub repository that exposes .claude-plugin/marketplace.json.") + } + + if let error = state.lastSkillError { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } + } + + Section("Custom Sources") { + if state.skillSources.isEmpty { + Text("No custom Git sources added.") + .foregroundStyle(.secondary) + } else { + ForEach(state.skillSources) { source in + HStack { + Text(source.displayName) + .lineLimit(1) + Spacer() + if state.inFlightSkillSourceMutations.contains(source.id) { + ProgressView() + } else { + Button(role: .destructive) { + Task { await state.removeSkillGitSource(source.id) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel("Remove \(source.displayName)") + } + } + } + } + } + } + .navigationTitle("Git Sources") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let submittedKey = addKey + await state.addSkillGitSource(url: gitURL, ref: ref) + if state.inFlightSkillSourceMutations.contains(submittedKey) { + gitURL = "" + ref = "" + } + } + } label: { + if state.inFlightSkillSourceMutations.contains(addKey) { + ProgressView() + } else { + Text("Add") + } + } + .disabled(!canAdd) + } + } + } + } +} diff --git a/RxCodeMobile/Views/MobileTips.swift b/RxCodeMobile/Views/MobileTips.swift new file mode 100644 index 0000000..2a6019c --- /dev/null +++ b/RxCodeMobile/Views/MobileTips.swift @@ -0,0 +1,130 @@ +import SwiftUI +import TipKit + +enum MobileTips { + struct PairingTip: Tip { + var title: Text { + Text("Pair with your Mac") + } + + var message: Text? { + Text("Scan the QR code from RxCode Settings on your Mac to control threads and approvals from this device.") + } + + var image: Image? { + Image(systemName: "qrcode.viewfinder") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct BriefingTip: Tip { + var title: Text { + Text("Review project briefings") + } + + var message: Text? { + Text("Branch briefings collect recent thread summaries from your Mac into a quick mobile status view.") + } + + var image: Image? { + Image(systemName: "doc.text") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct SearchTip: Tip { + var title: Text { + Text("Search projects and threads") + } + + var message: Text? { + Text("Use mobile search to find synced projects, thread titles, and desktop search hits.") + } + + var image: Image? { + Image(systemName: "magnifyingglass") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct RemoteProjectTip: Tip { + var title: Text { + Text("Add a project from your Mac") + } + + var message: Text? { + Text("Pick a desktop folder remotely, then start or continue its threads from mobile.") + } + + var image: Image? { + Image(systemName: "folder.badge.plus") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct AgentSelectionTip: Tip { + var title: Text { + Text("Choose the thread agent") + } + + var message: Text? { + Text("Start a mobile-created thread with Claude Code, Codex, or an ACP agent synced from your Mac.") + } + + var image: Image? { + Image(systemName: "sparkles") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct PlanModeTip: Tip { + var title: Text { + Text("Start with a plan") + } + + var message: Text? { + Text("Enable Plan before sending a new thread so the desktop agent drafts a read-only plan first.") + } + + var image: Image? { + Image(systemName: "checklist") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct SummarizationTip: Tip { + var title: Text { + Text("Control desktop summaries") + } + + var message: Text? { + Text("Change the summarization provider from mobile; API keys and endpoint secrets stay on the Mac.") + } + + var image: Image? { + Image(systemName: "text.badge.checkmark") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } +} diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index 5d46edb..d5d58b4 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -2,6 +2,7 @@ import SwiftUI import UIKit import RxCodeCore import RxCodeSync +import TipKit /// Modal sheet for composing a new thread. Captures the prompt + config knobs /// (branch, model, permission mode), fires `requestNewSession` on submit, then @@ -72,7 +73,7 @@ struct NewThreadSheet: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .interactiveDismissDisabled(isSubmitting) + .interactiveDismissDisabled(true) .onAppear { seedConfigIfNeeded() DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { @@ -409,6 +410,7 @@ struct NewThreadConfigStrip: View { chipLabel(icon: "cpu", title: selectedModelLabel) } .disabled(allModels.isEmpty) + .popoverTip(MobileTips.AgentSelectionTip(), arrowEdge: .top) } private func applyModel(_ model: AgentModel) { @@ -460,6 +462,7 @@ struct NewThreadConfigStrip: View { .buttonStyle(.plain) .accessibilityLabel("Plan mode") .accessibilityValue(planModeEnabled ? "On" : "Off") + .popoverTip(MobileTips.PlanModeTip(), arrowEdge: .top) } // MARK: Chip diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift index 03415ca..3410dda 100644 --- a/RxCodeMobile/Views/OnboardingView.swift +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -2,6 +2,7 @@ import SwiftUI import PhotosUI import RxCodeSync import os +import TipKit private let onboardingLogger = Logger(subsystem: "com.claudework", category: "Onboarding") @@ -240,6 +241,7 @@ struct OnboardingView: View { .buttonStyle(.glassProminent) .controlSize(.large) .tint(.accentColor) + .popoverTip(MobileTips.PairingTip(), arrowEdge: .top) } private func errorBanner(_ message: String) -> some View { diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 7ddcfbb..634678c 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -1,6 +1,7 @@ import SwiftUI import RxCodeCore import RxCodeSync +import TipKit struct ProjectsSidebar: View { @EnvironmentObject private var state: MobileAppState @@ -23,6 +24,7 @@ struct ProjectsSidebar: View { Image(systemName: "folder.badge.plus") } .accessibilityLabel("Add Project") + .popoverTip(MobileTips.RemoteProjectTip(), arrowEdge: .top) } } .sheet(isPresented: $showingRemoteFolderPicker) { @@ -37,6 +39,7 @@ struct ProjectsSidebar: View { placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search projects and threads" ) + .popoverTip(MobileTips.SearchTip(), arrowEdge: .top) .autocorrectionDisabled(true) .textInputAutocapitalization(.never) .onChange(of: searchText) { _, newValue in @@ -85,6 +88,7 @@ struct ProjectsSidebar: View { .font(.headline) .foregroundStyle(showingBriefing ? Color.accentColor : Color.primary) } + .popoverTip(MobileTips.BriefingTip(), arrowEdge: .trailing) } Section("Projects") { diff --git a/RxCodeMobile/Views/QRScannerView.swift b/RxCodeMobile/Views/QRScannerView.swift index 151829c..22cb71b 100644 --- a/RxCodeMobile/Views/QRScannerView.swift +++ b/RxCodeMobile/Views/QRScannerView.swift @@ -21,6 +21,8 @@ struct QRScannerView: UIViewControllerRepresentable { var onResult: ((String) -> Void)? private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? + private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + private var rotationObservation: NSKeyValueObservation? override func viewDidLoad() { super.viewDidLoad() @@ -77,12 +79,34 @@ struct QRScannerView: UIViewControllerRepresentable { preview.frame = view.layer.bounds view.layer.addSublayer(preview) previewLayer = preview + + // Keep the preview upright as the device rotates (notably on iPad, + // which is not locked to portrait like the iPhone layout). + let coordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: preview) + rotationCoordinator = coordinator + applyPreviewRotation() + rotationObservation = coordinator.observe( + \.videoRotationAngleForHorizonLevelPreview, + options: [.new] + ) { [weak self] _, _ in + DispatchQueue.main.async { self?.applyPreviewRotation() } + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() scannerLogger.info("capture session started") } } + private func applyPreviewRotation() { + guard let coordinator = rotationCoordinator, + let connection = previewLayer?.connection else { return } + let angle = coordinator.videoRotationAngleForHorizonLevelPreview + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.layer.bounds diff --git a/RxCodeMobile/Views/ThreadChangesSheet.swift b/RxCodeMobile/Views/ThreadChangesSheet.swift new file mode 100644 index 0000000..8e6341e --- /dev/null +++ b/RxCodeMobile/Views/ThreadChangesSheet.swift @@ -0,0 +1,346 @@ +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Sheet listing the changes for a thread, reached from the thread's ⋯ menu. +/// A segmented control switches between every file edited across the thread +/// session ("This Turn") and the project's uncommitted git changes +/// ("Uncommitted"). Tapping a file pushes a full diff page. +struct ThreadChangesSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let sessionID: String + + private enum Tab: Hashable { + case thisTurn + case uncommitted + } + + @State private var tab: Tab = .thisTurn + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("Changes", selection: $tab) { + Text("This Turn").tag(Tab.thisTurn) + Text("Uncommitted").tag(Tab.uncommitted) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + content + } + .navigationTitle("Changes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(state.isLoadingThreadChanges) + .accessibilityLabel("Refresh") + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + await state.requestThreadChanges(sessionID: sessionID) + } + + /// The current result, but only when it belongs to this thread — a stale + /// result for a previously-opened thread is treated as "not yet loaded". + private var result: ThreadChangesResultPayload? { + guard let changes = state.threadChanges, changes.sessionID == sessionID else { return nil } + return changes + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if let result { + switch tab { + case .thisTurn: + // Thread edits are valid even when the git lookup failed, so + // they are never gated behind `ok`. + turnList(result.turnEdits) + case .uncommitted: + if result.ok { + uncommittedList(result.uncommitted) + } else { + errorState(result.errorMessage ?? "Could not load changes.") + } + } + } else if state.isPaired { + loadingState + } else { + errorState("Not connected to your Mac.") + } + } + + private var loadingState: some View { + VStack(spacing: 12) { + ProgressView() + Text("Loading changes…") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorState(_ message: String) -> some View { + ContentUnavailableView { + Label("Couldn't Load Changes", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Retry") { Task { await load() } } + } + } + + private func emptyState(_ message: String) -> some View { + ContentUnavailableView { + Label("No Changes", systemImage: "checkmark.circle") + } description: { + Text(message) + } + } + + // MARK: - This Turn + + @ViewBuilder + private func turnList(_ edits: [SyncFileEdit]) -> some View { + if edits.isEmpty { + emptyState("No files have been edited in this thread yet.") + } else { + List(edits) { edit in + NavigationLink { + ThreadChangeDetailView( + title: edit.name, + subtitle: edit.path, + diff: .hunks(edit.hunks.map { + PreviewFile.EditHunk(oldString: $0.oldString, newString: $0.newString) + }), + truncated: false + ) + } label: { + fileRow( + name: edit.name, + path: edit.path, + badge: edit.containsWrite ? "W" : "M", + badgeColor: edit.containsWrite ? .blue : .orange, + stat: hunkStat(edit.hunks) + ) + } + } + .listStyle(.plain) + .refreshable { await load() } + } + } + + // MARK: - Uncommitted + + @ViewBuilder + private func uncommittedList(_ changes: [SyncGitChange]) -> some View { + if changes.isEmpty { + emptyState("No uncommitted changes.") + } else { + List { + gitSection("Staged", changes.filter { $0.kind == .staged }) + gitSection("Unstaged", changes.filter { $0.kind == .unstaged }) + gitSection("Untracked", changes.filter { $0.kind == .untracked }) + } + .listStyle(.plain) + .refreshable { await load() } + } + } + + @ViewBuilder + private func gitSection(_ title: String, _ changes: [SyncGitChange]) -> some View { + if !changes.isEmpty { + Section(title) { + ForEach(changes) { change in + NavigationLink { + ThreadChangeDetailView( + title: fileName(change.displayPath), + subtitle: change.displayPath, + diff: .unified(change.unifiedDiff), + truncated: change.truncated + ) + } label: { + fileRow( + name: fileName(change.displayPath), + path: change.displayPath, + badge: change.statusChar, + badgeColor: gitStatusColor(change), + stat: unifiedStat(change.unifiedDiff) + ) + } + } + } + } + } + + // MARK: - Row + + private func fileRow( + name: String, + path: String, + badge: String, + badgeColor: Color, + stat: (added: Int, removed: Int) + ) -> some View { + HStack(spacing: 10) { + Text(badge) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(badgeColor, in: RoundedRectangle(cornerRadius: 5)) + + VStack(alignment: .leading, spacing: 2) { + Text(name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + Text(path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + } + + Spacer(minLength: 8) + + HStack(spacing: 6) { + if stat.added > 0 { + Text("+\(stat.added)").foregroundStyle(.green) + } + if stat.removed > 0 { + Text("−\(stat.removed)").foregroundStyle(.red) + } + } + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + } + .padding(.vertical, 2) + } + + // MARK: - Helpers + + private func fileName(_ path: String) -> String { + (path as NSString).lastPathComponent + } + + private func gitStatusColor(_ change: SyncGitChange) -> Color { + if change.kind == .untracked { return .blue } + switch change.statusChar { + case "A": return .green + case "D": return .red + case "R", "C": return .purple + default: return .orange + } + } + + private func hunkStat(_ hunks: [SyncEditHunk]) -> (added: Int, removed: Int) { + var added = 0 + var removed = 0 + for hunk in hunks { + if !hunk.oldString.isEmpty { + removed += hunk.oldString.components(separatedBy: "\n").count + } + if !hunk.newString.isEmpty { + added += hunk.newString.components(separatedBy: "\n").count + } + } + return (added, removed) + } + + private func unifiedStat(_ diff: String) -> (added: Int, removed: Int) { + var added = 0 + var removed = 0 + for line in diff.components(separatedBy: "\n") { + if line.hasPrefix("+"), !line.hasPrefix("+++") { + added += 1 + } else if line.hasPrefix("-"), !line.hasPrefix("---") { + removed += 1 + } + } + return (added, removed) + } +} + +/// Full-screen diff page for one changed file, pushed from `ThreadChangesSheet`. +struct ThreadChangeDetailView: View { + enum Diff { + case unified(String) + case hunks([PreviewFile.EditHunk]) + } + + let title: String + let subtitle: String + let diff: Diff + let truncated: Bool + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(.horizontal, 8) + + if truncated { + Label( + "Diff truncated — open this file on your Mac for the full diff.", + systemImage: "scissors" + ) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + } + + diffBody + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var diffBody: some View { + switch diff { + case .unified(let text): + if text.isEmpty { + emptyDiff + } else { + ChangeDiffView(unifiedDiff: text) + } + case .hunks(let hunks): + if hunks.isEmpty { + emptyDiff + } else { + ChangeDiffView(hunks: hunks) + } + } + } + + private var emptyDiff: some View { + Text("No diff content available.") + .font(.callout) + .foregroundStyle(.secondary) + .padding() + } +} diff --git a/RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Assets.xcassets/Contents.json b/RxCodeWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Info.plist b/RxCodeWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/RxCodeWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/RxCodeWidget/RxCodeJobActivity.swift b/RxCodeWidget/RxCodeJobActivity.swift new file mode 100644 index 0000000..284fac5 --- /dev/null +++ b/RxCodeWidget/RxCodeJobActivity.swift @@ -0,0 +1,110 @@ +// +// RxCodeJobActivity.swift +// RxCode +// +// Shared Live Activity attributes for RxCode's ongoing jobs. A *single* +// Live Activity per device aggregates every in-progress agent run — one +// activity, no matter how many jobs are running, keeps the app well within +// the scarce iOS push-to-start budget. +// +// This file is compiled into BOTH the RxCodeMobile app target (which starts +// and observes the activity) and the RxCodeWidget extension (which renders +// it). +// +// The desktop builds the APNs `content-state` JSON by hand, so the field +// names and types here are a wire contract: keep them in sync with +// `MobileSyncService` on macOS. Only plain JSON-friendly types are used +// (String / Int / Double / String-backed enum / arrays of those) so +// ActivityKit can decode a pushed content-state without a custom strategy. +// + +#if os(iOS) +import ActivityKit +import Foundation + +/// Live Activity descriptor for RxCode's ongoing jobs. The activity itself +/// carries no static attributes — it is device-scoped and every job lives in +/// the mutable `ContentState`. +struct RxCodeJobActivityAttributes: ActivityAttributes { + /// The mutable part, refreshed via APNs `update` pushes from the desktop. + struct ContentState: Codable, Hashable { + /// One agent job (chat session) tracked by the activity. + struct Job: Codable, Hashable, Identifiable { + /// Lifecycle phase of a single job. + enum Phase: String, Codable, Hashable { + /// The agent is still working. + case running + /// The agent finished. The job stays in the list — in this + /// state — until the user dismisses the whole activity. + case done + } + + /// Chat-session id the job belongs to. Stable for its lifetime + /// and used as the deep-link target. + var id: String + /// Lifecycle phase. + var phase: Phase + /// Thread title. The desktop swaps in an AI-summarized title + /// shortly after the job starts, pushed as a content update. + var title: String + /// Project display name, shown as the subtitle. + var projectName: String + /// Completed todo count. `todoTotal == 0` means no todo list. + var todoDone: Int + /// Total todo count; zero when the job has no todo list yet. + var todoTotal: Int + /// Active-form label of the in-progress todo (e.g. "Running + /// tests"). `nil` when there is no todo list or nothing is active. + var currentStep: String? + + /// `true` when the job has a todo list to render as steps. + var hasTodos: Bool { todoTotal > 0 } + + /// Fractional progress 0...1; zero when the job has no todo list. + var fractionComplete: Double { + guard todoTotal > 0 else { return 0 } + return min(1, max(0, Double(todoDone) / Double(todoTotal))) + } + } + + /// Every tracked job: those still running plus recently finished ones, + /// in the order they started. + var jobs: [Job] + /// Desktop-side update time, unix seconds. A `Double` rather than + /// `Date` so a pushed content-state decodes without a date strategy. + var updatedAt: Double + + /// Client-side normalized job list keyed by the chat-session id. APNs + /// updates can race a foreground local start, so renderers should read + /// this list instead of the raw wire array. + var deduplicatedJobs: [Job] { + var ordered: [Job] = [] + var indicesByID: [String: Int] = [:] + for job in jobs { + if let index = indicesByID[job.id] { + ordered[index] = job + } else { + indicesByID[job.id] = ordered.count + ordered.append(job) + } + } + return ordered + } + + /// Jobs still being worked on. + var runningJobs: [Job] { deduplicatedJobs.filter { $0.phase == .running } } + /// Jobs that have finished. + var doneJobs: [Job] { deduplicatedJobs.filter { $0.phase == .done } } + /// Count of jobs still running — the headline number for the UI. + var runningCount: Int { runningJobs.count } + /// Count of unique jobs represented by this activity. + var jobCount: Int { deduplicatedJobs.count } + /// `true` once every tracked job has finished. + var allDone: Bool { !deduplicatedJobs.isEmpty && runningJobs.isEmpty } + /// The single job to feature when exactly one is running, else `nil`. + var soleRunningJob: Job? { + runningJobs.count == 1 ? runningJobs.first : nil + } + } +} +#endif diff --git a/RxCodeWidget/RxCodeWidget.entitlements b/RxCodeWidget/RxCodeWidget.entitlements new file mode 100644 index 0000000..28721cf --- /dev/null +++ b/RxCodeWidget/RxCodeWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.rxlab.rxcodemobile + + + diff --git a/RxCodeWidget/RxCodeWidget.swift b/RxCodeWidget/RxCodeWidget.swift new file mode 100644 index 0000000..a872431 --- /dev/null +++ b/RxCodeWidget/RxCodeWidget.swift @@ -0,0 +1,190 @@ +// +// RxCodeWidget.swift +// RxCodeWidget +// +// Home-screen widget mirroring the desktop menubar: the number of ongoing +// jobs plus Claude Code and Codex rate-limit usage. Data is written by the +// iOS app into the shared App Group and refreshed via background APNs pushes. +// + +import WidgetKit +import SwiftUI + +/// RxCode terracotta accent (#D97757). +private let rxAccent = Color(red: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255) + +// MARK: - Timeline + +struct RxCodeWidgetEntry: TimelineEntry { + let date: Date + let data: RxCodeWidgetData +} + +struct RxCodeWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> RxCodeWidgetEntry { + RxCodeWidgetEntry( + date: Date(), + data: RxCodeWidgetData(jobCount: 2, ccUsagePercent: 45, codexUsagePercent: 12, updatedAt: 0) + ) + } + + func getSnapshot(in context: Context, completion: @escaping (RxCodeWidgetEntry) -> Void) { + completion(RxCodeWidgetEntry(date: Date(), data: RxCodeWidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = RxCodeWidgetEntry(date: Date(), data: RxCodeWidgetStore.load()) + // The app pushes fresh data via background APNs; this hourly refresh is + // just a fallback so the widget never goes fully stale. + let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) + ?? Date().addingTimeInterval(3600) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Views + +struct RxCodeWidgetEntryView: View { + @Environment(\.widgetFamily) private var family + var entry: RxCodeWidgetProvider.Entry + + var body: some View { + switch family { + case .systemMedium: + mediumBody + default: + smallBody + } + } + + private var smallBody: some View { + VStack(alignment: .leading, spacing: 10) { + jobsHeader + Spacer(minLength: 0) + usageRow(label: "CC", percent: entry.data.ccUsagePercent) + usageRow(label: "CX", percent: entry.data.codexUsagePercent) + } + } + + private var mediumBody: some View { + HStack(alignment: .center, spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + jobsHeader + Spacer(minLength: 0) + Text(updatedLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Divider() + VStack(alignment: .leading, spacing: 12) { + usageRow(label: "Claude Code", percent: entry.data.ccUsagePercent) + usageRow(label: "Codex", percent: entry.data.codexUsagePercent) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var jobsHeader: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Image(systemName: "hammer.fill") + .font(.caption2) + .foregroundStyle(rxAccent) + Text("RxCode") + .font(.caption2.weight(.bold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(entry.data.jobCount)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundStyle(entry.data.jobCount > 0 ? rxAccent : .primary) + Text(entry.data.jobCount == 1 ? "job" : "jobs") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func usageRow(label: String, percent: Double?) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Text(percent.map { "\(Int($0.rounded()))%" } ?? "—") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(percent == nil ? .secondary : .primary) + } + UsageBar(percent: percent) + } + } + + private var updatedLabel: String { + guard entry.data.updatedAt > 0 else { return "No data yet" } + let date = Date(timeIntervalSince1970: entry.data.updatedAt) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return "Updated \(formatter.localizedString(for: date, relativeTo: Date()))" + } +} + +/// A thin capsule usage bar that tints toward red as utilization climbs. +private struct UsageBar: View { + let percent: Double? + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.22)) + Capsule() + .fill(tint) + .frame(width: geo.size.width * fraction) + } + } + .frame(height: 5) + } + + private var fraction: Double { + guard let percent else { return 0 } + return min(1, max(0, percent / 100)) + } + + private var tint: Color { + guard let percent else { return .secondary } + if percent >= 90 { return .red } + if percent >= 70 { return .orange } + return rxAccent + } +} + +// MARK: - Widget + +struct RxCodeWidget: Widget { + let kind: String = "RxCodeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: RxCodeWidgetProvider()) { entry in + RxCodeWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("RxCode Jobs") + .description("Ongoing jobs and Claude Code / Codex usage.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +#Preview(as: .systemSmall) { + RxCodeWidget() +} timeline: { + RxCodeWidgetEntry(date: .now, data: RxCodeWidgetData(jobCount: 3, ccUsagePercent: 64, codexUsagePercent: 22, updatedAt: Date().timeIntervalSince1970)) + RxCodeWidgetEntry(date: .now, data: .empty) +} + +#Preview(as: .systemMedium) { + RxCodeWidget() +} timeline: { + RxCodeWidgetEntry(date: .now, data: RxCodeWidgetData(jobCount: 1, ccUsagePercent: 91, codexUsagePercent: nil, updatedAt: Date().timeIntervalSince1970)) +} diff --git a/RxCodeWidget/RxCodeWidgetBundle.swift b/RxCodeWidget/RxCodeWidgetBundle.swift new file mode 100644 index 0000000..9c2af22 --- /dev/null +++ b/RxCodeWidget/RxCodeWidgetBundle.swift @@ -0,0 +1,17 @@ +// +// RxCodeWidgetBundle.swift +// RxCodeWidget +// +// Created by Qiwei Li on 5/20/26. +// + +import WidgetKit +import SwiftUI + +@main +struct RxCodeWidgetBundle: WidgetBundle { + var body: some Widget { + RxCodeWidget() + RxCodeWidgetLiveActivity() + } +} diff --git a/RxCodeWidget/RxCodeWidgetData.swift b/RxCodeWidget/RxCodeWidgetData.swift new file mode 100644 index 0000000..a8ff7c2 --- /dev/null +++ b/RxCodeWidget/RxCodeWidgetData.swift @@ -0,0 +1,64 @@ +// +// RxCodeWidgetData.swift +// RxCode +// +// Shared snapshot for the RxCode home-screen widget. Compiled into BOTH the +// RxCodeMobile app target (which writes it) and the RxCodeWidget extension +// (which reads it). The data crosses the process boundary through the +// App Group container, and the app nudges WidgetKit to reload after a write. +// + +import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif + +/// What the RxCode home-screen widget renders: the number of ongoing jobs and +/// the agents' rate-limit usage, mirrored from the desktop menubar. +struct RxCodeWidgetData: Codable, Equatable { + /// Number of in-progress jobs (streaming chat sessions). + var jobCount: Int + /// Claude Code 5-hour utilization, 0...100. `nil` when not signed in. + var ccUsagePercent: Double? + /// Codex 5-hour utilization, 0...100. `nil` when not signed in. + var codexUsagePercent: Double? + /// When the desktop produced this snapshot, unix seconds. + var updatedAt: Double + + static let empty = RxCodeWidgetData( + jobCount: 0, ccUsagePercent: nil, codexUsagePercent: nil, updatedAt: 0 + ) +} + +/// Reads and writes `RxCodeWidgetData` in the App Group shared by the app and +/// the widget extension. +enum RxCodeWidgetStore { + /// App Group identifier — must match the `com.apple.security.application-groups` + /// entitlement on both the app and the widget targets. + static let appGroupID = "group.app.rxlab.rxcodemobile" + + private static let snapshotKey = "widget.snapshot" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroupID) + } + + /// Current snapshot, or `.empty` when nothing has been written yet. + static func load() -> RxCodeWidgetData { + guard let data = defaults?.data(forKey: snapshotKey), + let decoded = try? JSONDecoder().decode(RxCodeWidgetData.self, from: data) + else { return .empty } + return decoded + } + + /// Persist a fresh snapshot and ask WidgetKit to reload the timeline. + /// Called from the app (foreground sync and background widget push). + static func save(_ snapshot: RxCodeWidgetData) { + guard let defaults, + let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: snapshotKey) + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } +} diff --git a/RxCodeWidget/RxCodeWidgetLiveActivity.swift b/RxCodeWidget/RxCodeWidgetLiveActivity.swift new file mode 100644 index 0000000..703a8a2 --- /dev/null +++ b/RxCodeWidget/RxCodeWidgetLiveActivity.swift @@ -0,0 +1,390 @@ +// +// RxCodeWidgetLiveActivity.swift +// RxCodeWidget +// +// Live Activity for RxCode's ongoing jobs. A single activity aggregates +// every in-progress agent job: it shows a step progress bar when exactly one +// job is running, and an in-progress count plus a compact list when several +// are. A green "all done" state stays on screen until the user dismisses it. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +/// RxCode terracotta accent (#D97757). +private let rxAccent = Color(red: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255) +/// Unfilled progress-track color. +private let rxTrack = Color.secondary.opacity(0.25) + +private typealias JobState = RxCodeJobActivityAttributes.ContentState +private typealias Job = RxCodeJobActivityAttributes.ContentState.Job + +struct RxCodeWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RxCodeJobActivityAttributes.self) { context in + // Lock screen / banner presentation. + JobsLockScreenView(state: context.state) + .activityBackgroundTint(Color.black.opacity(0.55)) + .activitySystemActionForegroundColor(rxAccent) + + } dynamicIsland: { context in + let state = context.state + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + .font(.title3) + } + DynamicIslandExpandedRegion(.trailing) { + Text(statusBadge(state)) + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + } + DynamicIslandExpandedRegion(.center) { + Text(headlineTitle(state)) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + JobsExpandedBottomView(state: state) + } + } compactLeading: { + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + } compactTrailing: { + Text(compactTrailing(state)) + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + } minimal: { + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + } + .widgetURL(deepLink(state)) + .keylineTint(rxAccent) + } + } +} + +// MARK: - Presentation helpers + +private func leadingIcon(_ state: JobState) -> String { + state.allDone ? "checkmark.circle.fill" : "hammer.fill" +} + +/// Headline title: the sole running job's title, or an N-jobs summary. +private func headlineTitle(_ state: JobState) -> String { + if let job = state.soleRunningJob { return job.title } + if state.allDone { + return state.jobCount == 1 ? "Job done" : "\(state.jobCount) jobs done" + } + return "\(state.runningCount) jobs running" +} + +/// Trailing status badge for the lock screen / expanded island. +private func statusBadge(_ state: JobState) -> String { + if state.allDone { return "Done" } + if let job = state.soleRunningJob { + return job.hasTodos ? "\(job.todoDone)/\(job.todoTotal)" : "Working" + } + return "\(state.runningCount) running" +} + +/// Compact Dynamic Island trailing — tight on space, so digits only. +private func compactTrailing(_ state: JobState) -> String { + if state.allDone { return "✓" } + if let job = state.soleRunningJob { + return job.hasTodos ? "\(job.todoDone)/\(job.todoTotal)" : "•••" + } + return "\(state.runningCount)" +} + +/// Deep-link to the single running job, or the app root for several. +private func deepLink(_ state: JobState) -> URL? { + if let job = state.soleRunningJob { + return URL(string: "rxcode://thread/\(job.id)") + } + return URL(string: "rxcode://") +} + +// MARK: - Lock screen + +private struct JobsLockScreenView: View { + let state: JobState + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + content + } + .padding(16) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + Text(headerLabel) + .font(.caption.weight(.bold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text(statusBadge(state)) + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + } + } + + private var headerLabel: String { + if state.allDone { return "Jobs done" } + return state.runningCount == 1 ? "Ongoing job" : "Ongoing jobs" + } + + @ViewBuilder + private var content: some View { + if let job = state.soleRunningJob { + // Exactly one job: feature it with a full step progress bar. + SoleJobView(job: job) + } else if state.allDone { + VStack(alignment: .leading, spacing: 8) { + Text("\(state.jobCount) \(state.jobCount == 1 ? "job" : "jobs") completed") + .font(.subheadline.weight(.medium)) + StepProgressBar(done: 1, total: 0, isComplete: true) + } + } else { + // Several jobs: lead with the count, list the rest. + MultiJobView(state: state) + } + } +} + +/// The featured single-job layout: title, project, and a step progress bar. +private struct SoleJobView: View { + let job: Job + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(job.title) + .font(.headline) + .lineLimit(1) + if !job.projectName.isEmpty { + Text(job.projectName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + StepProgressBar(done: job.todoDone, total: job.todoTotal) + if let step = job.currentStep, !step.isEmpty { + Text(step) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } +} + +/// The multi-job layout: a compact row per running job, then an overflow line. +private struct MultiJobView: View { + let state: JobState + private let maxRows = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(state.runningJobs.prefix(maxRows))) { job in + JobRowView(job: job) + } + let overflow = state.runningCount - maxRows + if overflow > 0 { + Text("+\(overflow) more") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + } + } +} + +/// One compact row: a title line plus a thin step progress bar. +private struct JobRowView: View { + let job: Job + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Circle() + .fill(rxAccent) + .frame(width: 5, height: 5) + Text(job.title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Spacer(minLength: 6) + Text(job.hasTodos ? "\(job.todoDone)/\(job.todoTotal)" : "•••") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) + } + StepProgressBar(done: job.todoDone, total: job.todoTotal, height: 4) + } + } +} + +// MARK: - Dynamic Island expanded bottom + +private struct JobsExpandedBottomView: View { + let state: JobState + + var body: some View { + if let job = state.soleRunningJob { + VStack(alignment: .leading, spacing: 6) { + if !job.projectName.isEmpty { + Text(job.projectName) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + StepProgressBar(done: job.todoDone, total: job.todoTotal) + if let step = job.currentStep, !step.isEmpty { + Text(step) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } else if state.allDone { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("All jobs done") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(state.runningJobs.prefix(2))) { job in + JobRowView(job: job) + } + let overflow = state.runningCount - 2 + if overflow > 0 { + Text("+\(overflow) more") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - Step progress bar + +/// A segmented "step" progress bar — one capsule per todo, filled as steps +/// complete. Above `stepCap` steps the segments would be too thin, so it +/// falls back to a continuous bar; with no todo list it shows a small +/// "working" sliver; once complete every segment turns green. +private struct StepProgressBar: View { + /// Completed step count. + let done: Int + /// Total step count; 0 renders the indeterminate sliver. + let total: Int + /// Paints the whole bar in the done color. + var isComplete: Bool = false + /// Bar height — thinner for compact list rows. + var height: CGFloat = 6 + + /// Above this step count discrete segments get too thin to read. + private let stepCap = 12 + + var body: some View { + GeometryReader { geo in + bar(in: geo.size.width) + } + .frame(height: height) + } + + @ViewBuilder + private func bar(in width: CGFloat) -> some View { + if total >= 1, total <= stepCap { + let spacing: CGFloat = total > 8 ? 2 : 3 + let each = (width - spacing * CGFloat(total - 1)) / CGFloat(total) + HStack(spacing: spacing) { + ForEach(0.. 0 else { return 0.16 } // no todo list: a "working" sliver + return min(1, max(0, Double(done) / Double(total))) + } +} + +// MARK: - Previews + +extension RxCodeJobActivityAttributes.ContentState { + fileprivate static func makeJob( + _ id: String, _ title: String, _ project: String, + phase: Job.Phase = .running, done: Int = 0, total: Int = 0, + step: String? = nil + ) -> Job { + Job(id: id, phase: phase, title: title, projectName: project, + todoDone: done, todoTotal: total, currentStep: step) + } + + /// Exactly one running job — featured with a step progress bar. + fileprivate static var oneRunning: Self { + .init(jobs: [ + makeJob("a", "Add live activity support", "RxCode", + done: 2, total: 5, step: "Implementing widget UI"), + ], updatedAt: 0) + } + + /// One running job with no todo list — shows the "working" sliver. + fileprivate static var oneRunningNoTodos: Self { + .init(jobs: [ + makeJob("a", "Investigate flaky sync test", "RxCodeMobile"), + ], updatedAt: 0) + } + + /// Several running jobs — shows the in-progress count and a list. + fileprivate static var manyRunning: Self { + .init(jobs: [ + makeJob("a", "Add live activity support", "RxCode", done: 2, total: 5), + makeJob("b", "Fix mobile sync reconnect", "RxCodeMobile", done: 1, total: 3), + makeJob("c", "Refactor marketplace fetch", "RxCode"), + makeJob("d", "Update onboarding copy", "Homepage", done: 3, total: 4), + ], updatedAt: 0) + } + + /// Every job finished — the green terminal state. + fileprivate static var allDone: Self { + .init(jobs: [ + makeJob("a", "Add live activity support", "RxCode", + phase: .done, done: 5, total: 5), + makeJob("b", "Fix mobile sync reconnect", "RxCodeMobile", + phase: .done, done: 3, total: 3), + ], updatedAt: 0) + } +} + +#Preview("Live Activity", as: .content, using: RxCodeJobActivityAttributes()) { + RxCodeWidgetLiveActivity() +} contentStates: { + RxCodeJobActivityAttributes.ContentState.oneRunning + RxCodeJobActivityAttributes.ContentState.oneRunningNoTodos + RxCodeJobActivityAttributes.ContentState.manyRunning + RxCodeJobActivityAttributes.ContentState.allDone +} diff --git a/relay-server/README.md b/relay-server/README.md index 9a45c79..77cc9b3 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -13,18 +13,36 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. 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. +- `POST /push` — desktop submits APNs pushes. The `push_type` field selects + one of three delivery modes (defaults to `alert`): + - **`alert`** (or omitted) — encrypted banner. Body: + ```json + { + "device_token": "", + "encrypted_alert": "", + "category": "permission_request", // optional + "collapse_id": "" // optional + } + ``` + The encrypted alert blob is decrypted on-device by the iOS Notification + Service Extension before iOS displays the banner. + - **`liveactivity`** — ActivityKit start/update/end push. Body: + ```json + { + "device_token": "", + "push_type": "liveactivity", + "apns_payload": { "aps": { "event": "update", "content-state": { … } } }, + "collapse_id": "" // optional + } + ``` + The relay forwards `apns_payload` verbatim and suffixes the topic with + `.push-type.liveactivity`. Live Activity content-state is **not** E2E + encrypted — ActivityKit consumes it directly. + - **`background`** — silent `content-available` push used to refresh the + home-screen widget. Body is `{ "device_token", "push_type": "background", + "apns_payload" }`; the payload is forwarded verbatim at low priority. + + The relay signs a JWT with the configured APNs auth key and forwards the push. - `GET /healthz` — liveness probe. ## Run locally diff --git a/relay-server/push.go b/relay-server/push.go index e224f1d..73e99d1 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -58,23 +58,47 @@ func NewPushSender(keyPath string, keyPEM []byte, keyID, teamID, topic string, p return &PushSender{client: client, topic: topic}, nil } +// Push delivery modes accepted by POST /push. +const ( + // pushModeAlert is the legacy encrypted-banner path: the desktop ships an + // opaque E2E-encrypted blob and the iOS Notification Service Extension + // decrypts it before the banner is shown. This is the default when + // `push_type` is empty. + pushModeAlert = "alert" + // pushModeLiveActivity carries an ActivityKit start/update/end payload. + // Live Activity content-state cannot be E2E encrypted because ActivityKit + // consumes it directly, so `apns_payload` is forwarded verbatim. + pushModeLiveActivity = "liveactivity" + // pushModeBackground is a silent content-available push used to refresh + // the home-screen widget; `apns_payload` is forwarded verbatim. + pushModeBackground = "background" +) + // 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. +// For the legacy alert path, `encrypted_alert` is an opaque base64 blob +// produced by the desktop sender — the relay never decrypts it. For the +// `liveactivity` and `background` paths, `apns_payload` is the complete APNs +// JSON payload (`{"aps": {…}, …}`) built by the desktop and forwarded verbatim. type PushRequest struct { DeviceToken string `json:"device_token"` - EncryptedAlertB64 string `json:"encrypted_alert"` + EncryptedAlertB64 string `json:"encrypted_alert,omitempty"` Category string `json:"category,omitempty"` CollapseID string `json:"collapse_id,omitempty"` + // PushType selects the delivery mode: "" / "alert", "liveactivity", or + // "background". Unknown values are rejected. + PushType string `json:"push_type,omitempty"` + // APNSPayload is the raw APNs JSON, required for "liveactivity" and + // "background". Ignored for the alert path. + APNSPayload json.RawMessage `json:"apns_payload,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). +// Auth is intentionally minimal in v1: any client may submit, since the alert +// payload itself is E2E-encrypted to the recipient device. Live Activity and +// widget payloads are not encrypted (ActivityKit / WidgetKit consume them +// directly); a future hardening pass should require a signed sender token. func pushHandler(sender *PushSender) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -95,86 +119,57 @@ func pushHandler(sender *PushSender) http.HandlerFunc { 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) + if req.DeviceToken == "" { + http.Error(w, "missing device_token", 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, + mode := req.PushType + if mode == "" { + mode = pushModeAlert } - raw, _ := json.Marshal(payload) - notif := &apns2.Notification{ - DeviceToken: req.DeviceToken, - Topic: sender.topic, - Payload: raw, - } - if req.Category != "" { - notif.PushType = apns2.PushTypeAlert + var notif *apns2.Notification + switch mode { + case pushModeAlert: + notif, err = buildAlertNotification(sender, &req) + case pushModeLiveActivity: + notif, err = buildRawNotification(sender, &req, apns2.PushTypeLiveActivity, apns2.PriorityHigh, true) + case pushModeBackground: + notif, err = buildRawNotification(sender, &req, apns2.PushTypeBackground, apns2.PriorityLow, false) + default: + http.Error(w, "unknown push_type", http.StatusBadRequest) + return } - if req.CollapseID != "" { - notif.CollapseID = req.CollapseID + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } + payloadBytes, _ := notif.Payload.([]byte) 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), + "apns push send: mode=%s device=%s category=%q collapse_id=%q payload_bytes=%d", + mode, short(req.DeviceToken), req.Category, req.CollapseID, len(payloadBytes), ) 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), + "apns push transport error: %v mode=%s device=%s category=%q", + err, mode, short(req.DeviceToken), req.Category, ) 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), + "apns push sent: mode=%s status=%d apns_id=%s device=%s", + mode, res.StatusCode, res.ApnsID, short(req.DeviceToken), ) } 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), + "apns push rejected: mode=%s status=%d reason=%q apns_id=%s device=%s", + mode, res.StatusCode, res.Reason, res.ApnsID, short(req.DeviceToken), ) } resp := map[string]any{ @@ -186,3 +181,74 @@ func pushHandler(sender *PushSender) http.HandlerFunc { _ = json.NewEncoder(w).Encode(resp) } } + +// buildAlertNotification wraps the desktop's E2E-encrypted blob in the static +// envelope the iOS Notification Service Extension expects. `mutable-content=1` +// triggers the extension, which decrypts `enc` and rewrites the visible alert. +func buildAlertNotification(sender *PushSender, req *PushRequest) (*apns2.Notification, error) { + if req.EncryptedAlertB64 == "" { + return nil, fmt.Errorf("missing encrypted_alert") + } + if _, err := base64.StdEncoding.DecodeString(req.EncryptedAlertB64); err != nil { + return nil, fmt.Errorf("encrypted_alert must be base64") + } + 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 + } + return notif, nil +} + +// buildRawNotification forwards the desktop-built APNs payload verbatim. Used +// for Live Activity and background (widget) pushes, whose payloads cannot be +// E2E encrypted because the OS consumes them directly. When `liveActivityTopic` +// is set, the APNs topic is suffixed with `.push-type.liveactivity` as Apple +// requires for Live Activity pushes. +func buildRawNotification( + sender *PushSender, + req *PushRequest, + pushType apns2.EPushType, + priority int, + liveActivityTopic bool, +) (*apns2.Notification, error) { + if len(req.APNSPayload) == 0 { + return nil, fmt.Errorf("missing apns_payload") + } + if !json.Valid(req.APNSPayload) { + return nil, fmt.Errorf("apns_payload must be valid JSON") + } + topic := sender.topic + if liveActivityTopic { + topic = sender.topic + ".push-type.liveactivity" + } + notif := &apns2.Notification{ + DeviceToken: req.DeviceToken, + Topic: topic, + Payload: []byte(req.APNSPayload), + PushType: pushType, + Priority: priority, + } + if req.CollapseID != "" { + notif.CollapseID = req.CollapseID + } + return notif, nil +}