diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Services/GatewayReplacementReconciler.swift b/macos/WayfinderMac/Sources/WayfinderMac/Services/GatewayReplacementReconciler.swift new file mode 100644 index 00000000..ac005c36 --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/Services/GatewayReplacementReconciler.swift @@ -0,0 +1,112 @@ +import Foundation + +public enum GatewayReplacementReconciliation: Equatable, Sendable { + case unchanged + case restarted + case deferred +} + +/// Restarts a loaded bundled gateway once after its containing app is replaced. +/// +/// launchd can keep the old executable image alive when an app is replaced at the same path. That +/// stale process can no longer authenticate newly signed nested XPC services. The helper is located +/// only at the fixed bundle-relative path, and the LaunchAgent must name that exact path before it +/// can be restarted. Full helper authentication still happens at every execution boundary; this +/// marker only avoids an unnecessary restart on every ordinary app launch. +public struct GatewayReplacementReconciler: Sendable { + public typealias GatewayLocator = @Sendable () -> URL? + public typealias StatusQuery = @Sendable (_ expectedGateway: URL) async -> GatewayServiceStatus + public typealias Restarter = @Sendable (_ expectedGateway: URL) async throws -> Void + public typealias FingerprintProvider = @Sendable (_ gateway: URL) -> String? + public typealias MarkerReader = @Sendable () -> String? + public typealias MarkerWriter = @Sendable (_ fingerprint: String) async -> Void + + private static let markerKey = "Wayfinder.GatewayInstalledFingerprint" + + private let locateGateway: GatewayLocator + private let statusQuery: StatusQuery + private let restart: Restarter + private let fingerprintProvider: FingerprintProvider + private let markerReader: MarkerReader + private let markerWriter: MarkerWriter + + public init( + appBundleURL: URL = Bundle.main.bundleURL, + service: GatewayServiceController = GatewayServiceController() + ) { + self.locateGateway = { + let gateway = GatewayToolResolver.bundledHelperURL(in: appBundleURL) + return FileManager.default.isExecutableFile(atPath: gateway.path) ? gateway : nil + } + self.statusQuery = { await service.status(expectedGateway: $0) } + self.restart = { try await service.restart(expectedGateway: $0) } + self.fingerprintProvider = { Self.installationFingerprint(gateway: $0) } + self.markerReader = { UserDefaults.standard.string(forKey: Self.markerKey) } + self.markerWriter = { UserDefaults.standard.set($0, forKey: Self.markerKey) } + } + + init( + locateGateway: @escaping GatewayLocator, + statusQuery: @escaping StatusQuery, + restart: @escaping Restarter, + fingerprintProvider: @escaping FingerprintProvider, + markerReader: @escaping MarkerReader, + markerWriter: @escaping MarkerWriter + ) { + self.locateGateway = locateGateway + self.statusQuery = statusQuery + self.restart = restart + self.fingerprintProvider = fingerprintProvider + self.markerReader = markerReader + self.markerWriter = markerWriter + } + + public func reconcile() async -> GatewayReplacementReconciliation { + guard let gateway = locateGateway(), + let fingerprint = fingerprintProvider(gateway) else { + return .deferred + } + if markerReader() == fingerprint { + return .unchanged + } + + let status = await statusQuery(gateway) + guard status.installed, + status.loaded, + status.launchConfiguration.usesGateway(at: gateway) else { + return .deferred + } + + do { + try await restart(gateway) + await markerWriter(fingerprint) + return .restarted + } catch { + return .deferred + } + } + + static func installationFingerprint( + appBundleURL: URL = Bundle.main.bundleURL, + gateway: URL + ) -> String? { + guard let values = try? gateway.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), + let fileSize = values.fileSize, + let modified = values.contentModificationDate else { + return nil + } + let info = Bundle(url: appBundleURL)?.infoDictionary ?? Bundle.main.infoDictionary ?? [:] + let version = info["CFBundleShortVersionString"] as? String ?? "unknown" + let build = info["CFBundleVersion"] as? String ?? "unknown" + let signing: String + switch AppleFoundationModelsProductReadiness.current(appBundleURL: appBundleURL) { + case .ready(let teamIdentifier): + signing = teamIdentifier + case .incompleteOrInvalid: + signing = "untrusted" + } + let modifiedNanoseconds = Int64(modified.timeIntervalSince1970 * 1_000_000_000) + return [version, build, signing, String(fileSize), String(modifiedNanoseconds)] + .joined(separator: "|") + } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift index bdae9217..7d97ccad 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift @@ -5,7 +5,6 @@ public struct ChatConversationView: View { @Binding var selectedTurnID: UUID? let canRetry: Bool let onRetry: () -> Void - let onOpenRouting: (ChatTurn) -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isNearBottom = true @@ -13,14 +12,12 @@ public struct ChatConversationView: View { turns: [ChatTurn], selectedTurnID: Binding, canRetry: Bool = false, - onRetry: @escaping () -> Void = {}, - onOpenRouting: @escaping (ChatTurn) -> Void = { _ in } + onRetry: @escaping () -> Void = {} ) { self.turns = turns self._selectedTurnID = selectedTurnID self.canRetry = canRetry self.onRetry = onRetry - self.onOpenRouting = onOpenRouting } public var body: some View { @@ -37,13 +34,8 @@ public struct ChatConversationView: View { ChatTurnHistoryRow( turn: turn, isLast: index == turns.count - 1, - isSelected: selectedTurnID == turn.id, canRetry: canRetry && index == turns.count - 1, - onRetry: onRetry, - onShowRouting: { - selectedTurnID = turn.id - onOpenRouting(turn) - } + onRetry: onRetry ) .id(turn.id) } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift index 6d0cb723..55c5eb6e 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift @@ -3,25 +3,19 @@ import SwiftUI public struct ChatTurnHistoryRow: View { let turn: ChatTurn let isLast: Bool - let isSelected: Bool let canRetry: Bool let onRetry: () -> Void - let onShowRouting: () -> Void public init( turn: ChatTurn, isLast: Bool, - isSelected: Bool, canRetry: Bool, - onRetry: @escaping () -> Void, - onShowRouting: @escaping () -> Void + onRetry: @escaping () -> Void ) { self.turn = turn self.isLast = isLast - self.isSelected = isSelected self.canRetry = canRetry self.onRetry = onRetry - self.onShowRouting = onShowRouting } public var body: some View { @@ -30,11 +24,10 @@ public struct ChatTurnHistoryRow: View { if let response = turn.response { AssistantTurnResponse( + turn: turn, response: response, - isSelected: isSelected, canRetry: canRetry, - onRetry: onRetry, - onShowRouting: onShowRouting + onRetry: onRetry ) } else { PendingRouteStrip() @@ -70,11 +63,10 @@ public struct ChatTurnHistoryRow: View { } private struct AssistantTurnResponse: View { + let turn: ChatTurn let response: ChatMessage - let isSelected: Bool let canRetry: Bool let onRetry: () -> Void - let onShowRouting: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -132,9 +124,8 @@ private struct AssistantTurnResponse: View { if let decision = response.decision { RoutingReceiptButton( - decision: decision, - isSelected: isSelected, - action: onShowRouting + turn: turn, + decision: decision ) } } @@ -144,21 +135,21 @@ private struct AssistantTurnResponse: View { } private struct RoutingReceiptButton: View { + let turn: ChatTurn let decision: RoutingDecision - let isSelected: Bool - let action: () -> Void + @State private var showsRoutingDetails = false var body: some View { - Button(action: action) { + Button { + showsRoutingDetails.toggle() + } label: { HStack(spacing: 6) { Image(systemName: decision.route.symbolName) .foregroundStyle(decision.route.accentColor) Text(decision.routeSummary) .fontWeight(.semibold) - Text("Routing details") - .foregroundStyle(ChatWorkspaceChrome.secondaryText) - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) + Image(systemName: "info.circle") + .font(.caption.weight(.semibold)) .foregroundStyle(ChatWorkspaceChrome.tertiaryText) } .font(.caption) @@ -166,9 +157,21 @@ private struct RoutingReceiptButton: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .foregroundStyle(isSelected ? decision.route.accentColor : .primary) + .foregroundStyle(decision.route.accentColor) .accessibilityLabel("\(decision.routeSummary). Show routing details.") - .help("Show this turn in the routing inspector") + .help("Show routing details") + .onHover { hovering in + if hovering { + showsRoutingDetails = true + } + } + .popover(isPresented: $showsRoutingDetails, arrowEdge: .bottom) { + RoutingOutputsPanel( + turn: turn, + onClose: { showsRoutingDetails = false } + ) + .frame(width: 400, height: 560) + } } } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift index 14092848..bcff9460 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift @@ -13,16 +13,12 @@ enum ChatWorkspaceChrome { static let sidebarMinimumWidth: CGFloat = 210 static let sidebarWidth: CGFloat = 220 static let sidebarMaximumWidth: CGFloat = 260 - static let inspectorMinimumWidth: CGFloat = 260 - static let inspectorWidth: CGFloat = 296 - static let inspectorMaximumWidth: CGFloat = 340 static let conversationWidth: CGFloat = 760 static let composerWidth: CGFloat = 780 static let initialWindowWidth: CGFloat = 1_180 static let initialWindowHeight: CGFloat = 720 static let minimumWindowWidth: CGFloat = 900 static let minimumWindowHeight: CGFloat = 580 - static let showsInspectorByDefault = false } public enum ChatRouteFilter: String, CaseIterable, Identifiable { diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift index 0270d9fc..47fbc540 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift @@ -6,7 +6,6 @@ public struct WayfinderChatWindow: View { @State private var routeFilter: ChatRouteFilter = .all @State private var searchText = "" @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var showsInspector = ChatWorkspaceChrome.showsInspectorByDefault @State private var followsLatestTurn = true @State private var searchFocusRequest = 0 @@ -41,10 +40,8 @@ public struct WayfinderChatWindow: View { ChatToolbar( title: chatTitle(in: turns), showsSidebar: showsSidebar, - showsInspector: showsInspector, onToggleSidebar: toggleSidebar, onFocusSearch: focusSearch, - onToggleInspector: { showsInspector.toggle() }, canRetry: appState.canRetryChat, canClear: appState.canClearChat, onRetry: retryLastTurn, @@ -55,11 +52,7 @@ public struct WayfinderChatWindow: View { turns: workspace.transcriptTurns, selectedTurnID: $selectedTurnID, canRetry: appState.canRetryChat, - onRetry: retryLastTurn, - onOpenRouting: { turn in - selectedTurnID = turn.id - showsInspector = true - } + onRetry: retryLastTurn ) ChatComposerView( draft: $appState.chatDraft, @@ -75,17 +68,6 @@ public struct WayfinderChatWindow: View { ) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .inspector(isPresented: $showsInspector) { - RoutingOutputsPanel( - turn: selectedTurn(in: turns), - onClose: { showsInspector = false } - ) - .inspectorColumnWidth( - min: ChatWorkspaceChrome.inspectorMinimumWidth, - ideal: ChatWorkspaceChrome.inspectorWidth, - max: ChatWorkspaceChrome.inspectorMaximumWidth - ) - } } .navigationSplitViewStyle(.prominentDetail) .frame( @@ -121,7 +103,11 @@ public struct WayfinderChatWindow: View { } private func toggleSidebar() { - columnVisibility = showsSidebar ? .detailOnly : .all + if showsSidebar { + columnVisibility = .detailOnly + } else { + columnVisibility = .all + } } private func focusSearch() { @@ -149,21 +135,13 @@ public struct WayfinderChatWindow: View { return firstPrompt } - private func selectedTurn(in turns: [ChatTurn]) -> ChatTurn? { - guard let selectedTurnID else { - return nil - } - return turns.first { $0.id == selectedTurnID } - } } private struct ChatToolbar: View { let title: String let showsSidebar: Bool - let showsInspector: Bool let onToggleSidebar: () -> Void let onFocusSearch: () -> Void - let onToggleInspector: () -> Void let canRetry: Bool let canClear: Bool let onRetry: () -> Void @@ -197,13 +175,6 @@ private struct ChatToolbar: View { .keyboardShortcut("r", modifiers: .command) .help("Retry the last response") } - Button(action: onToggleInspector) { - Image(systemName: "sidebar.right") - .symbolVariant(showsInspector ? .fill : .none) - } - .accessibilityLabel(showsInspector ? "Hide routing inspector" : "Show routing inspector") - .keyboardShortcut("i", modifiers: [.command, .control]) - .help(showsInspector ? "Hide routing inspector" : "Show routing inspector") Button(action: onClear) { Image(systemName: "square.and.pencil") } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift b/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift index f8180362..7922503f 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift @@ -5,6 +5,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private let appState: AppState private let featurePolicy: ReleaseFeaturePolicy private let openChatOnLaunch: Bool + private let gatewayReplacementReconciler: GatewayReplacementReconciler private var statusItemController: StatusItemController? private var chatFeatureCoordinator: ChatFeatureCoordinator? private var settingsWindowController: SettingsWindowController? @@ -16,16 +17,26 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { public init( client: any WayfinderClient, featurePolicy: ReleaseFeaturePolicy = .current, - openChatOnLaunch: Bool = false + openChatOnLaunch: Bool = false, + gatewayReplacementReconciler: GatewayReplacementReconciler = GatewayReplacementReconciler() ) { self.appState = AppState(client: client) self.featurePolicy = featurePolicy self.openChatOnLaunch = openChatOnLaunch + self.gatewayReplacementReconciler = gatewayReplacementReconciler super.init() } public func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + Task { @MainActor [weak self] in + guard let self else { return } + _ = await gatewayReplacementReconciler.reconcile() + finishLaunching() + } + } + + private func finishLaunching() { let chatFeatureCoordinator = ChatFeatureCoordinator( policy: featurePolicy, appState: appState diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift index 51c89fdd..6d62f0fe 100644 --- a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift @@ -185,20 +185,11 @@ final class ChatStateTests: XCTestCase { func testChatWorkspaceSizingKeepsTheConversationPrimary() { let defaultConversationWidth = ChatWorkspaceChrome.initialWindowWidth - ChatWorkspaceChrome.sidebarWidth - let expandedInspectorConversationWidth = ChatWorkspaceChrome.initialWindowWidth - - ChatWorkspaceChrome.sidebarWidth - - ChatWorkspaceChrome.inspectorWidth let minimumConversationWidth = ChatWorkspaceChrome.minimumWindowWidth - ChatWorkspaceChrome.sidebarMinimumWidth - XCTAssertFalse(ChatWorkspaceChrome.showsInspectorByDefault) XCTAssertGreaterThanOrEqual(defaultConversationWidth, ChatWorkspaceChrome.conversationWidth) - XCTAssertGreaterThanOrEqual(expandedInspectorConversationWidth, 640) XCTAssertGreaterThanOrEqual(minimumConversationWidth, 640) - XCTAssertLessThan( - ChatWorkspaceChrome.sidebarWidth + ChatWorkspaceChrome.inspectorWidth, - ChatWorkspaceChrome.initialWindowWidth / 2 - ) } func testStreamingOnlyFollowsAnExplicitlySelectedLatestTurn() { diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/GatewayReplacementReconcilerTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/GatewayReplacementReconcilerTests.swift new file mode 100644 index 00000000..c30d58ce --- /dev/null +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/GatewayReplacementReconcilerTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import WayfinderMacCore + +final class GatewayReplacementReconcilerTests: XCTestCase { + private actor Recorder { + var restarts: [URL] = [] + var markers: [String] = [] + + func restart(_ gateway: URL) { restarts.append(gateway) } + func write(_ marker: String) { markers.append(marker) } + func snapshot() -> (restarts: [URL], markers: [String]) { (restarts, markers) } + } + + func testChangedInstallationRestartsMatchingLoadedBundledGatewayAndPersistsMarker() async { + let gateway = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/Helpers/WayfinderGateway.app/Contents/MacOS/wayfinder-router") + let recorder = Recorder() + let reconciler = makeReconciler( + gateway: gateway, + storedMarker: "old", + fingerprint: "new", + recorder: recorder + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .restarted) + XCTAssertEqual(snapshot.restarts, [gateway]) + XCTAssertEqual(snapshot.markers, ["new"]) + } + + func testUnchangedInstallationDoesNotQueryOrRestartService() async { + let gateway = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/MacOS/wayfinder-router") + let recorder = Recorder() + let reconciler = GatewayReplacementReconciler( + locateGateway: { gateway }, + statusQuery: { _ in XCTFail("Unchanged installation must not query service"); return Self.status(gateway: gateway) }, + restart: { _ in XCTFail("Unchanged installation must not restart") }, + fingerprintProvider: { _ in "same" }, + markerReader: { "same" }, + markerWriter: { _ in XCTFail("Unchanged installation must not rewrite marker") } + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .unchanged) + XCTAssertTrue(snapshot.restarts.isEmpty) + } + + func testMismatchedStoppedOrUninstalledServiceDefersWithoutMarker() async { + let expected = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/MacOS/wayfinder-router") + let other = URL(fileURLWithPath: "/opt/homebrew/bin/wayfinder-router") + for status in [ + Self.status(gateway: other), + Self.status(gateway: expected, loaded: false), + Self.status(gateway: expected, installed: false), + ] { + let recorder = Recorder() + let reconciler = GatewayReplacementReconciler( + locateGateway: { expected }, + statusQuery: { _ in status }, + restart: { gateway in await recorder.restart(gateway) }, + fingerprintProvider: { _ in "new" }, + markerReader: { "old" }, + markerWriter: { marker in await recorder.write(marker) } + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .deferred) + XCTAssertTrue(snapshot.restarts.isEmpty) + XCTAssertTrue(snapshot.markers.isEmpty) + } + } + + func testFailedRestartDefersAndDoesNotPersistMarker() async { + let gateway = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/MacOS/wayfinder-router") + let recorder = Recorder() + let reconciler = GatewayReplacementReconciler( + locateGateway: { gateway }, + statusQuery: { _ in Self.status(gateway: gateway) }, + restart: { _ in throw GatewayServiceControllerError.restartFailed("expected") }, + fingerprintProvider: { _ in "new" }, + markerReader: { "old" }, + markerWriter: { marker in await recorder.write(marker) } + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .deferred) + XCTAssertTrue(snapshot.markers.isEmpty) + } + + private func makeReconciler( + gateway: URL, + storedMarker: String?, + fingerprint: String, + recorder: Recorder + ) -> GatewayReplacementReconciler { + GatewayReplacementReconciler( + locateGateway: { gateway }, + statusQuery: { _ in Self.status(gateway: gateway) }, + restart: { value in await recorder.restart(value) }, + fingerprintProvider: { _ in fingerprint }, + markerReader: { storedMarker }, + markerWriter: { marker in await recorder.write(marker) } + ) + } + + private static func status( + gateway: URL, + installed: Bool = true, + loaded: Bool = true + ) -> GatewayServiceStatus { + GatewayServiceStatus( + installed: installed, + loaded: loaded, + launchConfiguration: GatewayLaunchConfiguration( + host: "127.0.0.1", + port: 8088, + configPath: "/tmp/config.toml", + executablePath: gateway.path + ), + health: nil + ) + } +}