diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 256417319..8e1347058 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -25,7 +25,6 @@ final class BridgeConnectionController { private let discovery = BridgeDiscoveryModel() private weak var appModel: NodeAppModel? private var didAutoConnect = false - private var seenStableIDs = Set() private let bridgeClientFactory: @Sendable () -> any BridgePairingClient @@ -120,9 +119,15 @@ final class BridgeConnectionController { return } - let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")? + let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !targetStableID.isEmpty else { return } + let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty } + guard let targetStableID = candidates.first(where: { id in + self.bridges.contains(where: { $0.stableID == id }) + }) else { return } guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } @@ -131,11 +136,18 @@ final class BridgeConnectionController { } private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { - let newlyDiscovered = bridges.filter { self.seenStableIDs.insert($0.stableID).inserted } - guard let last = newlyDiscovered.last else { return } + let defaults = UserDefaults.standard + let preferred = defaults.string(forKey: "bridge.preferredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - UserDefaults.standard.set(last.stableID, forKey: "bridge.lastDiscoveredStableID") - BridgeSettingsStore.saveLastDiscoveredBridgeStableID(last.stableID) + // Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect). + guard preferred.isEmpty, existingLast.isEmpty else { return } + guard let first = bridges.first else { return } + + defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID") + BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID) } private func makeHello(token: String) -> BridgeHello { diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index e93bce006..a06154037 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -461,11 +461,13 @@ final class TalkModeManager: NSObject { let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false) if canUseElevenLabs, let voiceId, let apiKey { - let desiredOutputFormat = directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100" - let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat) - if outputFormat == nil, let desiredOutputFormat, !desiredOutputFormat.isEmpty { + let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil + let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100") + if outputFormat == nil, let requestedOutputFormat { self.logger.warning( - "talk output_format unsupported for local playback: \(desiredOutputFormat, privacy: .public)") + "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") } let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId diff --git a/apps/ios/Tests/BridgeConnectionControllerTests.swift b/apps/ios/Tests/BridgeConnectionControllerTests.swift index 51e22ec5d..e4eb6ccf2 100644 --- a/apps/ios/Tests/BridgeConnectionControllerTests.swift +++ b/apps/ios/Tests/BridgeConnectionControllerTests.swift @@ -60,6 +60,35 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +@MainActor +private func withUserDefaults( + _ updates: [String: Any?], + _ body: () async throws -> T) async rethrows -> T +{ + let defaults = UserDefaults.standard + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + return try await body() +} + private func withKeychainValues(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T { var snapshot: [KeychainEntry: String?] = [:] for entry in updates.keys { @@ -84,6 +113,34 @@ private func withKeychainValues(_ updates: [KeychainEntry: String?], _ body: return try body() } +@MainActor +private func withKeychainValues( + _ updates: [KeychainEntry: String?], + _ body: () async throws -> T) async rethrows -> T +{ + var snapshot: [KeychainEntry: String?] = [:] + for entry in updates.keys { + snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) + } + for (entry, value) in updates { + if let value { + _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) + } else { + _ = KeychainStore.delete(service: entry.service, account: entry.account) + } + } + defer { + for (entry, value) in snapshot { + if let value { + _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) + } else { + _ = KeychainStore.delete(service: entry.service, account: entry.account) + } + } + } + return try await body() +} + @Suite(.serialized) struct BridgeConnectionControllerTests { @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { let defaults = UserDefaults.standard @@ -192,13 +249,13 @@ private func withKeychainValues(_ updates: [KeychainEntry: String?], _ body: let mock = MockBridgePairingClient(resultToken: "new-token") let account = "bridge-token.ios-test" - withKeychainValues([ + await withKeychainValues([ instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil, KeychainEntry(service: bridgeService, account: account): "old-token", ]) { - withUserDefaults([ + await withUserDefaults([ "node.instanceId": "ios-test", "bridge.lastDiscoveredStableID": "bridge-1", "bridge.manual.enabled": false, @@ -224,4 +281,61 @@ private func withKeychainValues(_ updates: [KeychainEntry: String?], _ body: } } } + + @Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async { + let bridgeA = BridgeDiscoveryModel.DiscoveredBridge( + name: "Gateway A", + endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790), + stableID: "bridge-1", + debugID: "bridge-a", + lanHost: "MacA.local", + tailnetDns: nil, + gatewayPort: 18789, + bridgePort: 18790, + canvasPort: 18793, + cliPath: nil) + let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( + name: "Gateway B", + endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790), + stableID: "bridge-2", + debugID: "bridge-b", + lanHost: "MacB.local", + tailnetDns: nil, + gatewayPort: 28789, + bridgePort: 28790, + canvasPort: 28793, + cliPath: nil) + + let mock = MockBridgePairingClient(resultToken: "token-ok") + let account = "bridge-token.ios-test" + + await withKeychainValues([ + instanceIdEntry: nil, + preferredBridgeEntry: nil, + lastBridgeEntry: nil, + KeychainEntry(service: bridgeService, account: account): "old-token", + ]) { + await withUserDefaults([ + "node.instanceId": "ios-test", + "bridge.preferredStableID": "bridge-2", + "bridge.lastDiscoveredStableID": "bridge-1", + "bridge.manual.enabled": false, + ]) { + let appModel = NodeAppModel() + let controller = BridgeConnectionController( + appModel: appModel, + startDiscovery: false, + bridgeClientFactory: { mock }) + controller._test_setBridges([bridgeA, bridgeB]) + controller._test_triggerAutoConnect() + + for _ in 0..<20 { + if appModel.connectedBridgeID == bridgeB.stableID { break } + try? await Task.sleep(nanoseconds: 50_000_000) + } + + #expect(appModel.connectedBridgeID == bridgeB.stableID) + } + } + } } diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index f9a0c141f..7d8e899a9 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -229,7 +229,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { item.view = HighlightedMenuItemHostView( rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), width: width) - item.submenu = self.buildNodeSubmenu(entry: entry) + item.submenu = self.buildNodeSubmenu(entry: entry, width: width) menu.insertItem(item, at: cursor) cursor += 1 } @@ -444,13 +444,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { item.view = HighlightedMenuItemHostView( rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), width: width) - item.submenu = self.buildNodeSubmenu(entry: entry) + item.submenu = self.buildNodeSubmenu(entry: entry, width: width) menu.addItem(item) } return menu } - private func buildNodeSubmenu(entry: NodeInfo) -> NSMenu { + private func buildNodeSubmenu(entry: NodeInfo, width: CGFloat) -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false @@ -484,7 +484,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), !commands.isEmpty { - menu.addItem(self.makeNodeCopyItem(label: "Commands", value: commands.joined(separator: ", "))) + menu.addItem(self.makeNodeMultilineItem( + label: "Commands", + value: commands.joined(separator: ", "), + width: width)) } return menu @@ -503,6 +506,17 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return item } + private func makeNodeMultilineItem(label: String, value: String, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem() + item.target = self + item.action = #selector(self.copyNodeValue(_:)) + item.representedObject = value + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuMultilineView(label: label, value: value, width: width)), + width: width) + return item + } + private func formatVersionLabel(_ version: String) -> String { let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return version } diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 45e1b0c44..a19da2a65 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -39,12 +39,12 @@ struct NodeMenuEntryFormatter { return role } - static func detailRight(_ entry: NodeInfo) -> String? { + static func headlineRight(_ entry: NodeInfo) -> String? { var parts: [String] = [] if let platform = self.platformText(entry) { parts.append(platform) } if let version = entry.version?.nonEmpty { - let short = self.compactVersion(version) - parts.append("v\(short)") + let short = self.shortVersionLabel(version) + parts.append(short) } if parts.isEmpty { return nil } return parts.joined(separator: " ยท ") @@ -103,6 +103,16 @@ struct NodeMenuEntryFormatter { return trimmed } + private static func shortVersionLabel(_ raw: String) -> String { + let compact = self.compactVersion(raw) + if compact.isEmpty { return compact } + if compact.lowercased().hasPrefix("v") { return compact } + if let first = compact.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) { + return "v\(compact)" + } + return compact + } + static func leadingSymbol(_ entry: NodeInfo) -> String { if let family = entry.deviceFamily?.lowercased() { if family.contains("mac") { @@ -151,28 +161,24 @@ struct NodeMenuRowView: View { .frame(width: 22, height: 22, alignment: .center) VStack(alignment: .leading, spacing: 2) { - Text(NodeMenuEntryFormatter.primaryName(self.entry)) - .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) - .foregroundStyle(self.primaryColor) - .lineLimit(1) - .truncationMode(.middle) - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(NodeMenuEntryFormatter.detailLeft(self.entry)) - .font(.caption) - .foregroundStyle(self.secondaryColor) + Text(NodeMenuEntryFormatter.primaryName(self.entry)) + .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) + .foregroundStyle(self.primaryColor) .lineLimit(1) .truncationMode(.middle) + .layoutPriority(1) - Spacer(minLength: 0) + Spacer(minLength: 8) HStack(alignment: .firstTextBaseline, spacing: 6) { - if let right = NodeMenuEntryFormatter.detailRight(self.entry) { + if let right = NodeMenuEntryFormatter.headlineRight(self.entry) { Text(right) .font(.caption.monospacedDigit()) .foregroundStyle(self.secondaryColor) .lineLimit(1) .truncationMode(.middle) + .layoutPriority(2) } Image(systemName: "chevron.right") @@ -181,6 +187,13 @@ struct NodeMenuRowView: View { .padding(.leading, 2) } } + + Text(NodeMenuEntryFormatter.detailLeft(self.entry)) + .font(.caption) + .foregroundStyle(self.secondaryColor) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) @@ -220,3 +233,36 @@ struct AndroidMark: View { } } } + +struct NodeMenuMultilineView: View { + let label: String + let value: String + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var primaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("\(self.label):") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryColor) + + Text(self.value) + .font(.caption) + .foregroundStyle(self.primaryColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 6) + .padding(.leading, 18) + .padding(.trailing, 12) + .frame(width: max(1, self.width), alignment: .leading) + } +}