diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift index 0c91f085..ca09fe2f 100644 --- a/app/EXO/EXO/ContentView.swift +++ b/app/EXO/EXO/ContentView.swift @@ -20,10 +20,6 @@ struct ContentView: View { @State private var deletingInstanceIDs: Set = [] @State private var showAllNodes = false @State private var showAllInstances = false - @State private var showDebugInfo = false - @State private var bugReportInFlight = false - @State private var bugReportMessage: String? - @State private var uninstallInProgress = false @State private var baseURLCopied = false var body: some View { @@ -256,101 +252,79 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 0) { if controller.status != .stopped { dashboardButton + baseURLRow Divider() .padding(.vertical, 8) } else { Divider() .padding(.vertical, 4) } - advancedSection - .padding(.bottom, 8) - controlButton(title: "Quit", tint: .secondary) { + HoverButton( + title: "Settings", + tint: .primary, + trailingSystemImage: "gear" + ) { + settingsWindowController.open( + controller: controller, + updater: updater, + networkStatusService: networkStatusService, + thunderboltBridgeService: thunderboltBridgeService, + stateService: stateService + ) + } + HoverButton( + title: "Check for Updates", + tint: .primary, + trailingSystemImage: "arrow.triangle.2.circlepath" + ) { + updater.checkForUpdates() + } + .padding(.bottom, 8) + HoverButton(title: "Quit", tint: .secondary) { controller.stop() NSApplication.shared.terminate(nil) } } } - private var advancedSection: some View { - VStack(alignment: .leading, spacing: 6) { - HoverButton( - title: "Settings", - tint: .primary, - trailingSystemImage: "gear", - small: false - ) { - settingsWindowController.open(controller: controller, updater: updater) - } - HoverButton(title: "Check for Updates", small: true) { - updater.checkForUpdates() - } - debugSection - HoverButton(title: "Uninstall", tint: .red, small: true) { - showUninstallConfirmationAlert() - } - .disabled(uninstallInProgress) + private var dashboardButton: some View { + HoverButton( + title: "Web Dashboard", + tint: .primary, + trailingSystemImage: "arrow.up.right" + ) { + guard let url = URL(string: "http://localhost:52415/") else { return } + NSWorkspace.shared.open(url) } } - private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void) - -> some View - { - HoverButton(title: title, tint: tint, trailingSystemImage: nil, action: action) - } - - private var dashboardButton: some View { - VStack(spacing: 6) { + private var baseURLRow: some View { + HStack(spacing: 6) { + Image(systemName: "link") + .imageScale(.small) + .foregroundColor(.secondary) + Text("localhost:52415/v1") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.primary) + Spacer() Button { - guard let url = URL(string: "http://localhost:52415/") else { return } - NSWorkspace.shared.open(url) - } label: { - HStack { - Image(systemName: "globe") - .imageScale(.small) - Text("Web Dashboard") - .fontWeight(.medium) - Spacer() - Image(systemName: "arrow.up.right") - .imageScale(.small) - .foregroundColor(.secondary) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("http://localhost:52415/v1", forType: .string) + baseURLCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + baseURLCopied = false } - .padding(.vertical, 8) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2)) - ) + } label: { + Image(systemName: baseURLCopied ? "checkmark" : "doc.on.doc") + .imageScale(.small) + .foregroundColor(baseURLCopied ? .green : .secondary) + .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) - - // Base URL for API integrations (SillyTavern, etc.) - HStack(spacing: 6) { - Text("Base URL:") - .font(.caption2) - .foregroundColor(.secondary) - Text("localhost:52415/v1") - .font(.system(.caption2, design: .monospaced)) - .foregroundColor(.primary) - Spacer() - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString("http://localhost:52415/v1", forType: .string) - baseURLCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - baseURLCopied = false - } - } label: { - Image(systemName: baseURLCopied ? "checkmark" : "doc.on.doc") - .imageScale(.small) - .foregroundColor(baseURLCopied ? .green : .secondary) - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - .help("Copy API base URL") - } - .padding(.horizontal, 10) + .help("Copy API base URL") } - .padding(.bottom, 4) + .padding(.vertical, 4) + .padding(.horizontal, 8) } private func collapseButton(isExpanded: Binding) -> some View { @@ -405,207 +379,6 @@ struct ContentView: View { } } - private var thunderboltStatusText: String { - switch networkStatusService.status.thunderboltBridgeState { - case .some(.disabled): - return "Thunderbolt Bridge: Disabled" - case .some(.deleted): - return "Thunderbolt Bridge: Deleted" - case .some(.enabled): - return "Thunderbolt Bridge: Enabled" - case nil: - return "Thunderbolt Bridge: Unknown" - } - } - - private var thunderboltStatusColor: Color { - switch networkStatusService.status.thunderboltBridgeState { - case .some(.disabled), .some(.deleted): - return .green - case .some(.enabled): - return .red - case nil: - return .secondary - } - } - - /// Shows TB bridge status for all nodes from exo cluster state - private var clusterThunderboltBridgeView: some View { - let bridgeStatuses = stateService.latestSnapshot?.nodeThunderboltBridge ?? [:] - let localNodeId = stateService.localNodeId - let nodeProfiles = stateService.latestSnapshot?.nodeProfiles ?? [:] - - return VStack(alignment: .leading, spacing: 1) { - if bridgeStatuses.isEmpty { - Text("Cluster TB Bridge: No data") - .font(.caption2) - .foregroundColor(.secondary) - } else { - Text("Cluster TB Bridge Status:") - .font(.caption2) - .foregroundColor(.secondary) - ForEach(Array(bridgeStatuses.keys.sorted()), id: \.self) { nodeId in - if let status = bridgeStatuses[nodeId] { - let nodeName = - nodeProfiles[nodeId]?.friendlyName ?? String(nodeId.prefix(8)) - let isLocal = nodeId == localNodeId - let prefix = isLocal ? " \(nodeName) (local):" : " \(nodeName):" - let statusText = - !status.exists - ? "N/A" - : (status.enabled ? "Enabled" : "Disabled") - let color: Color = - !status.exists - ? .secondary - : (status.enabled ? .red : .green) - Text("\(prefix) \(statusText)") - .font(.caption2) - .foregroundColor(color) - } - } - } - } - } - - private var interfaceIpList: some View { - let statuses = networkStatusService.status.interfaceStatuses - return VStack(alignment: .leading, spacing: 1) { - Text("Interfaces (en0–en7):") - .font(.caption2) - .foregroundColor(.secondary) - if statuses.isEmpty { - Text(" Unknown") - .font(.caption2) - .foregroundColor(.secondary) - } else { - ForEach(statuses, id: \.interfaceName) { status in - let ipText = status.ipAddress ?? "No IP" - Text(" \(status.interfaceName): \(ipText)") - .font(.caption2) - .foregroundColor(status.ipAddress == nil ? .red : .green) - } - } - } - } - - private var debugSection: some View { - VStack(alignment: .leading, spacing: 4) { - HoverButton( - title: "Debug Info", - tint: .primary, - trailingSystemImage: showDebugInfo ? "chevron.up" : "chevron.down", - small: true - ) { - showDebugInfo.toggle() - } - if showDebugInfo { - VStack(alignment: .leading, spacing: 4) { - Text("Version: \(buildTag)") - .font(.caption2) - .foregroundColor(.secondary) - Text("Commit: \(buildCommit)") - .font(.caption2) - .foregroundColor(.secondary) - Text(thunderboltStatusText) - .font(.caption2) - .foregroundColor(thunderboltStatusColor) - clusterThunderboltBridgeView - interfaceIpList - rdmaStatusView - sendBugReportButton - .padding(.top, 6) - } - .padding(.leading, 8) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.25), value: showDebugInfo) - } - - private var rdmaStatusView: some View { - let rdmaStatuses = stateService.latestSnapshot?.nodeRdmaCtl ?? [:] - let localNodeId = stateService.localNodeId - let nodeProfiles = stateService.latestSnapshot?.nodeProfiles ?? [:] - let localDevices = networkStatusService.status.localRdmaDevices - let localPorts = networkStatusService.status.localRdmaActivePorts - - return VStack(alignment: .leading, spacing: 1) { - if rdmaStatuses.isEmpty { - Text("Cluster RDMA: No data") - .font(.caption2) - .foregroundColor(.secondary) - } else { - Text("Cluster RDMA Status:") - .font(.caption2) - .foregroundColor(.secondary) - ForEach(Array(rdmaStatuses.keys.sorted()), id: \.self) { nodeId in - if let status = rdmaStatuses[nodeId] { - let nodeName = - nodeProfiles[nodeId]?.friendlyName ?? String(nodeId.prefix(8)) - let isLocal = nodeId == localNodeId - let prefix = isLocal ? " \(nodeName) (local):" : " \(nodeName):" - let statusText = status.enabled ? "Enabled" : "Disabled" - let color: Color = status.enabled ? .green : .orange - Text("\(prefix) \(statusText)") - .font(.caption2) - .foregroundColor(color) - } - } - } - if !localDevices.isEmpty { - Text(" Local Devices: \(localDevices.joined(separator: ", "))") - .font(.caption2) - .foregroundColor(.secondary) - } - if !localPorts.isEmpty { - Text(" Local Active Ports:") - .font(.caption2) - .foregroundColor(.secondary) - ForEach(localPorts, id: \.device) { port in - Text(" \(port.device) port \(port.port): \(port.state)") - .font(.caption2) - .foregroundColor(.green) - } - } - } - } - - private var sendBugReportButton: some View { - VStack(alignment: .leading, spacing: 4) { - Button { - Task { - await sendBugReport() - } - } label: { - HStack { - if bugReportInFlight { - ProgressView() - .scaleEffect(0.6) - } - Text("Send Bug Report") - .font(.caption) - .fontWeight(.semibold) - Spacer() - } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.accentColor.opacity(0.12)) - ) - } - .buttonStyle(.plain) - .disabled(bugReportInFlight) - - if let message = bugReportMessage { - Text(message) - .font(.caption2) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - private var processToggleBinding: Binding { Binding( get: { @@ -646,101 +419,6 @@ struct ContentView: View { ) } - private func sendBugReport() async { - bugReportInFlight = true - bugReportMessage = "Collecting logs..." - let service = BugReportService() - do { - let outcome = try await service.sendReport(isManual: true) - bugReportMessage = outcome.message - } catch { - bugReportMessage = error.localizedDescription - } - bugReportInFlight = false - } - - private func showUninstallConfirmationAlert() { - let alert = NSAlert() - alert.messageText = "Uninstall EXO" - alert.informativeText = """ - This will remove EXO and all its system components: - - • Network configuration daemon - • Launch at login registration - • EXO network location - - The app will be moved to Trash. - """ - alert.alertStyle = .warning - alert.addButton(withTitle: "Uninstall") - alert.addButton(withTitle: "Cancel") - - // Style the Uninstall button as destructive - if let uninstallButton = alert.buttons.first { - uninstallButton.hasDestructiveAction = true - } - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - performUninstall() - } - } - - private func performUninstall() { - uninstallInProgress = true - - // Stop EXO process first - controller.cancelPendingLaunch() - controller.stop() - stateService.stopPolling() - - // Run the privileged uninstall on a background thread - // Using .utility QoS to avoid priority inversion with NSAppleScript's subprocess - DispatchQueue.global(qos: .utility).async { - do { - // Remove network setup daemon and components (requires admin privileges) - try NetworkSetupHelper.uninstall() - - DispatchQueue.main.async { - // Unregister from launch at login - LaunchAtLoginHelper.disable() - - // Move app to trash - self.moveAppToTrash() - - // Quit the app - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NSApplication.shared.terminate(nil) - } - } - } catch { - DispatchQueue.main.async { - self.showErrorAlert(message: error.localizedDescription) - self.uninstallInProgress = false - } - } - } - } - - private func showErrorAlert(message: String) { - let alert = NSAlert() - alert.messageText = "Uninstall Failed" - alert.informativeText = message - alert.alertStyle = .critical - alert.addButton(withTitle: "OK") - alert.runModal() - } - - private func moveAppToTrash() { - guard let appURL = Bundle.main.bundleURL as URL? else { return } - do { - try FileManager.default.trashItem(at: appURL, resultingItemURL: nil) - } catch { - // If we can't trash the app, that's OK - user can do it manually - // The important system components have already been cleaned up - } - } - private var buildTag: String { Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown" } diff --git a/app/EXO/EXO/Views/FirstLaunchPopout.swift b/app/EXO/EXO/Views/FirstLaunchPopout.swift index ef1dcdd0..68fbf0ba 100644 --- a/app/EXO/EXO/Views/FirstLaunchPopout.swift +++ b/app/EXO/EXO/Views/FirstLaunchPopout.swift @@ -82,6 +82,7 @@ private struct PopoutContentView: View { let onDismiss: () -> Void let onOpen: () -> Void @State private var countdown = 5 + @State private var timerTask: Task? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -130,10 +131,14 @@ private struct PopoutContentView: View { .onAppear { startCountdown() } + .onDisappear { + timerTask?.cancel() + timerTask = nil + } } private func startCountdown() { - Task { + timerTask = Task { while countdown > 0 { try? await Task.sleep(nanoseconds: 1_000_000_000) if !Task.isCancelled { diff --git a/app/EXO/EXO/Views/SettingsView.swift b/app/EXO/EXO/Views/SettingsView.swift index 4d364866..ed221c89 100644 --- a/app/EXO/EXO/Views/SettingsView.swift +++ b/app/EXO/EXO/Views/SettingsView.swift @@ -1,15 +1,22 @@ +import AppKit import SwiftUI /// Native macOS Settings window following Apple HIG. -/// Organized into General, Model, and Advanced sections. +/// Organized into General, Model, Advanced, and About sections. struct SettingsView: View { @EnvironmentObject private var controller: ExoProcessController @EnvironmentObject private var updater: SparkleUpdater + @EnvironmentObject private var networkStatusService: NetworkStatusService + @EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService + @EnvironmentObject private var stateService: ClusterStateService @State private var pendingNamespace: String = "" @State private var pendingHFToken: String = "" @State private var pendingEnableImageModels = false @State private var needsRestart = false + @State private var bugReportInFlight = false + @State private var bugReportMessage: String? + @State private var uninstallInProgress = false var body: some View { TabView { @@ -21,12 +28,16 @@ struct SettingsView: View { .tabItem { Label("Model", systemImage: "cube") } + advancedTab + .tabItem { + Label("Advanced", systemImage: "wrench.and.screwdriver") + } aboutTab .tabItem { Label("About", systemImage: "info.circle") } } - .frame(width: 450, height: 320) + .frame(width: 450, height: 400) .onAppear { pendingNamespace = controller.customNamespace pendingHFToken = controller.hfToken @@ -100,6 +111,66 @@ struct SettingsView: View { .padding() } + // MARK: - Advanced Tab + + private var advancedTab: some View { + Form { + Section("Onboarding") { + HStack { + VStack(alignment: .leading) { + Text("Reset Onboarding") + Text("Opens the dashboard and resets the onboarding wizard.") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Button("Reset") { + guard let url = URL(string: "http://localhost:52415/?reset-onboarding") + else { return } + NSWorkspace.shared.open(url) + } + } + } + + Section("Debug Info") { + LabeledContent("Thunderbolt Bridge") { + Text(thunderboltStatusText) + .foregroundColor(thunderboltStatusColor) + } + + VStack(alignment: .leading, spacing: 2) { + clusterThunderboltBridgeView + } + + VStack(alignment: .leading, spacing: 2) { + interfaceIpList + } + + VStack(alignment: .leading, spacing: 2) { + rdmaStatusView + } + + sendBugReportButton + } + + Section("Danger Zone") { + Button(role: .destructive) { + showUninstallConfirmationAlert() + } label: { + HStack { + Text("Uninstall EXO") + Spacer() + Image(systemName: "trash") + .imageScale(.small) + } + } + .disabled(uninstallInProgress) + } + } + .formStyle(.grouped) + .padding() + } + // MARK: - About Tab private var aboutTab: some View { @@ -126,6 +197,250 @@ struct SettingsView: View { .padding() } + // MARK: - Debug Info Views (moved from ContentView) + + private var thunderboltStatusText: String { + switch networkStatusService.status.thunderboltBridgeState { + case .some(.disabled): + return "Disabled" + case .some(.deleted): + return "Deleted" + case .some(.enabled): + return "Enabled" + case nil: + return "Unknown" + } + } + + private var thunderboltStatusColor: Color { + switch networkStatusService.status.thunderboltBridgeState { + case .some(.disabled), .some(.deleted): + return .green + case .some(.enabled): + return .red + case nil: + return .secondary + } + } + + private var clusterThunderboltBridgeView: some View { + let bridgeStatuses = stateService.latestSnapshot?.nodeThunderboltBridge ?? [:] + let localNodeId = stateService.localNodeId + let nodeProfiles = stateService.latestSnapshot?.nodeProfiles ?? [:] + + return VStack(alignment: .leading, spacing: 1) { + if bridgeStatuses.isEmpty { + Text("Cluster TB Bridge: No data") + .font(.caption2) + .foregroundColor(.secondary) + } else { + Text("Cluster TB Bridge Status:") + .font(.caption2) + .foregroundColor(.secondary) + ForEach(Array(bridgeStatuses.keys.sorted()), id: \.self) { nodeId in + if let status = bridgeStatuses[nodeId] { + let nodeName = + nodeProfiles[nodeId]?.friendlyName ?? String(nodeId.prefix(8)) + let isLocal = nodeId == localNodeId + let prefix = isLocal ? " \(nodeName) (local):" : " \(nodeName):" + let statusText = + !status.exists + ? "N/A" + : (status.enabled ? "Enabled" : "Disabled") + let color: Color = + !status.exists + ? .secondary + : (status.enabled ? .red : .green) + Text("\(prefix) \(statusText)") + .font(.caption2) + .foregroundColor(color) + } + } + } + } + } + + private var interfaceIpList: some View { + let statuses = networkStatusService.status.interfaceStatuses + return VStack(alignment: .leading, spacing: 1) { + Text("Interfaces (en0–en7):") + .font(.caption2) + .foregroundColor(.secondary) + if statuses.isEmpty { + Text(" Unknown") + .font(.caption2) + .foregroundColor(.secondary) + } else { + ForEach(statuses, id: \.interfaceName) { status in + let ipText = status.ipAddress ?? "No IP" + Text(" \(status.interfaceName): \(ipText)") + .font(.caption2) + .foregroundColor(status.ipAddress == nil ? .red : .green) + } + } + } + } + + private var rdmaStatusView: some View { + let rdmaStatuses = stateService.latestSnapshot?.nodeRdmaCtl ?? [:] + let localNodeId = stateService.localNodeId + let nodeProfiles = stateService.latestSnapshot?.nodeProfiles ?? [:] + let localDevices = networkStatusService.status.localRdmaDevices + let localPorts = networkStatusService.status.localRdmaActivePorts + + return VStack(alignment: .leading, spacing: 1) { + if rdmaStatuses.isEmpty { + Text("Cluster RDMA: No data") + .font(.caption2) + .foregroundColor(.secondary) + } else { + Text("Cluster RDMA Status:") + .font(.caption2) + .foregroundColor(.secondary) + ForEach(Array(rdmaStatuses.keys.sorted()), id: \.self) { nodeId in + if let status = rdmaStatuses[nodeId] { + let nodeName = + nodeProfiles[nodeId]?.friendlyName ?? String(nodeId.prefix(8)) + let isLocal = nodeId == localNodeId + let prefix = isLocal ? " \(nodeName) (local):" : " \(nodeName):" + let statusText = status.enabled ? "Enabled" : "Disabled" + let color: Color = status.enabled ? .green : .orange + Text("\(prefix) \(statusText)") + .font(.caption2) + .foregroundColor(color) + } + } + } + if !localDevices.isEmpty { + Text(" Local Devices: \(localDevices.joined(separator: ", "))") + .font(.caption2) + .foregroundColor(.secondary) + } + if !localPorts.isEmpty { + Text(" Local Active Ports:") + .font(.caption2) + .foregroundColor(.secondary) + ForEach(localPorts, id: \.device) { port in + Text(" \(port.device) port \(port.port): \(port.state)") + .font(.caption2) + .foregroundColor(.green) + } + } + } + } + + private var sendBugReportButton: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + Task { + await sendBugReport() + } + } label: { + HStack { + if bugReportInFlight { + ProgressView() + .scaleEffect(0.6) + } + Text("Send Bug Report") + .font(.caption) + .fontWeight(.semibold) + Spacer() + } + } + .disabled(bugReportInFlight) + + if let message = bugReportMessage { + Text(message) + .font(.caption2) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // MARK: - Actions + + private func sendBugReport() async { + bugReportInFlight = true + bugReportMessage = "Collecting logs..." + let service = BugReportService() + do { + let outcome = try await service.sendReport(isManual: true) + bugReportMessage = outcome.message + } catch { + bugReportMessage = error.localizedDescription + } + bugReportInFlight = false + } + + private func showUninstallConfirmationAlert() { + let alert = NSAlert() + alert.messageText = "Uninstall EXO" + alert.informativeText = """ + This will remove EXO and all its system components: + + • Network configuration daemon + • Launch at login registration + • EXO network location + + The app will be moved to Trash. + """ + alert.alertStyle = .warning + alert.addButton(withTitle: "Uninstall") + alert.addButton(withTitle: "Cancel") + + if let uninstallButton = alert.buttons.first { + uninstallButton.hasDestructiveAction = true + } + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + performUninstall() + } + } + + private func performUninstall() { + uninstallInProgress = true + + controller.cancelPendingLaunch() + controller.stop() + stateService.stopPolling() + + DispatchQueue.global(qos: .utility).async { + do { + try NetworkSetupHelper.uninstall() + + DispatchQueue.main.async { + LaunchAtLoginHelper.disable() + self.moveAppToTrash() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NSApplication.shared.terminate(nil) + } + } + } catch { + DispatchQueue.main.async { + let errorAlert = NSAlert() + errorAlert.messageText = "Uninstall Failed" + errorAlert.informativeText = error.localizedDescription + errorAlert.alertStyle = .critical + errorAlert.addButton(withTitle: "OK") + errorAlert.runModal() + self.uninstallInProgress = false + } + } + } + } + + private func moveAppToTrash() { + guard let appURL = Bundle.main.bundleURL as URL? else { return } + do { + try FileManager.default.trashItem(at: appURL, resultingItemURL: nil) + } catch { + // If we can't trash the app, that's OK - user can do it manually + } + } + // MARK: - Helpers private var hasGeneralChanges: Bool { diff --git a/app/EXO/EXO/Views/SettingsWindowController.swift b/app/EXO/EXO/Views/SettingsWindowController.swift index 7ac6b1bc..98517f92 100644 --- a/app/EXO/EXO/Views/SettingsWindowController.swift +++ b/app/EXO/EXO/Views/SettingsWindowController.swift @@ -7,7 +7,13 @@ import SwiftUI final class SettingsWindowController: ObservableObject { private var window: NSWindow? - func open(controller: ExoProcessController, updater: SparkleUpdater) { + func open( + controller: ExoProcessController, + updater: SparkleUpdater, + networkStatusService: NetworkStatusService, + thunderboltBridgeService: ThunderboltBridgeService, + stateService: ClusterStateService + ) { if let existing = window, existing.isVisible { existing.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) @@ -17,11 +23,14 @@ final class SettingsWindowController: ObservableObject { let settingsView = SettingsView() .environmentObject(controller) .environmentObject(updater) + .environmentObject(networkStatusService) + .environmentObject(thunderboltBridgeService) + .environmentObject(stateService) let hostingView = NSHostingView(rootView: settingsView) let newWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 450, height: 320), + contentRect: NSRect(x: 0, y: 0, width: 450, height: 400), styleMask: [.titled, .closable], backing: .buffered, defer: false diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index 031f2575..29653566 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -36,6 +36,7 @@ createConversation, setSelectedChatModel, selectedChatModel, + sendMessage, debugMode, toggleDebugMode, topologyOnlyMode, @@ -822,6 +823,16 @@ onMount(() => { mounted = true; fetchModels(); + + // Handle reset-onboarding query parameter (triggered from native Settings) + const params = new URLSearchParams(window.location.search); + if (params.has("reset-onboarding")) { + localStorage.removeItem(ONBOARDING_COMPLETE_KEY); + window.history.replaceState({}, "", window.location.pathname); + onboardingStep = 1; + return; + } + // Show onboarding wizard for first-time users if (!localStorage.getItem(ONBOARDING_COMPLETE_KEY)) { onboardingStep = 1; @@ -2604,10 +2615,15 @@ onNodeClick={togglePreviewNodeFilter} /> +

+ Running on {nodeCount} device{nodeCount !== 1 ? "s" : ""}. Install + exo on more devices on your network to combine their power — they + connect automatically. +

+ {/each} + + {:else if !chatStarted}