From b29465c7b81178ac1d95b3113b57d20965d3233c Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 17:09:02 +0800 Subject: [PATCH 1/3] fix: optimistic remove paired device and store relay url --- RxCode/Services/MobileSyncService.swift | 27 +++++++++++++++---- RxCode/Views/Settings/MobileSettingsTab.swift | 8 ++++++ .../State/MobileAppState+Inbound.swift | 5 ++-- RxCodeMobile/State/MobileAppState+Sync.swift | 11 +++++--- RxCodeMobile/State/MobileAppState.swift | 10 +++++++ RxCodeMobile/Views/MobileSettingsView.swift | 13 ++++++--- 6 files changed, 61 insertions(+), 13 deletions(-) diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 17f8eede..b2692492 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -29,8 +29,18 @@ struct PairedDevice: Codable, Identifiable, Sendable, Hashable { var liveActivityTokens: [LiveActivityTokenRef]? var pairedAt: Date var lastSeen: Date? + /// The relay server URL this device was paired through. + var relayURL: String? var id: String { pubkeyHex } + + /// Human-readable relay host for display (e.g. "relay.example.com"). + var relayDisplayName: String? { + guard let urlString = relayURL, + let url = URL(string: urlString), + let host = url.host else { return nil } + return host + } } /// Result of a pairing handshake propagated to the SwiftUI pairing sheet. @@ -240,7 +250,8 @@ final class MobileSyncService: ObservableObject { apnsToken: nil, apnsEnvironment: nil, pairedAt: .now, - lastSeen: .now + lastSeen: .now, + relayURL: relayURL.absoluteString ) pairedDevices.removeAll { $0.pubkeyHex == device.pubkeyHex } pairedDevices.append(device) @@ -253,6 +264,7 @@ final class MobileSyncService: ObservableObject { pairingToken = nil pairingContinuation?.resume(returning: .accepted(device)) pairingContinuation = nil + logger.info("[Pairing] accepted mobile=\(pending.displayName, privacy: .public) mobileKey=\(String(pending.mobilePubkeyHex.prefix(12)), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") } func cancelPairing() { @@ -273,11 +285,16 @@ final class MobileSyncService: ObservableObject { /// Remove a paired device and notify it before forgetting its pubkey. func unpair(_ device: PairedDevice) async { - try? await client.send(.unpair(UnpairPayload(reason: "desktop")), toHex: device.pubkeyHex) - pairedDevices.removeAll { $0.pubkeyHex == device.pubkeyHex } + let pubkeyHex = device.pubkeyHex + + // Optimistically remove from UI first for immediate feedback + pairedDevices.removeAll { $0.pubkeyHex == pubkeyHex } savePairedDevices() - subscribedSessions.removeValue(forKey: device.pubkeyHex) - await client.removePeer(device.pubkeyHex) + subscribedSessions.removeValue(forKey: pubkeyHex) + + // Notify mobile and clean up peer connection (best-effort, ignore failures) + try? await client.send(.unpair(UnpairPayload(reason: "desktop")), toHex: pubkeyHex) + await client.removePeer(pubkeyHex) } // MARK: - Notification fan-out diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index fedae543..3a629e93 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -283,6 +283,14 @@ struct MobileSettingsTab: View { .font(.caption) .foregroundStyle(.secondary) } + if let relay = device.relayDisplayName { + Text("•") + .font(.caption) + .foregroundStyle(.secondary) + Label(relay, systemImage: "antenna.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + } } } Spacer() diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 0bde3c73..440f2a61 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -38,12 +38,13 @@ extension MobileAppState { pubkeyHex: inbound.fromHex, displayName: ack.desktopName, pairedAt: .now, - lastSeen: .now + lastSeen: .now, + relayURL: relayURL.absoluteString ) ) setActiveDesktop(pubkeyHex: inbound.fromHex) pairingStatus = .idle - logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") MobileHaptics.connected() savePairedDesktops() Task { diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index e8161697..a172dd91 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -358,9 +358,10 @@ extension MobileAppState { func removePairedDesktop(_ desktop: PairedDesktop) async { let wasActive = desktop.pubkeyHex == pairedDesktopPubkey - try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: desktop.pubkeyHex) - pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } - await client.removePeer(desktop.pubkeyHex) + let pubkeyHex = desktop.pubkeyHex + + // Optimistically remove from UI first for immediate feedback + pairedDesktops.removeAll { $0.pubkeyHex == pubkeyHex } if wasActive { clearDesktopMirror() @@ -370,6 +371,10 @@ extension MobileAppState { } savePairedDesktops() + // Notify desktop and clean up peer connection (best-effort, ignore failures) + try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: pubkeyHex) + await client.removePeer(pubkeyHex) + if wasActive, isPaired { await requestSnapshot() await reportAPNsTokenIfPending() diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index ec9628b4..5da4489a 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -23,8 +23,18 @@ struct PairedDesktop: Codable, Identifiable, Equatable, Hashable { var displayName: String var pairedAt: Date var lastSeen: Date? + /// The relay server URL this desktop was paired through. + var relayURL: String? var id: String { pubkeyHex } + + /// Human-readable relay host for display (e.g. "relay.example.com"). + var relayDisplayName: String? { + guard let urlString = relayURL, + let url = URL(string: urlString), + let host = url.host else { return nil } + return host + } } /// Single source of truth for the mobile app. Owns the `SyncClient`, the diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 449ca64a..e2439100 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -125,9 +125,16 @@ struct MobileSettingsView: View { VStack(alignment: .leading, spacing: 2) { Text(desktop.displayName.isEmpty ? "Unknown Mac" : desktop.displayName) .foregroundStyle(.primary) - Text("Paired \(desktop.pairedAt, format: .relative(presentation: .named))") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 6) { + Text("Paired \(desktop.pairedAt, format: .relative(presentation: .named))") + if let relay = desktop.relayDisplayName { + Text("•") + Label(relay, systemImage: "antenna.radiowaves.left.and.right") + .labelStyle(.titleOnly) + } + } + .font(.caption) + .foregroundStyle(.secondary) } Spacer() if desktop.pubkeyHex == state.pairedDesktopPubkey { From 65dd5e622d5885a2e3767a2cbbe93a817da35ae7 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 17:24:59 +0800 Subject: [PATCH 2/3] fix: improve widget --- .../RxCodeChatKit/StatusLineView.swift | 10 +- .../RxCodeSync/APNs/EncryptedAlert.swift | 15 ++- .../Sources/RxCodeSync/Protocol/Payload.swift | 2 +- .../Tests/RxCodeSyncTests/PayloadTests.swift | 9 +- RxCode/App/AppState+Lifecycle.swift | 8 +- RxCode/App/RxCodeApp.swift | 6 +- RxCode/Services/CodexAppServer+Parsing.swift | 7 +- RxCode/Services/CodexAppServer+Process.swift | 2 +- .../MobileSyncService+LiveActivity.swift | 2 + RxCode/Services/MobileSyncService.swift | 19 ++- RxCode/Views/Settings/MobileSettingsTab.swift | 30 +++++ RxCodeMobile/AppDelegate.swift | 2 + .../State/MobileAppState+Persistence.swift | 16 ++- RxCodeMobile/State/MobileAppState+Sync.swift | 2 + RxCodeMobile/Views/MobileSettingsView.swift | 44 +++++-- RxCodeWidget/RxCodeWidget.swift | 115 ++++++++++++++---- RxCodeWidget/RxCodeWidgetData.swift | 11 +- 17 files changed, 246 insertions(+), 54 deletions(-) diff --git a/Packages/Sources/RxCodeChatKit/StatusLineView.swift b/Packages/Sources/RxCodeChatKit/StatusLineView.swift index 96c51c19..b02ad81c 100644 --- a/Packages/Sources/RxCodeChatKit/StatusLineView.swift +++ b/Packages/Sources/RxCodeChatKit/StatusLineView.swift @@ -170,13 +170,13 @@ struct StatusLineView: View { body: "Tracks usage against Codex's rolling 5-hour limit. Resets gradually as older requests age out." ) rateLimitSegment( - label: String(localized: "24h", bundle: .module), + label: String(localized: "7d", bundle: .module), icon: "calendar", - percent: rateLimit?.twentyFourHourPercent, - resetsAt: rateLimit?.twentyFourHourResetsAt, + percent: rateLimit?.sevenDayPercent, + resetsAt: rateLimit?.sevenDayResetsAt, isPresented: $showSevenDayPopover, - title: "24-hour rate limit", - body: "Tracks usage against Codex's rolling 24-hour limit. Resets gradually as older requests age out." + title: "7-day rate limit", + body: "Tracks usage against Codex's rolling 7-day limit. Resets gradually as older requests age out." ) case .acp: EmptyView() diff --git a/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift b/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift index 82532c31..884e43c2 100644 --- a/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift +++ b/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift @@ -60,15 +60,28 @@ public struct WidgetSnapshotPayload: Codable, Sendable { public let jobs: Int? /// Claude Code 5-hour utilization (0...100), or `nil` to leave untouched. public let cc: Double? + /// Claude Code 7-day utilization (0...100), or `nil` to leave untouched. + public let ccWeekly: Double? /// Codex 5-hour utilization (0...100), or `nil` to leave untouched. public let codex: Double? + /// Codex 7-day utilization (0...100), or `nil` to leave untouched. + public let codexWeekly: Double? /// When the desktop produced this snapshot, unix seconds. public let updatedAt: Double - public init(jobs: Int? = nil, cc: Double? = nil, codex: Double? = nil, updatedAt: Double) { + public init( + jobs: Int? = nil, + cc: Double? = nil, + ccWeekly: Double? = nil, + codex: Double? = nil, + codexWeekly: Double? = nil, + updatedAt: Double + ) { self.jobs = jobs self.cc = cc + self.ccWeekly = ccWeekly self.codex = codex + self.codexWeekly = codexWeekly self.updatedAt = updatedAt } } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 03cf0eaa..62c890d4 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -230,7 +230,7 @@ public struct RequestSnapshotPayload: Codable, Sendable { public struct MobileUsageSnapshot: Codable, Sendable, Equatable { /// Claude Code usage: 5-hour and 7-day limits. public let claudeCode: RateLimitUsage? - /// Codex usage: 5-hour and 24-hour limits. + /// Codex usage: 5-hour and 7-day limits. public let codex: RateLimitUsage? public init(claudeCode: RateLimitUsage? = nil, codex: RateLimitUsage? = nil) { diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index 41c8b7c8..02d58960 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -115,11 +115,9 @@ struct PayloadTests { ), codex: RateLimitUsage( fiveHourPercent: 7, - sevenDayPercent: 0, - twentyFourHourPercent: 55, + sevenDayPercent: 55, fiveHourResetsAt: nil, - sevenDayResetsAt: nil, - twentyFourHourResetsAt: Date(timeIntervalSince1970: 300) + sevenDayResetsAt: Date(timeIntervalSince1970: 300) ) ) ) @@ -135,7 +133,8 @@ struct PayloadTests { #expect(snapshot.usage?.hasAnyUsage == true) #expect(snapshot.usage?.claudeCode?.fiveHourPercent == 42) #expect(snapshot.usage?.claudeCode?.sevenDayResetsAt == Date(timeIntervalSince1970: 200)) - #expect(snapshot.usage?.codex?.twentyFourHourPercent == 55) + #expect(snapshot.usage?.codex?.sevenDayPercent == 55) + #expect(snapshot.usage?.codex?.sevenDayResetsAt == Date(timeIntervalSince1970: 300)) } @Test("snapshot without usage decodes to nil") diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index d0888222..e63f5166 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -137,8 +137,12 @@ extension AppState { self?.projects.first { $0.id == id }?.name } MobileSyncService.shared.usageSnapshotProvider = { [weak self] in - (self?.latestRateLimitUsage?.fiveHourPercent, - self?.latestCodexRateLimitUsage?.fiveHourPercent) + ( + cc: self?.latestRateLimitUsage?.fiveHourPercent, + ccWeekly: self?.latestRateLimitUsage?.sevenDayPercent, + codex: self?.latestCodexRateLimitUsage?.fiveHourPercent, + codexWeekly: self?.latestCodexRateLimitUsage?.sevenDayPercent + ) } await refreshAgentInstallations() diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index d7bfd3c7..48aa9641 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -219,7 +219,7 @@ private struct MenuBarContentView: View { private var secondaryLimitLabel: String { switch appState.selectedAgentProvider { case .claudeCode: return "7-day limit" - case .codex: return "24-hour limit" + case .codex: return "7-day limit" case .acp: return "Usage" } } @@ -227,7 +227,7 @@ private struct MenuBarContentView: View { private var secondaryLimitPercent: Double? { switch appState.selectedAgentProvider { case .claudeCode: return selectedUsage?.sevenDayPercent - case .codex: return selectedUsage?.twentyFourHourPercent + case .codex: return selectedUsage?.sevenDayPercent case .acp: return nil } } @@ -235,7 +235,7 @@ private struct MenuBarContentView: View { private var secondaryLimitResetsAt: Date? { switch appState.selectedAgentProvider { case .claudeCode: return selectedUsage?.sevenDayResetsAt - case .codex: return selectedUsage?.twentyFourHourResetsAt + case .codex: return selectedUsage?.sevenDayResetsAt case .acp: return nil } } diff --git a/RxCode/Services/CodexAppServer+Parsing.swift b/RxCode/Services/CodexAppServer+Parsing.swift index 1489873b..1f450605 100644 --- a/RxCode/Services/CodexAppServer+Parsing.swift +++ b/RxCode/Services/CodexAppServer+Parsing.swift @@ -69,16 +69,17 @@ extension CodexAppServer { guard !windows.isEmpty else { return nil } let fiveHour = windows.first { $0.durationMinutes == 300 } ?? windows.first - let twentyFourHour = windows.first { $0.durationMinutes == 1_440 } + let sevenDay = windows.first { $0.durationMinutes == 10_080 } ?? windows.first { $0.durationMinutes != fiveHour?.durationMinutes } ?? windows.dropFirst().first + let twentyFourHour = windows.first { $0.durationMinutes == 1_440 } return RateLimitUsage( fiveHourPercent: fiveHour?.percent ?? 0, - sevenDayPercent: 0, + sevenDayPercent: sevenDay?.percent ?? 0, twentyFourHourPercent: twentyFourHour?.percent, fiveHourResetsAt: fiveHour?.resetsAt, - sevenDayResetsAt: nil, + sevenDayResetsAt: sevenDay?.resetsAt, twentyFourHourResetsAt: twentyFourHour?.resetsAt ) } diff --git a/RxCode/Services/CodexAppServer+Process.swift b/RxCode/Services/CodexAppServer+Process.swift index 532a271b..37920c9d 100644 --- a/RxCode/Services/CodexAppServer+Process.swift +++ b/RxCode/Services/CodexAppServer+Process.swift @@ -27,7 +27,7 @@ extension CodexAppServer { if Self.idString(object["id"]) == "2", let result = object["result"] { let usage = Self.parseCodexRateLimits(from: result) if let usage { - logger.info("Codex rate limits 5h=\(usage.fiveHourPercent)% 24h=\(usage.twentyFourHourPercent ?? 0)%") + logger.info("Codex rate limits 5h=\(usage.fiveHourPercent)% 7d=\(usage.sevenDayPercent)%") } else { logger.warning("Codex account/rateLimits/read returned no parseable limits") } diff --git a/RxCode/Services/MobileSyncService+LiveActivity.swift b/RxCode/Services/MobileSyncService+LiveActivity.swift index a7dca3df..52808868 100644 --- a/RxCode/Services/MobileSyncService+LiveActivity.swift +++ b/RxCode/Services/MobileSyncService+LiveActivity.swift @@ -306,7 +306,9 @@ extension MobileSyncService { let snapshot = WidgetSnapshotPayload( jobs: jobCount, cc: usage?.cc, + ccWeekly: usage?.ccWeekly, codex: usage?.codex, + codexWeekly: usage?.codexWeekly, updatedAt: Date().timeIntervalSince1970 ) for device in devices { diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index b2692492..509b2ba0 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -105,9 +105,14 @@ final class MobileSyncService: ObservableObject { /// 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 + /// Supplies the current Claude Code / Codex usage for the widget /// background push. Set by `AppState`; `nil` before that. - var usageSnapshotProvider: (@MainActor () -> (cc: Double?, codex: Double?))? + var usageSnapshotProvider: (@MainActor () -> ( + cc: Double?, + ccWeekly: Double?, + codex: Double?, + codexWeekly: Double? + ))? /// Pure state machine behind the aggregate Live Activity — the tracked-job /// list and the streaming-session set. The folding logic lives here so it @@ -297,6 +302,16 @@ final class MobileSyncService: ObservableObject { await client.removePeer(pubkeyHex) } + /// Rename a paired device locally. + func renameDevice(_ device: PairedDevice, to newName: String) { + guard let index = pairedDevices.firstIndex(where: { $0.pubkeyHex == device.pubkeyHex }) else { return } + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + pairedDevices[index].displayName = trimmed + savePairedDevices() + logger.info("[MobileSync] renamed device mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) newName=\(trimmed, privacy: .public)") + } + // MARK: - Notification fan-out /// Send a notification to every paired device. diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 3a629e93..11a09667 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -23,6 +23,8 @@ struct MobileSettingsTab: View { @State private var customURLText: String @State private var testNotificationDeviceID: String? @State private var testNotificationAlert: TestNotificationAlert? + @State private var deviceBeingRenamed: PairedDevice? + @State private var renameText: String = "" init() { let current = MobileSyncService.shared.relayURL @@ -65,6 +67,26 @@ struct MobileSettingsTab: View { dismissButton: .default(Text("OK")) ) } + .alert( + "Rename Device", + isPresented: Binding( + get: { deviceBeingRenamed != nil }, + set: { if !$0 { deviceBeingRenamed = nil } } + ) + ) { + TextField("Device name", text: $renameText) + Button("Cancel", role: .cancel) { + deviceBeingRenamed = nil + } + Button("Save") { + if let device = deviceBeingRenamed { + sync.renameDevice(device, to: renameText) + } + deviceBeingRenamed = nil + } + } message: { + Text("Enter a new name for this device.") + } } private var headerSection: some View { @@ -295,6 +317,14 @@ struct MobileSettingsTab: View { } Spacer() testNotificationButton(for: device) + Button { + renameText = device.displayName + deviceBeingRenamed = device + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Rename device") Button(role: .destructive) { Task { await sync.unpair(device) } } label: { diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 3248c6e0..cb92b0c1 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -72,7 +72,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent var stored = RxCodeWidgetStore.load() if let jobs = snapshot.jobs { stored.jobCount = jobs } if let cc = snapshot.cc { stored.ccUsagePercent = cc } + if let ccWeekly = snapshot.ccWeekly { stored.ccWeeklyUsagePercent = ccWeekly } if let codex = snapshot.codex { stored.codexUsagePercent = codex } + if let codexWeekly = snapshot.codexWeekly { stored.codexWeeklyUsagePercent = codexWeekly } stored.updatedAt = snapshot.updatedAt RxCodeWidgetStore.save(stored) logger.info("[Widget] background push applied jobs=\(stored.jobCount, privacy: .public)") diff --git a/RxCodeMobile/State/MobileAppState+Persistence.swift b/RxCodeMobile/State/MobileAppState+Persistence.swift index 204854b5..fed18c12 100644 --- a/RxCodeMobile/State/MobileAppState+Persistence.swift +++ b/RxCodeMobile/State/MobileAppState+Persistence.swift @@ -84,13 +84,27 @@ extension MobileAppState { pubkeyHex: desktop.pubkeyHex, displayName: desktop.displayName, pairedAt: existing.pairedAt, - lastSeen: desktop.lastSeen ?? existing.lastSeen + lastSeen: desktop.lastSeen ?? existing.lastSeen, + relayURL: desktop.relayURL ?? existing.relayURL ) } else { pairedDesktops.append(desktop) } } + /// Rename a paired desktop locally. + func renamePairedDesktop(_ desktop: PairedDesktop, to newName: String) { + guard let index = pairedDesktops.firstIndex(where: { $0.pubkeyHex == desktop.pubkeyHex }) else { return } + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + pairedDesktops[index].displayName = trimmed + if desktop.pubkeyHex == pairedDesktopPubkey { + pairedDesktopName = trimmed + } + savePairedDesktops() + logger.info("[MobileSync] renamed desktop desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) newName=\(trimmed, privacy: .public)") + } + func acceptsActiveDesktopPayload(from pubkeyHex: String, type: String) -> Bool { guard pubkeyHex == pairedDesktopPubkey else { logger.info("[Pairing] ignoring \(type, privacy: .public) from inactive desktopKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index a172dd91..0ed3ed45 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -332,7 +332,9 @@ extension MobileAppState { let snapshot = RxCodeWidgetData( jobCount: jobCount, ccUsagePercent: desktopUsage?.claudeCode?.fiveHourPercent, + ccWeeklyUsagePercent: desktopUsage?.claudeCode?.sevenDayPercent, codexUsagePercent: desktopUsage?.codex?.fiveHourPercent, + codexWeeklyUsagePercent: desktopUsage?.codex?.sevenDayPercent, updatedAt: Date().timeIntervalSince1970 ) RxCodeWidgetStore.save(snapshot) diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index e2439100..8ef27ca7 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -9,6 +9,8 @@ struct MobileSettingsView: View { let showsDoneButton: Bool @State private var showPairingSheet = false @State private var desktopPendingRemoval: PairedDesktop? + @State private var desktopBeingRenamed: PairedDesktop? + @State private var renameText: String = "" @State private var modelDraft = "" @State private var acpClientDraft = "" @@ -88,6 +90,26 @@ struct MobileSettingsView: View { Text("This removes \(desktop.displayName.isEmpty ? "this Mac" : desktop.displayName) from this device. Other paired Macs stay available.") } } + .alert( + "Rename Mac", + isPresented: Binding( + get: { desktopBeingRenamed != nil }, + set: { if !$0 { desktopBeingRenamed = nil } } + ) + ) { + TextField("Mac name", text: $renameText) + Button("Cancel", role: .cancel) { + desktopBeingRenamed = nil + } + Button("Save") { + if let desktop = desktopBeingRenamed { + state.renamePairedDesktop(desktop, to: renameText) + } + desktopBeingRenamed = nil + } + } message: { + Text("Enter a new name for this Mac.") + } .onAppear { modelDraft = state.desktopSettings?.selectedModel ?? "" acpClientDraft = state.desktopSettings?.selectedACPClientId ?? "" @@ -151,6 +173,14 @@ struct MobileSettingsView: View { .buttonStyle(.borderless) .accessibilityLabel("Switch to \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") } + Button { + renameText = desktop.displayName + desktopBeingRenamed = desktop + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .accessibilityLabel("Rename \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") Button(role: .destructive) { desktopPendingRemoval = desktop } label: { @@ -231,14 +261,12 @@ struct MobileSettingsView: View { valueText: Self.percentText(codex.fiveHourPercent), caption: Self.resetCaption(codex.fiveHourResetsAt) ) - if let twentyFour = codex.twentyFourHourPercent { - MetricBar( - label: "24-hour limit", - percent: twentyFour, - valueText: Self.percentText(twentyFour), - caption: Self.resetCaption(codex.twentyFourHourResetsAt) - ) - } + MetricBar( + label: "7-day limit", + percent: codex.sevenDayPercent, + valueText: Self.percentText(codex.sevenDayPercent), + caption: Self.resetCaption(codex.sevenDayResetsAt) + ) } } } else { diff --git a/RxCodeWidget/RxCodeWidget.swift b/RxCodeWidget/RxCodeWidget.swift index a872431b..71515c11 100644 --- a/RxCodeWidget/RxCodeWidget.swift +++ b/RxCodeWidget/RxCodeWidget.swift @@ -7,11 +7,11 @@ // iOS app into the shared App Group and refreshed via background APNs pushes. // -import WidgetKit import SwiftUI +import WidgetKit /// RxCode terracotta accent (#D97757). -private let rxAccent = Color(red: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255) +private let rxAccent = Color(red: 0xd9 / 255, green: 0x77 / 255, blue: 0x57 / 255) // MARK: - Timeline @@ -24,7 +24,14 @@ struct RxCodeWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> RxCodeWidgetEntry { RxCodeWidgetEntry( date: Date(), - data: RxCodeWidgetData(jobCount: 2, ccUsagePercent: 45, codexUsagePercent: 12, updatedAt: 0) + data: RxCodeWidgetData( + jobCount: 2, + ccUsagePercent: 45, + ccWeeklyUsagePercent: 18, + codexUsagePercent: 12, + codexWeeklyUsagePercent: 7, + updatedAt: 0 + ) ) } @@ -58,11 +65,19 @@ struct RxCodeWidgetEntryView: View { } private var smallBody: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { jobsHeader Spacer(minLength: 0) - usageRow(label: "CC", percent: entry.data.ccUsagePercent) - usageRow(label: "CX", percent: entry.data.codexUsagePercent) + providerUsageRow( + label: "CC", + fiveHour: entry.data.ccUsagePercent, + weekly: entry.data.ccWeeklyUsagePercent + ) + providerUsageRow( + label: "CX", + fiveHour: entry.data.codexUsagePercent, + weekly: entry.data.codexWeeklyUsagePercent + ) } } @@ -76,9 +91,17 @@ struct RxCodeWidgetEntryView: View { .foregroundStyle(.tertiary) } Divider() - VStack(alignment: .leading, spacing: 12) { - usageRow(label: "Claude Code", percent: entry.data.ccUsagePercent) - usageRow(label: "Codex", percent: entry.data.codexUsagePercent) + VStack(alignment: .leading, spacing: 10) { + providerUsageRow( + label: "Claude Code", + fiveHour: entry.data.ccUsagePercent, + weekly: entry.data.ccWeeklyUsagePercent + ) + providerUsageRow( + label: "Codex", + fiveHour: entry.data.codexUsagePercent, + weekly: entry.data.codexWeeklyUsagePercent + ) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -107,18 +130,41 @@ struct RxCodeWidgetEntryView: View { } @ViewBuilder - private func usageRow(label: String, percent: Double?) -> some View { - VStack(alignment: .leading, spacing: 3) { - HStack { + private func providerUsageRow(label: String, fiveHour: Double?, weekly: Double?) -> some View { + VStack(alignment: .leading, spacing: family == .systemSmall ? 2 : 4) { + HStack(alignment: .firstTextBaseline) { 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) + // 5-hour usage with solid bar + usageBarRow( + period: "5h", + percent: fiveHour, + barStyle: .solid + ) + // 7-day usage with striped/dimmed bar + usageBarRow( + period: "7d", + percent: weekly, + barStyle: .dimmed + ) + } + } + + @ViewBuilder + private func usageBarRow(period: String, percent: Double?, barStyle: UsageBarStyle) -> some View { + HStack(spacing: family == .systemSmall ? 4 : 6) { + Text(period) + .font(.system(size: family == .systemSmall ? 8 : 9, weight: .medium, design: .rounded)) + .foregroundStyle(.tertiary) + .frame(width: family == .systemSmall ? 14 : 16, alignment: .trailing) + UsageBar(percent: percent, style: barStyle) + Text(percent.map { "\(Int($0.rounded()))%" } ?? "—") + .font(.system(size: family == .systemSmall ? 9 : 10, weight: .semibold, design: .rounded).monospacedDigit()) + .foregroundStyle(percent == nil ? .tertiary : (barStyle == .solid ? .primary : .secondary)) + .frame(width: family == .systemSmall ? 24 : 28, alignment: .trailing) } } @@ -131,20 +177,27 @@ struct RxCodeWidgetEntryView: View { } } +/// Style variants for usage bars to differentiate 5h vs 7d. +private enum UsageBarStyle { + case solid // 5-hour: full opacity, prominent + case dimmed // 7-day: reduced opacity, subtler +} + /// A thin capsule usage bar that tints toward red as utilization climbs. private struct UsageBar: View { let percent: Double? + var style: UsageBarStyle = .solid var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { - Capsule().fill(Color.secondary.opacity(0.22)) + Capsule().fill(Color.secondary.opacity(style == .solid ? 0.22 : 0.12)) Capsule() - .fill(tint) + .fill(tint.opacity(style == .solid ? 1.0 : 0.5)) .frame(width: geo.size.width * fraction) } } - .frame(height: 5) + .frame(height: style == .solid ? 6 : 4) } private var fraction: Double { @@ -179,12 +232,32 @@ struct RxCodeWidget: Widget { #Preview(as: .systemSmall) { RxCodeWidget() } timeline: { - RxCodeWidgetEntry(date: .now, data: RxCodeWidgetData(jobCount: 3, ccUsagePercent: 64, codexUsagePercent: 22, updatedAt: Date().timeIntervalSince1970)) + RxCodeWidgetEntry( + date: .now, + data: RxCodeWidgetData( + jobCount: 3, + ccUsagePercent: 64, + ccWeeklyUsagePercent: 31, + codexUsagePercent: 22, + codexWeeklyUsagePercent: 14, + 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)) + RxCodeWidgetEntry( + date: .now, + data: RxCodeWidgetData( + jobCount: 1, + ccUsagePercent: 91, + ccWeeklyUsagePercent: 67, + codexUsagePercent: nil, + codexWeeklyUsagePercent: nil, + updatedAt: Date().timeIntervalSince1970 + ) + ) } diff --git a/RxCodeWidget/RxCodeWidgetData.swift b/RxCodeWidget/RxCodeWidgetData.swift index a8ff7c25..c15964dc 100644 --- a/RxCodeWidget/RxCodeWidgetData.swift +++ b/RxCodeWidget/RxCodeWidgetData.swift @@ -20,13 +20,22 @@ struct RxCodeWidgetData: Codable, Equatable { var jobCount: Int /// Claude Code 5-hour utilization, 0...100. `nil` when not signed in. var ccUsagePercent: Double? + /// Claude Code 7-day utilization, 0...100. `nil` when not signed in. + var ccWeeklyUsagePercent: Double? /// Codex 5-hour utilization, 0...100. `nil` when not signed in. var codexUsagePercent: Double? + /// Codex 7-day utilization, 0...100. `nil` when not signed in. + var codexWeeklyUsagePercent: Double? /// When the desktop produced this snapshot, unix seconds. var updatedAt: Double static let empty = RxCodeWidgetData( - jobCount: 0, ccUsagePercent: nil, codexUsagePercent: nil, updatedAt: 0 + jobCount: 0, + ccUsagePercent: nil, + ccWeeklyUsagePercent: nil, + codexUsagePercent: nil, + codexWeeklyUsagePercent: nil, + updatedAt: 0 ) } From 396a178b8c44d8de636e57e65eb770ce221ed496 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 17:59:34 +0800 Subject: [PATCH 3/3] feat: add multiple relay server connection support --- .../MobileSyncService+EventDispatch.swift | 56 +- RxCode/Services/MobileSyncService.swift | 300 +++++++++-- RxCode/Views/Settings/MobileSettingsTab.swift | 484 ++++++++++++------ RxCode/Views/Tips/RxCodeTips.swift | 18 + .../State/MobileAppState+Persistence.swift | 20 +- RxCodeMobile/State/MobileAppState+Sync.swift | 18 +- RxCodeMobile/State/MobileAppState.swift | 20 +- RxCodeMobile/Views/MobileSettingsView.swift | 96 ++-- RxCodeMobile/Views/OnboardingView.swift | 20 +- RxCodeMobile/Views/RootView.swift | 29 ++ RxCodeMobile/Views/SyncLoadingView.swift | 134 ++++- 11 files changed, 898 insertions(+), 297 deletions(-) diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift index f086a28f..f04aadfb 100644 --- a/RxCode/Services/MobileSyncService+EventDispatch.swift +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -24,11 +24,60 @@ extension MobileSyncService { } } + /// Handle events from a specific relay server (multi-relay support). + func handle(event: RelayClient.Event, forServer server: SavedRelayServer) { + switch event { + case .stateChanged(let s): + relayConnectionStates[server.id] = s + // Update global connectionState to reflect aggregate status + updateAggregateConnectionState() + logger.info("[MobileSync] relay state=\(String(describing: s), privacy: .public) server=\(server.name, privacy: .public)") + case .deliveryFailed(let toHex): + logger.warning("[MobileSync] relay delivery failed to mobileKey=\(String(toHex.prefix(12)), privacy: .public) server=\(server.name, privacy: .public)") + case .inbound(let inbound): + handleInbound(inbound, fromServer: server) + } + } + + /// Update aggregate connection state based on all relay states. + private func updateAggregateConnectionState() { + let states = Array(relayConnectionStates.values) + if states.isEmpty { + connectionState = .disconnected + } else if states.contains(.connected) { + connectionState = .connected + } else if states.contains(.connecting) { + connectionState = .connecting + } else if let reconnecting = states.first(where: { + if case .reconnecting = $0 { return true } + return false + }) { + connectionState = reconnecting + } else { + connectionState = .disconnected + } + } + + /// Handle inbound messages from a specific relay server. + private func handleInbound(_ inbound: RelayClient.Inbound, fromServer server: SavedRelayServer) { + // Route to standard handler but track which server the message came from + // This allows pairing to know which relay was used + if case .pairRequest = inbound.payload { + pairingRelayServerID = server.id + } + handleInbound(inbound, viaClient: additionalClients[server.id]) + } + func handleInbound(_ inbound: RelayClient.Inbound) { + handleInbound(inbound, viaClient: nil) + } + + private func handleInbound(_ inbound: RelayClient.Inbound, viaClient: SyncClient?) { + let activeClient = viaClient ?? client switch inbound.payload { case .pairRequest(let req): pendingPairing = req - Task { try? await client.addPeer(req.mobilePubkeyHex) } + Task { try? await activeClient.addPeer(req.mobilePubkeyHex) } case .unpair: guard isPairedPeer(inbound.fromHex) else { return } handleRemoteUnpair(pubkeyHex: inbound.fromHex) @@ -327,7 +376,7 @@ extension MobileSyncService { ) case .ping: guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } - Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } + Task { try? await activeClient.send(.pong(PongPayload()), toHex: inbound.fromHex) } default: break } @@ -366,8 +415,9 @@ extension MobileSyncService { /// Send a payload to a single peer (used by AppState when replying to /// `request_snapshot` etc). func send(_ payload: Payload, toHex hex: String) async { + let targetClient = clientForPeer(hex) do { - try await client.send(payload, toHex: hex) + try await targetClient.send(payload, toHex: hex) logger.debug("[MobileSync] sent type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") } catch { logger.error("[MobileSync] send failed type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 509b2ba0..97f45c5d 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -74,6 +74,36 @@ enum MobilePushError: LocalizedError { } } +/// A saved relay server configuration, stored in UserDefaults. +struct SavedRelayServer: Codable, Identifiable, Hashable { + var id: UUID + var name: String + var url: String + var addedAt: Date + /// Whether this relay should be connected (user preference). + var isEnabled: Bool + + /// Human-readable relay host for display (e.g. "relay.example.com"). + var displayHost: String? { + guard let parsed = URL(string: url), let host = parsed.host else { return nil } + return host + } + + init(id: UUID = UUID(), name: String, url: String, addedAt: Date = .now, isEnabled: Bool = true) { + self.id = id + self.name = name + self.url = url + self.addedAt = addedAt + self.isEnabled = isEnabled + } +} + +/// Connection state for a single relay server. +struct RelayConnectionInfo: Identifiable { + let id: UUID // matches SavedRelayServer.id + var state: RelayClient.ConnectionState +} + /// Bridges the desktop app to paired mobile devices over the E2E-encrypted /// relay channel. Owns the long-term `DeviceIdentity`, the `SyncClient`, and /// the persistent paired-device list. @@ -87,14 +117,22 @@ final class MobileSyncService: ObservableObject { @Published var isPairing: Bool = false @Published var pendingPairing: PairRequestPayload? @Published var relayURL: URL + @Published var savedRelayServers: [SavedRelayServer] = [] + /// Connection state per relay server (keyed by SavedRelayServer.id). + @Published var relayConnectionStates: [UUID: RelayClient.ConnectionState] = [:] let logger = Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") let identity: DeviceIdentity var client: SyncClient + /// Additional clients for multi-relay support (keyed by SavedRelayServer.id). + var additionalClients: [UUID: SyncClient] = [:] + var additionalEventTasks: [UUID: Task] = [:] var subscribedSessions: [String: String] = [:] var eventTask: Task? var pairingToken: PairingToken? var pairingContinuation: CheckedContinuation? + /// The relay server ID currently being used for pairing. + var pairingRelayServerID: UUID? /// The single AppState reference is set in init order from RxCodeApp, /// because AppState owns the storage layer and the streaming loop. @@ -160,6 +198,7 @@ final class MobileSyncService: ObservableObject { } self.client = SyncClient(identity: identity, relayURL: initial) loadPairedDevices() + loadSavedRelayServers() } static func logFatalKeychain(_ error: Error) { @@ -175,34 +214,122 @@ final class MobileSyncService: ObservableObject { /// Begin (or resume) the relay connection. Safe to call multiple times. func start() { Task { @MainActor in - logger.info("[MobileSync] starting relay=\(self.relayURL.absoluteString, privacy: .public) pairedDevices=\(self.pairedDevices.count, privacy: .public) desktopKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - for device in pairedDevices { - do { - try await client.addPeer(device.pubkeyHex) - logger.info("[MobileSync] added paired peer mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - } catch { - logger.error("[MobileSync] failed to add paired peer mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } + // Start all enabled relay servers + for server in savedRelayServers where server.isEnabled { + await startRelayServer(server) } - // Subscribe to events BEFORE calling start() so we don't miss the - // initial .connecting / .connected state transitions. - let events = await client.events() - eventTask?.cancel() - eventTask = Task { @MainActor in - for await event in events { - self.handle(event: event) - } + + // If no saved servers, we have nothing to connect to + if savedRelayServers.isEmpty { + logger.info("[MobileSync] no relay servers configured — waiting for user to add one") } - await client.start() } } + /// Start connection to a specific relay server. + func startRelayServer(_ server: SavedRelayServer) async { + guard let url = URL(string: server.url) else { + logger.error("[MobileSync] invalid relay URL for server=\(server.name, privacy: .public)") + return + } + + // Check if already connected + if additionalClients[server.id] != nil { + logger.info("[MobileSync] relay already started server=\(server.name, privacy: .public)") + return + } + + logger.info("[MobileSync] starting relay server=\(server.name, privacy: .public) url=\(server.url, privacy: .public) desktopKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + + let newClient = SyncClient(identity: identity, relayURL: url) + additionalClients[server.id] = newClient + + // Add all paired devices that use this relay + for device in pairedDevices where Self.normalize(device.relayURL ?? "") == Self.normalize(server.url) { + do { + try await newClient.addPeer(device.pubkeyHex) + logger.info("[MobileSync] added paired peer mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) to relay=\(server.name, privacy: .public)") + } catch { + logger.error("[MobileSync] failed to add paired peer mobileKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + // Subscribe to events + let events = await newClient.events() + additionalEventTasks[server.id]?.cancel() + additionalEventTasks[server.id] = Task { @MainActor [weak self] in + for await event in events { + self?.handle(event: event, forServer: server) + } + } + + await newClient.start() + } + + /// Stop connection to a specific relay server. + func stopRelayServer(_ server: SavedRelayServer) async { + additionalEventTasks[server.id]?.cancel() + additionalEventTasks.removeValue(forKey: server.id) + + if let client = additionalClients.removeValue(forKey: server.id) { + await client.stop() + logger.info("[MobileSync] stopped relay server=\(server.name, privacy: .public)") + } + + relayConnectionStates.removeValue(forKey: server.id) + } + + /// Normalize a URL string for comparison. + static func normalize(_ urlString: String) -> String { + urlString.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + /// Find the SyncClient that handles a device based on its relay URL. + func clientForDevice(_ device: PairedDevice) -> SyncClient? { + guard let deviceRelayURL = device.relayURL else { return nil } + let normalizedDeviceURL = Self.normalize(deviceRelayURL) + for server in savedRelayServers { + if Self.normalize(server.url) == normalizedDeviceURL { + return additionalClients[server.id] + } + } + return nil + } + + /// Find the SyncClient that should be used for a paired mobile key. + /// + /// Multirelay connections keep one client per relay server. Replies must + /// go back through the relay used by the paired device, not the legacy + /// single-relay client, otherwise request/response flows time out. + func clientForPeer(_ pubkeyHex: String) -> SyncClient { + guard let device = pairedDevices.first(where: { $0.pubkeyHex == pubkeyHex }), + let deviceClient = clientForDevice(device) + else { + return client + } + return deviceClient + } + func stop() { eventTask?.cancel() eventTask = nil pendingJobsPushTask?.cancel() pendingJobsPushTask = nil - Task { await client.stop() } + + // Stop all relay server connections + for (serverID, task) in additionalEventTasks { + task.cancel() + additionalEventTasks.removeValue(forKey: serverID) + } + Task { + for (serverID, client) in additionalClients { + await client.stop() + additionalClients.removeValue(forKey: serverID) + } + } + relayConnectionStates.removeAll() } /// Update the configured relay URL, persist it, and reconnect. @@ -223,9 +350,17 @@ final class MobileSyncService: ObservableObject { // MARK: - Pairing /// Generate a fresh pairing token + show it as QR. Token expires in 2 min. - func beginPairing() -> PairingToken { + /// - Parameter server: The relay server to pair through. If nil, uses the first enabled server. + func beginPairing(viaServer server: SavedRelayServer? = nil) -> PairingToken? { + let targetServer = server ?? savedRelayServers.first(where: { $0.isEnabled }) + guard let targetServer else { + logger.warning("[Pairing] no relay server available for pairing") + return nil + } + + pairingRelayServerID = targetServer.id let token = PairingToken( - relayURL: relayURL.absoluteString, + relayURL: targetServer.url, desktopPubkeyHex: identity.publicKeyHex, oneTimeSecretHex: PairingToken.makeOneTimeSecret(), expiresAt: Date.now.addingTimeInterval(120), @@ -248,6 +383,12 @@ final class MobileSyncService: ObservableObject { /// Accept the pending pair request shown to the user in the pairing sheet. func acceptPendingPairing() async { guard let pending = pendingPairing else { return } + + // Get the relay server and client used for this pairing + let pairingServer = pairingRelayServerID.flatMap { id in savedRelayServers.first { $0.id == id } } + let pairingClient = pairingRelayServerID.flatMap { additionalClients[$0] } + let relayURLString = pairingServer?.url ?? pairingToken?.relayURL ?? "" + let device = PairedDevice( pubkeyHex: pending.mobilePubkeyHex, displayName: pending.displayName, @@ -256,34 +397,43 @@ final class MobileSyncService: ObservableObject { apnsEnvironment: nil, pairedAt: .now, lastSeen: .now, - relayURL: relayURL.absoluteString + relayURL: relayURLString ) pairedDevices.removeAll { $0.pubkeyHex == device.pubkeyHex } pairedDevices.append(device) savePairedDevices() - try? await client.addPeer(device.pubkeyHex) - let ack = PairAckPayload(accepted: true, desktopName: localDeviceName) - try? await client.send(.pairAck(ack), toHex: device.pubkeyHex) + + if let pairingClient { + try? await pairingClient.addPeer(device.pubkeyHex) + let ack = PairAckPayload(accepted: true, desktopName: localDeviceName) + try? await pairingClient.send(.pairAck(ack), toHex: device.pubkeyHex) + } + isPairing = false pendingPairing = nil pairingToken = nil + pairingRelayServerID = nil pairingContinuation?.resume(returning: .accepted(device)) pairingContinuation = nil - logger.info("[Pairing] accepted mobile=\(pending.displayName, privacy: .public) mobileKey=\(String(pending.mobilePubkeyHex.prefix(12)), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + logger.info("[Pairing] accepted mobile=\(pending.displayName, privacy: .public) mobileKey=\(String(pending.mobilePubkeyHex.prefix(12)), privacy: .public) relay=\(relayURLString, privacy: .public)") } func cancelPairing() { Task { if let pending = pendingPairing { - let ack = PairAckPayload(accepted: false, desktopName: localDeviceName, reason: "rejected") - try? await client.addPeer(pending.mobilePubkeyHex) - try? await client.send(.pairAck(ack), toHex: pending.mobilePubkeyHex) - await client.removePeer(pending.mobilePubkeyHex) + let pairingClient = pairingRelayServerID.flatMap { additionalClients[$0] } + if let pairingClient { + let ack = PairAckPayload(accepted: false, desktopName: localDeviceName, reason: "rejected") + try? await pairingClient.addPeer(pending.mobilePubkeyHex) + try? await pairingClient.send(.pairAck(ack), toHex: pending.mobilePubkeyHex) + await pairingClient.removePeer(pending.mobilePubkeyHex) + } } } isPairing = false pendingPairing = nil pairingToken = nil + pairingRelayServerID = nil pairingContinuation?.resume(returning: .cancelled) pairingContinuation = nil } @@ -298,8 +448,10 @@ final class MobileSyncService: ObservableObject { subscribedSessions.removeValue(forKey: pubkeyHex) // Notify mobile and clean up peer connection (best-effort, ignore failures) - try? await client.send(.unpair(UnpairPayload(reason: "desktop")), toHex: pubkeyHex) - await client.removePeer(pubkeyHex) + if let deviceClient = clientForDevice(device) { + try? await deviceClient.send(.unpair(UnpairPayload(reason: "desktop")), toHex: pubkeyHex) + await deviceClient.removePeer(pubkeyHex) + } } /// Rename a paired device locally. @@ -314,6 +466,13 @@ final class MobileSyncService: ObservableObject { // MARK: - Notification fan-out + /// Broadcast a message to all connected relay clients. + private func broadcastToAllClients(_ payload: RxCodeSync.Payload) async { + for client in additionalClients.values { + await client.broadcast(payload) + } + } + /// Send a notification to every paired device. /// /// Two delivery paths run for each broadcast: @@ -323,7 +482,7 @@ final class MobileSyncService: ObservableObject { /// offline, so a finished thread still surfaces a banner. func broadcastNotification(_ payload: NotificationPayload) { Task { - await client.broadcast(.notification(payload)) + await broadcastToAllClients(.notification(payload)) await fanoutAPNs(payload) } } @@ -334,11 +493,15 @@ final class MobileSyncService: ObservableObject { func fanoutAPNs(_ payload: NotificationPayload) async { let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } guard !devices.isEmpty else { return } - guard let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[APNs] cannot derive push endpoint from relay URL \(self.relayURL.absoluteString, privacy: .public)") - return - } + for device in devices { + // Find the relay URL for this device + guard let relayURLString = device.relayURL, + let relayURL = URL(string: relayURLString), + let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[APNs] cannot derive push endpoint for device=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + continue + } do { try await sendAPNsPush(payload, to: device, pushURL: pushURL) } catch { @@ -354,7 +517,9 @@ final class MobileSyncService: ObservableObject { guard let token = device.apnsToken, !token.isEmpty else { throw MobilePushError.missingDeviceToken } - guard let peer = await client.peer(forHex: device.pubkeyHex) else { + // Find the client for this device's relay + let deviceClient = clientForDevice(device) + guard let peer = await deviceClient?.peer(forHex: device.pubkeyHex) else { throw MobilePushError.unknownPeer } @@ -409,10 +574,14 @@ final class MobileSyncService: ObservableObject { guard let token = device.apnsToken, !token.isEmpty else { throw MobilePushError.missingDeviceToken } - guard let peer = await client.peer(forHex: device.pubkeyHex) else { + // Find the client for this device's relay + let deviceClient = clientForDevice(device) + guard let peer = await deviceClient?.peer(forHex: device.pubkeyHex) else { throw MobilePushError.unknownPeer } - guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + guard let relayURLString = device.relayURL, + let deviceRelayURL = URL(string: relayURLString), + let pushURL = Self.pushEndpointURL(from: deviceRelayURL) else { throw MobilePushError.invalidRelayURL } @@ -483,7 +652,7 @@ final class MobileSyncService: ObservableObject { summary: summary, previousSessionID: previousSessionID ) - await client.broadcast(.sessionUpdate(payload)) + await broadcastToAllClients(.sessionUpdate(payload)) } updateJobTracking( sessionID: sessionID, @@ -499,13 +668,13 @@ final class MobileSyncService: ObservableObject { /// surface the same queue banner + question sheet. func broadcastQuestionQueue(_ questions: [PendingQuestionPayload]) { Task { - await client.broadcast(.questionQueue(QuestionQueuePayload(questions: questions))) + await broadcastToAllClients(.questionQueue(QuestionQueuePayload(questions: questions))) } } func broadcastRunTaskUpdate(_ task: MobileRunTaskSnapshot) { Task { - await client.broadcast(.runTaskUpdate(RunTaskUpdatePayload(task: task))) + await broadcastToAllClients(.runTaskUpdate(RunTaskUpdatePayload(task: task))) } } @@ -534,6 +703,53 @@ final class MobileSyncService: ObservableObject { } } + // MARK: - Saved Relay Servers + + private static let savedRelayServersKey = "mobileSync.savedRelayServers" + + func loadSavedRelayServers() { + guard let data = UserDefaults.standard.data(forKey: Self.savedRelayServersKey) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + savedRelayServers = (try? decoder.decode([SavedRelayServer].self, from: data)) ?? [] + } + + func saveSavedRelayServers() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + do { + let data = try encoder.encode(savedRelayServers) + UserDefaults.standard.set(data, forKey: Self.savedRelayServersKey) + } catch { + logger.error("save relay servers: \(error.localizedDescription)") + } + } + + func addRelayServer(_ server: SavedRelayServer) { + // Avoid duplicates by URL + let normalizedURL = server.url.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if savedRelayServers.contains(where: { + $0.url.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "/")) == normalizedURL + }) { + return + } + savedRelayServers.append(server) + saveSavedRelayServers() + logger.info("[MobileSync] added relay server name=\(server.name, privacy: .public) url=\(server.url, privacy: .public)") + } + + func removeRelayServer(_ server: SavedRelayServer) { + savedRelayServers.removeAll { $0.id == server.id } + saveSavedRelayServers() + logger.info("[MobileSync] removed relay server name=\(server.name, privacy: .public)") + } + + func updateRelayServer(_ server: SavedRelayServer) { + guard let index = savedRelayServers.firstIndex(where: { $0.id == server.id }) else { return } + savedRelayServers[index] = server + saveSavedRelayServers() + } + static func pushEndpointURL(from relayURL: URL) -> URL? { guard var components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) else { return nil diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 11a09667..6f55c4c9 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -1,47 +1,21 @@ -import SwiftUI import CoreImage.CIFilterBuiltins import RxCodeCore import RxCodeSync +import SwiftUI import TipKit -/// How the relay server is chosen in the Mobile settings tab. -private enum RelayMode: Hashable { - /// One of the RxLab-hosted relays from the published catalog. - case hosted - /// A free-form relay URL typed by the user (e.g. a self-hosted relay). - case custom -} - /// "Mobile" tab in SettingsView. Lists paired iOS / iPadOS devices and lets /// the user pair a new one via QR code or unpair existing devices. struct MobileSettingsTab: View { @StateObject private var sync = MobileSyncService.shared @State private var catalog = RelayPresetCatalog.shared @State private var showPairingSheet = false - @State private var relayMode: RelayMode - @State private var selectedPresetID: String? - @State private var customURLText: String + @State private var showAddRelaySheet = false @State private var testNotificationDeviceID: String? @State private var testNotificationAlert: TestNotificationAlert? @State private var deviceBeingRenamed: PairedDevice? @State private var renameText: String = "" - - init() { - let current = MobileSyncService.shared.relayURL - let match = RelayPresetCatalog.bundledPresets.first { - MobileSettingsTab.normalize($0.url) == MobileSettingsTab.normalize(current.absoluteString) - } - _customURLText = State(initialValue: current.absoluteString) - if let match { - _relayMode = State(initialValue: .hosted) - _selectedPresetID = State(initialValue: match.id) - } else { - _relayMode = State(initialValue: .custom) - let fallback = RelayPresetCatalog.bundledPresets.first { $0.recommended == true } - ?? RelayPresetCatalog.bundledPresets.first - _selectedPresetID = State(initialValue: fallback?.id) - } - } + @State private var relayToEdit: SavedRelayServer? var body: some View { ScrollView { @@ -55,10 +29,15 @@ struct MobileSettingsTab: View { } .task { await catalog.refresh() - reconcileWithCatalog() } .sheet(isPresented: $showPairingSheet) { - PairingSheet(sync: sync) + PairingSheet(sync: sync, catalog: catalog) + } + .sheet(isPresented: $showAddRelaySheet) { + RelayServerEditorSheet(sync: sync, catalog: catalog, existingServer: nil) + } + .sheet(item: $relayToEdit) { server in + RelayServerEditorSheet(sync: sync, catalog: catalog, existingServer: server) } .alert(item: $testNotificationAlert) { alert in Alert( @@ -102,156 +81,147 @@ struct MobileSettingsTab: View { private var relaySection: some View { VStack(alignment: .leading, spacing: 10) { - Text("Relay server") - .font(.headline) - Text("All sync traffic flows through this relay, end-to-end encrypted. Use an RxLab-hosted relay, or point RxCode at your own — self-host with github.com/rxlab/rxcode-relay.") + HStack { + Text("Relay servers") + .font(.headline) + Spacer() + Button { + showAddRelaySheet = true + } label: { + Label("Add server", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .popoverTip(RxCodeTips.AddRelayServerTip(), arrowEdge: .top) + } + Text("All sync traffic flows through relay servers, end-to-end encrypted. Add relay servers to connect with your mobile devices remotely.") .font(.caption) .foregroundStyle(.secondary) - Picker("Relay mode", selection: $relayMode) { - Text("Hosted server").tag(RelayMode.hosted) - Text("Custom URL").tag(RelayMode.custom) - } - .pickerStyle(.segmented) - .labelsHidden() - - switch relayMode { - case .hosted: - hostedRelayPicker - case .custom: - customRelayField - } + // List of relay servers with connection status + VStack(spacing: 8) { + ForEach(sync.savedRelayServers) { server in + relayServerRow(server) + } - HStack(spacing: 10) { - Button("Apply", action: applyRelay) - .disabled(!canApply) - if relayMode == .hosted, catalog.isLoading { - ProgressView().controlSize(.small) + if sync.savedRelayServers.isEmpty { + Label("No relay servers", systemImage: "antenna.radiowaves.left.and.right.slash") } - Spacer() } - - connectionBadge } } - @ViewBuilder - private var hostedRelayPicker: some View { - if catalog.presets.isEmpty { - Text("No hosted relays are available right now. Switch to Custom URL to enter one manually.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Picker("Hosted relay", selection: $selectedPresetID) { - ForEach(catalog.presets) { preset in - Text(preset.name).tag(Optional(preset.id)) - } - } - .labelsHidden() + private func relayServerRow(_ server: SavedRelayServer) -> some View { + let connectionState = sync.relayConnectionStates[server.id] ?? .disconnected + let isConnected = connectionState == .connected + return HStack { + // Connection status indicator + connectionStatusIcon(for: connectionState) + .frame(width: 20) - if let preset = selectedPreset { - VStack(alignment: .leading, spacing: 2) { - Text(preset.url) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(server.name) + .fontWeight(.medium) + if !server.isEnabled { + Text("Disabled") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2), in: Capsule()) + } + } + HStack(spacing: 4) { + Text(server.displayHost ?? server.url) .font(.caption) .monospaced() .foregroundStyle(.secondary) - .textSelection(.enabled) - if let description = preset.description { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - } + Text("•") + .foregroundStyle(.secondary) + connectionStatusText(for: connectionState) + .font(.caption) } } - } - } + Spacer() - private var customRelayField: some View { - VStack(alignment: .leading, spacing: 4) { - TextField("wss://host:port", text: $customURLText) - .textFieldStyle(.roundedBorder) - if !customURLText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - parsedCustomURL == nil { - Text("Enter a valid ws:// or wss:// URL.") - .font(.caption) - .foregroundStyle(.red) + // Toggle enabled state + Toggle("", isOn: Binding( + get: { server.isEnabled }, + set: { newValue in + var updated = server + updated.isEnabled = newValue + sync.updateRelayServer(updated) + if newValue { + Task { await sync.startRelayServer(updated) } + } else { + Task { await sync.stopRelayServer(updated) } + } + } + )) + .toggleStyle(.switch) + .controlSize(.small) + + Button { + relayToEdit = server + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + Button(role: .destructive) { + Task { + await sync.stopRelayServer(server) + sync.removeRelayServer(server) + } + } label: { + Image(systemName: "trash") } + .buttonStyle(.borderless) } + .padding(10) + .background(isConnected ? Color.green.opacity(0.1) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) } - // MARK: - Relay selection helpers - - private var selectedPreset: RelayPreset? { - catalog.presets.first { $0.id == selectedPresetID } - } - - /// The custom URL parsed and validated as a relay endpoint, or `nil`. - private var parsedCustomURL: URL? { - let trimmed = customURLText.trimmingCharacters(in: .whitespacesAndNewlines) - guard let url = URL(string: trimmed), - let scheme = url.scheme?.lowercased(), - scheme == "ws" || scheme == "wss", - url.host?.isEmpty == false else { return nil } - return url - } - - /// The relay URL implied by the current mode and selection. - private var pendingRelayURL: URL? { - switch relayMode { - case .hosted: selectedPreset?.relayURL - case .custom: parsedCustomURL + @ViewBuilder + private func connectionStatusIcon(for state: RelayClient.ConnectionState) -> some View { + switch state { + case .disconnected: + Image(systemName: "circle.slash") + .foregroundStyle(.secondary) + case .connecting: + ProgressView() + .controlSize(.small) + case .connected: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .reconnecting: + Image(systemName: "arrow.clockwise.circle") + .foregroundStyle(.orange) } } - private var canApply: Bool { - guard let pending = pendingRelayURL else { return false } - return Self.normalize(pending.absoluteString) != Self.normalize(sync.relayURL.absoluteString) - } - - private func applyRelay() { - guard let url = pendingRelayURL else { return } - sync.updateRelay(url: url) - } - - /// After the live catalog loads, keep a valid preset selected and upgrade a - /// "custom" selection to the hosted picker when the active relay turns out - /// to be a freshly published preset. - private func reconcileWithCatalog() { - if selectedPreset == nil { - selectedPresetID = catalog.presets.first { $0.recommended == true }?.id - ?? catalog.presets.first?.id - } - guard relayMode == .custom, - customURLText == sync.relayURL.absoluteString, - let match = catalog.presets.first(where: { - Self.normalize($0.url) == Self.normalize(sync.relayURL.absoluteString) - }) - else { return } - relayMode = .hosted - selectedPresetID = match.id - } - - /// Normalize a URL string for equality checks (lowercased, no trailing `/`). - static func normalize(_ urlString: String) -> String { - var value = urlString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - while value.hasSuffix("/") { value.removeLast() } - return value - } - @ViewBuilder - private var connectionBadge: some View { - switch sync.connectionState { + private func connectionStatusText(for state: RelayClient.ConnectionState) -> some View { + switch state { case .disconnected: - Label("Disconnected", systemImage: "circle.slash").foregroundStyle(.secondary) + Text("Disconnected") + .foregroundStyle(.secondary) case .connecting: - Label("Connecting…", systemImage: "circle.dotted").foregroundStyle(.orange) + Text("Connecting…") + .foregroundStyle(.orange) case .connected: - Label("Connected", systemImage: "checkmark.circle.fill").foregroundStyle(.green) - case .reconnecting(let s): - Label("Reconnecting in \(s)s", systemImage: "arrow.clockwise.circle").foregroundStyle(.orange) + Text("Connected") + .foregroundStyle(.green) + case .reconnecting(let seconds): + Text("Reconnecting in \(seconds)s") + .foregroundStyle(.orange) } } + /// Check if any relay server is connected. + private var hasConnectedRelay: Bool { + sync.relayConnectionStates.values.contains(.connected) + } + private var pairedSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -264,8 +234,8 @@ struct MobileSettingsTab: View { Label("Pair new device", systemImage: "plus.circle.fill") } .buttonStyle(.borderedProminent) - .disabled(sync.connectionState != .connected) - .help(sync.connectionState == .connected ? "" : "Connect to the relay before pairing a device.") + .disabled(!hasConnectedRelay) + .help(hasConnectedRelay ? "" : "Connect to a relay server before pairing a device.") .popoverTip(RxCodeTips.MobileConnectionTip(), arrowEdge: .top) } @@ -391,6 +361,151 @@ private struct TestNotificationAlert: Identifiable { let message: String } +// MARK: - Relay Server Editor Sheet + +private struct RelayServerEditorSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var sync: MobileSyncService + @State var catalog: RelayPresetCatalog + let existingServer: SavedRelayServer? + + @State private var name: String = "" + @State private var url: String = "" + @State private var selectedPresetID: String? + @State private var usePreset: Bool = false + + var body: some View { + VStack(spacing: 16) { + Text(existingServer == nil ? "Add Relay Server" : "Edit Relay Server") + .font(.title2) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 12) { + // Option to pick from presets + if !catalog.presets.isEmpty { + Toggle("Use hosted relay", isOn: $usePreset) + + if usePreset { + Picker("Select relay", selection: $selectedPresetID) { + Text("Choose…").tag(nil as String?) + ForEach(catalog.presets) { preset in + Text(preset.name).tag(Optional(preset.id)) + } + } + .labelsHidden() + + if let preset = catalog.presets.first(where: { $0.id == selectedPresetID }) { + Text(preset.url) + .font(.caption) + .monospaced() + .foregroundStyle(.secondary) + } + } + } + + if !usePreset { + VStack(alignment: .leading, spacing: 4) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("My Relay Server", text: $name) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 4) { + Text("URL") + .font(.caption) + .foregroundStyle(.secondary) + TextField("wss://relay.example.com", text: $url) + .textFieldStyle(.roundedBorder) + if !url.isEmpty && !isValidURL { + Text("Enter a valid ws:// or wss:// URL.") + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + + HStack { + Button("Cancel", role: .cancel) { + dismiss() + } + Spacer() + Button(existingServer == nil ? "Add" : "Save") { + save() + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!canSave) + } + } + .frame(width: 340) + .padding(24) + .onAppear { + if let server = existingServer { + name = server.name + url = server.url + } + } + } + + private var isValidURL: Bool { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = URL(string: trimmed), + let scheme = parsed.scheme?.lowercased(), + scheme == "ws" || scheme == "wss", + parsed.host?.isEmpty == false else { return false } + return true + } + + private var canSave: Bool { + if usePreset { + return selectedPresetID != nil + } + return !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && isValidURL + } + + private func save() { + if usePreset, let presetID = selectedPresetID, + let preset = catalog.presets.first(where: { $0.id == presetID }) + { + let server = SavedRelayServer( + name: preset.name, + url: preset.url + ) + sync.addRelayServer(server) + // Start connection to the new server + Task { await sync.startRelayServer(server) } + } else { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + + if let existing = existingServer { + var updated = existing + updated.name = trimmedName + updated.url = trimmedURL + sync.updateRelayServer(updated) + // Restart if URL changed + if existing.url != trimmedURL { + Task { + await sync.stopRelayServer(existing) + await sync.startRelayServer(updated) + } + } + } else { + let server = SavedRelayServer( + name: trimmedName, + url: trimmedURL + ) + sync.addRelayServer(server) + // Start connection to the new server + Task { await sync.startRelayServer(server) } + } + } + } +} + // MARK: - Pairing sheet private struct PairingSheet: View { @@ -399,6 +514,27 @@ private struct PairingSheet: View { @State private var token: PairingToken? @State private var qrImage: NSImage? @State private var autoReloadTask: Task? + @State private var selectedServerID: UUID? + + init(sync: MobileSyncService, catalog: RelayPresetCatalog) { + self.sync = sync + // Select first connected server by default + let firstConnected = sync.savedRelayServers.first { server in + sync.relayConnectionStates[server.id] == .connected + } + self._selectedServerID = State(initialValue: firstConnected?.id ?? sync.savedRelayServers.first?.id) + } + + /// Only show servers that are connected. + private var connectedServers: [SavedRelayServer] { + sync.savedRelayServers.filter { server in + sync.relayConnectionStates[server.id] == .connected + } + } + + private var selectedServer: SavedRelayServer? { + sync.savedRelayServers.first { $0.id == selectedServerID } + } var body: some View { VStack(spacing: 18) { @@ -423,18 +559,27 @@ private struct PairingSheet: View { Spacer() } } - .frame(width: 360) + .frame(width: 400) .padding(24) .onAppear { startPairing() } .onDisappear { autoReloadTask?.cancel() autoReloadTask = nil } + .onChange(of: selectedServerID) { _, _ in + startPairing() + } } private func startPairing() { autoReloadTask?.cancel() - let fresh = sync.beginPairing() + guard let server = selectedServer, + let fresh = sync.beginPairing(viaServer: server) + else { + token = nil + qrImage = nil + return + } token = fresh qrImage = nil if let qrString = try? fresh.qrString() { @@ -455,7 +600,32 @@ private struct PairingSheet: View { @ViewBuilder private func qrView(_ image: NSImage) -> some View { - VStack(spacing: 10) { + VStack(spacing: 12) { + // Relay server picker (only connected servers) + if connectedServers.count > 1 { + HStack { + Text("Relay:") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Relay server", selection: $selectedServerID) { + ForEach(connectedServers) { server in + Text(server.name).tag(Optional(server.id)) + } + } + .labelsHidden() + .frame(maxWidth: 200) + } + } else if let server = selectedServer { + HStack(spacing: 4) { + Text("Via:") + .font(.caption) + .foregroundStyle(.secondary) + Text(server.name) + .font(.caption) + .fontWeight(.medium) + } + } + Image(nsImage: image) .interpolation(.none) .resizable() @@ -470,7 +640,7 @@ private struct PairingSheet: View { Button { startPairing() } label: { - Label("Force reload", systemImage: "arrow.clockwise") + Label("Regenerate", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) } diff --git a/RxCode/Views/Tips/RxCodeTips.swift b/RxCode/Views/Tips/RxCodeTips.swift index 38e35667..55cf2e95 100644 --- a/RxCode/Views/Tips/RxCodeTips.swift +++ b/RxCode/Views/Tips/RxCodeTips.swift @@ -74,6 +74,24 @@ enum RxCodeTips { } } + struct AddRelayServerTip: Tip { + var title: Text { + Text("Add a relay server") + } + + var message: Text? { + Text("Connect to relay servers to sync with your mobile devices remotely. You can add multiple relays and connect to all of them simultaneously.") + } + + var image: Image? { + Image(systemName: "antenna.radiowaves.left.and.right") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + struct SummarizationModelTip: Tip { var title: Text { Text("Set the summarization model") diff --git a/RxCodeMobile/State/MobileAppState+Persistence.swift b/RxCodeMobile/State/MobileAppState+Persistence.swift index fed18c12..4840933e 100644 --- a/RxCodeMobile/State/MobileAppState+Persistence.swift +++ b/RxCodeMobile/State/MobileAppState+Persistence.swift @@ -78,7 +78,9 @@ extension MobileAppState { } func upsertPairedDesktop(_ desktop: PairedDesktop) { - if let index = pairedDesktops.firstIndex(where: { $0.pubkeyHex == desktop.pubkeyHex }) { + // Match by composite id (pubkey + relay) so the same Mac paired via + // different relays creates distinct entries instead of replacing. + if let index = pairedDesktops.firstIndex(where: { $0.id == desktop.id }) { let existing = pairedDesktops[index] pairedDesktops[index] = PairedDesktop( pubkeyHex: desktop.pubkeyHex, @@ -94,7 +96,7 @@ extension MobileAppState { /// Rename a paired desktop locally. func renamePairedDesktop(_ desktop: PairedDesktop, to newName: String) { - guard let index = pairedDesktops.firstIndex(where: { $0.pubkeyHex == desktop.pubkeyHex }) else { return } + guard let index = pairedDesktops.firstIndex(where: { $0.id == desktop.id }) else { return } let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } pairedDesktops[index].displayName = trimmed @@ -115,8 +117,12 @@ extension MobileAppState { func removePairedDesktopAfterRemoteUnpair(_ desktop: PairedDesktop) async { let wasActive = desktop.pubkeyHex == pairedDesktopPubkey - pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } - await client.removePeer(desktop.pubkeyHex) + pairedDesktops.removeAll { $0.id == desktop.id } + // Only remove the crypto peer if no other pairings use the same pubkey + // (e.g., same Mac paired via different relays). + if !pairedDesktops.contains(where: { $0.pubkeyHex == desktop.pubkeyHex }) { + await client.removePeer(desktop.pubkeyHex) + } if wasActive { clearDesktopMirror() setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) @@ -134,8 +140,10 @@ extension MobileAppState { var seen: Set = [] var result: [PairedDesktop] = [] for desktop in desktops { - guard !desktop.pubkeyHex.isEmpty, !seen.contains(desktop.pubkeyHex) else { continue } - seen.insert(desktop.pubkeyHex) + // Use composite id (pubkey + relay) for deduplication so the same + // Mac paired via different relays appears as distinct entries. + guard !desktop.pubkeyHex.isEmpty, !seen.contains(desktop.id) else { continue } + seen.insert(desktop.id) result.append(desktop) } return result diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index 0ed3ed45..d707de2e 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -348,9 +348,14 @@ extension MobileAppState { } func switchPairedDesktop(_ desktop: PairedDesktop) async { - guard pairedDesktops.contains(where: { $0.pubkeyHex == desktop.pubkeyHex }) else { return } - guard desktop.pubkeyHex != pairedDesktopPubkey else { return } + guard pairedDesktops.contains(where: { $0.id == desktop.id }) else { return } + // Allow switching even to the same pubkey if it's a different relay pairing + guard desktop.id != activePairedDesktop?.id else { return } clearDesktopMirror() + // Switch to the relay this pairing uses, then set the active desktop + if let relayString = desktop.relayURL, let relayURL = URL(string: relayString) { + await updateRelayForPairingIfNeeded(relayURL) + } setActiveDesktop(pubkeyHex: desktop.pubkeyHex) savePairedDesktops() try? await client.addPeer(desktop.pubkeyHex) @@ -359,11 +364,11 @@ extension MobileAppState { } func removePairedDesktop(_ desktop: PairedDesktop) async { - let wasActive = desktop.pubkeyHex == pairedDesktopPubkey + let wasActive = desktop.id == activePairedDesktop?.id let pubkeyHex = desktop.pubkeyHex // Optimistically remove from UI first for immediate feedback - pairedDesktops.removeAll { $0.pubkeyHex == pubkeyHex } + pairedDesktops.removeAll { $0.id == desktop.id } if wasActive { clearDesktopMirror() @@ -375,7 +380,10 @@ extension MobileAppState { // Notify desktop and clean up peer connection (best-effort, ignore failures) try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: pubkeyHex) - await client.removePeer(pubkeyHex) + // Only remove the crypto peer if no other pairings use the same pubkey + if !pairedDesktops.contains(where: { $0.pubkeyHex == pubkeyHex }) { + await client.removePeer(pubkeyHex) + } if wasActive, isPaired { await requestSnapshot() diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 5da4489a..599c4a36 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -26,7 +26,14 @@ struct PairedDesktop: Codable, Identifiable, Equatable, Hashable { /// The relay server URL this desktop was paired through. var relayURL: String? - var id: String { pubkeyHex } + /// Composite id: same Mac paired via different relays produces distinct entries. + var id: String { + let normalizedRelay = (relayURL ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return "\(pubkeyHex)::\(normalizedRelay)" + } /// Human-readable relay host for display (e.g. "relay.example.com"). var relayDisplayName: String? { @@ -249,7 +256,16 @@ final class MobileAppState: ObservableObject { var localPublicKeyHex: String { identity.publicKeyHex } var activePairedDesktop: PairedDesktop? { - pairedDesktops.first { $0.pubkeyHex == pairedDesktopPubkey } + // Match both pubkey and relay to identify the correct pairing when the + // same Mac is paired via multiple relays. + let currentRelay = relayURL.absoluteString.lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return pairedDesktops.first { desktop in + guard desktop.pubkeyHex == pairedDesktopPubkey else { return false } + let desktopRelay = (desktop.relayURL ?? "").lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return desktopRelay == currentRelay + } ?? pairedDesktops.first { $0.pubkeyHex == pairedDesktopPubkey } } func start() { diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 8ef27ca7..149bfa6e 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -40,8 +40,6 @@ struct MobileSettingsView: View { if state.isPaired { desktopConfigurationSection } - - pairNewSection } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) @@ -128,6 +126,12 @@ struct MobileSettingsView: View { ForEach(state.pairedDesktops) { desktop in pairedMacRow(desktop) } + Button { + showPairingSheet = true + } label: { + Label("Pair New Mac", systemImage: "plus.circle") + } + .popoverTip(MobileTips.PairingTip(), arrowEdge: .top) HStack { Text("Connection") Spacer() @@ -141,53 +145,54 @@ struct MobileSettingsView: View { } private func pairedMacRow(_ desktop: PairedDesktop) -> some View { - HStack(spacing: 12) { - Image(systemName: "desktopcomputer") - .frame(width: 22) - VStack(alignment: .leading, spacing: 2) { - Text(desktop.displayName.isEmpty ? "Unknown Mac" : desktop.displayName) - .foregroundStyle(.primary) - HStack(spacing: 6) { - Text("Paired \(desktop.pairedAt, format: .relative(presentation: .named))") - if let relay = desktop.relayDisplayName { - Text("•") - Label(relay, systemImage: "antenna.radiowaves.left.and.right") - .labelStyle(.titleOnly) + Button { + if desktop.id != state.activePairedDesktop?.id { + Task { await state.switchPairedDesktop(desktop) } + } + } label: { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(desktop.displayName.isEmpty ? "Unknown Mac" : desktop.displayName) + .foregroundStyle(.primary) + HStack(spacing: 6) { + Text("Paired \(desktop.pairedAt, format: .relative(presentation: .named))") + if let relay = desktop.relayDisplayName { + Text("•") + Label(relay, systemImage: "antenna.radiowaves.left.and.right") + .labelStyle(.titleOnly) + } } + .font(.caption) + .foregroundStyle(.secondary) } - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if desktop.pubkeyHex == state.pairedDesktopPubkey { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityLabel("Active") - } else { - Button { - Task { await state.switchPairedDesktop(desktop) } - } label: { - Label("Switch", systemImage: "arrow.triangle.2.circlepath") - .labelStyle(.iconOnly) + Spacer() + if desktop.id == state.activePairedDesktop?.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityLabel("Active") } - .buttonStyle(.borderless) - .accessibilityLabel("Switch to \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(desktop.id == state.activePairedDesktop?.id + ? "\(desktop.displayName.isEmpty ? "Mac" : desktop.displayName), Active" + : "Switch to \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + desktopPendingRemoval = desktop + } label: { + Label("Remove", systemImage: "trash") } Button { renameText = desktop.displayName desktopBeingRenamed = desktop } label: { - Image(systemName: "pencil") - } - .buttonStyle(.borderless) - .accessibilityLabel("Rename \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") - Button(role: .destructive) { - desktopPendingRemoval = desktop - } label: { - Image(systemName: "trash") + Label("Rename", systemImage: "pencil") } - .buttonStyle(.borderless) - .accessibilityLabel("Remove \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") + .tint(.orange) } } @@ -218,16 +223,7 @@ struct MobileSettingsView: View { } } - private var pairNewSection: some View { - Section { - Button { - showPairingSheet = true - } label: { - Label("Pair New Mac", systemImage: "plus.circle") - } - .popoverTip(MobileTips.PairingTip(), arrowEdge: .top) - } - } + /// Agent rate-limit usage mirrored from the paired desktop. Renders one /// section per provider that reported usage; falls back to a hint when the diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift index 3410dda3..44890311 100644 --- a/RxCodeMobile/Views/OnboardingView.swift +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -326,10 +326,10 @@ struct OnboardingView: View { Task { onboardingLogger.info("starting pair task displayName=\(displayName, privacy: .public)") await state.pair(with: token, displayName: displayName) - onboardingLogger.info("pair task finished isPaired=\(state.isPaired, privacy: .public)") - if state.isPaired, state.pairedDesktopPubkey == token.desktopPubkeyHex { - onPairingCompleted?() - } + // pair() only sends the request; wait for the desktop to accept. + // The pairingStatus changes to .idle on success or .failed on error. + onboardingLogger.info("pair request sent, waiting for ack...") + await waitForPairingResult(expectedPubkey: token.desktopPubkeyHex) } } catch { onboardingLogger.error("token parse failed: \(error.localizedDescription, privacy: .public)") @@ -337,6 +337,18 @@ struct OnboardingView: View { } } + /// Waits for the pairing handshake to complete (success or failure). + private func waitForPairingResult(expectedPubkey: String) async { + // Wait until pairingStatus is no longer .inProgress + while state.pairingStatus == .inProgress { + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + onboardingLogger.info("pair task finished isPaired=\(state.isPaired, privacy: .public) status=\(String(describing: state.pairingStatus), privacy: .public)") + if state.isPaired, state.pairedDesktopPubkey == expectedPubkey { + onPairingCompleted?() + } + } + private func loadAndDecode(item: PhotosPickerItem) async { onboardingLogger.info("loadAndDecode start") defer { photoItem = nil } diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index baaedfeb..0126c9a0 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -25,6 +25,7 @@ struct RootView: View { @State private var projectsPath = NavigationPath() @State private var minimumLoadingTimeElapsed = false @State private var connectionTimedOut = false + @State private var showPairingSheet = false var body: some View { Group { @@ -38,6 +39,21 @@ struct RootView: View { PermissionApprovalSheet(request: req) .environmentObject(state) } + .sheet(isPresented: $showPairingSheet) { + NavigationStack { + OnboardingView(showsCancelButton: true) { + showPairingSheet = false + connectionTimedOut = false + minimumLoadingTimeElapsed = false + Task { + await retryConnection() + } + } + .environmentObject(state) + .navigationTitle("Pair New Mac") + .navigationBarTitleDisplayMode(.inline) + } + } .mobileDismissesKeyboardOnScroll() } @@ -58,11 +74,24 @@ struct RootView: View { if !shouldShowContent { SyncLoadingView( isTimedOut: connectionTimedOut, + pairedDesktops: state.pairedDesktops, + activeDesktopID: state.activePairedDesktop?.id, onRetry: { connectionTimedOut = false Task { await retryConnection() } + }, + onSelectDesktop: { desktop in + connectionTimedOut = false + minimumLoadingTimeElapsed = false + Task { + await state.switchPairedDesktop(desktop) + await retryConnection() + } + }, + onPairNewDesktop: { + showPairingSheet = true } ) .transition(.splashTransition) diff --git a/RxCodeMobile/Views/SyncLoadingView.swift b/RxCodeMobile/Views/SyncLoadingView.swift index 885dd38e..f1e53ebb 100644 --- a/RxCodeMobile/Views/SyncLoadingView.swift +++ b/RxCodeMobile/Views/SyncLoadingView.swift @@ -9,7 +9,11 @@ private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "SyncLoa /// When timed out, shows a timeout screen with retry button. struct SyncLoadingView: View { var isTimedOut: Bool = false + var pairedDesktops: [PairedDesktop] = [] + var activeDesktopID: String? var onRetry: (() -> Void)? + var onSelectDesktop: ((PairedDesktop) -> Void)? + var onPairNewDesktop: (() -> Void)? @State private var pulseScale: CGFloat = 1.0 @State private var orbRotation: Double = 0 @@ -51,7 +55,7 @@ struct SyncLoadingView: View { .foregroundStyle(.primary) HStack(spacing: 6) { - Text("Connecting to your desktop") + Text("Connecting to \(activeDesktopDisplayName)") .font(.body) .foregroundStyle(.secondary) @@ -107,40 +111,114 @@ struct SyncLoadingView: View { .fixedSize(horizontal: false, vertical: true) } - // Retry button + desktopPickerView + + timeoutActions + + Spacer() + Spacer() + } + .padding(.horizontal, 40) + } + } + + private var timeoutActions: some View { + VStack(spacing: 12) { + Button { + onRetry?() + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .semibold)) + Text("Try Again") + .font(.body.weight(.semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 32) + .padding(.vertical, 14) + .background( + LinearGradient( + colors: [ + ClaudeTheme.accent, + ClaudeTheme.accent.opacity(0.85) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(Capsule()) + .shadow(color: ClaudeTheme.accent.opacity(0.3), radius: 12, x: 0, y: 6) + } + .buttonStyle(.plain) + + if let onPairNewDesktop { Button { - onRetry?() + onPairNewDesktop() } label: { - HStack(spacing: 8) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 16, weight: .semibold)) - Text("Try Again") - .font(.body.weight(.semibold)) + Label("Pair New Mac", systemImage: "plus.circle") + .font(.body.weight(.semibold)) + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + .buttonStyle(.glass) + .foregroundStyle(.primary) + } + } + .padding(.top, 8) + } + + @ViewBuilder + private var desktopPickerView: some View { + if pairedDesktops.count > 1 { + VStack(alignment: .leading, spacing: 8) { + Label("Connect to", systemImage: "desktopcomputer") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + + Picker("Mac", selection: desktopSelection) { + ForEach(pairedDesktops) { desktop in + Text(desktopPickerTitle(desktop)) + .tag(desktop.id) } - .foregroundStyle(.white) - .padding(.horizontal, 32) - .padding(.vertical, 14) - .background( - LinearGradient( - colors: [ - ClaudeTheme.accent, - ClaudeTheme.accent.opacity(0.85) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .clipShape(Capsule()) - .shadow(color: ClaudeTheme.accent.opacity(0.3), radius: 12, x: 0, y: 6) } - .buttonStyle(.plain) - .padding(.top, 8) + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.white.opacity(0.18)) + } + } + .frame(maxWidth: 320) + .padding(.top, 4) + } + } - Spacer() - Spacer() + private var desktopSelection: Binding { + Binding( + get: { + activeDesktopID ?? pairedDesktops.first?.id ?? "" + }, + set: { selectedID in + guard let desktop = pairedDesktops.first(where: { $0.id == selectedID }) else { return } + onSelectDesktop?(desktop) } - .padding(.horizontal, 40) + ) + } + + private func desktopPickerTitle(_ desktop: PairedDesktop) -> String { + let name = desktop.displayName.isEmpty ? "Unknown Mac" : desktop.displayName + guard let relay = desktop.relayDisplayName else { return name } + return "\(name) (\(relay))" + } + + private var activeDesktopDisplayName: String { + guard let desktop = pairedDesktops.first(where: { $0.id == activeDesktopID }) else { + return "your Mac" } + return desktop.displayName.isEmpty ? "Unknown Mac" : desktop.displayName } private var timeoutIconView: some View {