From 8d101d3e22335f9d8301ac5bfcd2a154ae97bfba Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 13:47:26 +1300 Subject: [PATCH 1/8] Intial Implemenation of SwiftBot Remote View Adds macOS Server style remote view --- SwiftBot.xcodeproj/project.pbxproj | 16 + SwiftBotApp/AdminWebServer.swift | 90 ++- SwiftBotApp/AppModel.swift | 241 +++++- SwiftBotApp/CommonUI.swift | 28 +- SwiftBotApp/GeneralPreferencesView.swift | 2 +- SwiftBotApp/Models.swift | 21 + SwiftBotApp/OnboardingView.swift | 179 ++++- SwiftBotApp/PreferencesView.swift | 72 +- SwiftBotApp/RemoteModeRootView.swift | 703 ++++++++++++++++++ SwiftBotApp/RootView.swift | 4 + SwiftBotApp/Services/RemoteAPI.swift | 110 +++ .../Services/RemoteControlService.swift | 153 ++++ SwiftBotApp/Services/RemoteModels.swift | 135 ++++ SwiftBotApp/SettingsView.swift | 12 +- 14 files changed, 1694 insertions(+), 72 deletions(-) create mode 100644 SwiftBotApp/RemoteModeRootView.swift create mode 100644 SwiftBotApp/Services/RemoteAPI.swift create mode 100644 SwiftBotApp/Services/RemoteControlService.swift create mode 100644 SwiftBotApp/Services/RemoteModels.swift diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index fd80b75..a98c9d6 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -41,6 +41,10 @@ A1B2C3D40111223344556A04 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D40111223344556904 /* NIOCore */; }; A1B2C3D40111223344556A05 /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D40111223344556905 /* NIOPosix */; }; A1B2C3D40111223344556A06 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D40111223344556906 /* NIOSSL */; }; + A01010101010101010101001 /* RemoteModeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101001 /* RemoteModeRootView.swift */; }; + A01010101010101010101002 /* Services/RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101002 /* Services/RemoteModels.swift */; }; + A01010101010101010101003 /* Services/RemoteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101003 /* Services/RemoteAPI.swift */; }; + A01010101010101010101004 /* Services/RemoteControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101004 /* Services/RemoteControlService.swift */; }; A7B810001122334455667788 /* PatchyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B811001122334455667788 /* PatchyView.swift */; }; A7B812001122334455667788 /* PatchyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B813001122334455667788 /* PatchyViewModel.swift */; }; AA1000011122334455667701 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667701 /* PreferencesView.swift */; }; @@ -92,6 +96,10 @@ A1B2C3D40111223344556703 /* Security/CloudflareDNSProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Security/CloudflareDNSProvider.swift; sourceTree = ""; }; A1B2C3D40111223344556704 /* Security/CloudflareTunnelClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Security/CloudflareTunnelClient.swift; sourceTree = ""; }; A1B2C3D40111223344556705 /* Security/TunnelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Security/TunnelManager.swift; sourceTree = ""; }; + B01010101010101010101001 /* RemoteModeRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteModeRootView.swift; sourceTree = ""; }; + B01010101010101010101002 /* Services/RemoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteModels.swift; sourceTree = ""; }; + B01010101010101010101003 /* Services/RemoteAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteAPI.swift; sourceTree = ""; }; + B01010101010101010101004 /* Services/RemoteControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteControlService.swift; sourceTree = ""; }; A1B2C3D4E5F60708001122A3 /* CommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsView.swift; sourceTree = ""; }; A7B811001122334455667788 /* PatchyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchyView.swift; sourceTree = ""; }; A7B813001122334455667788 /* PatchyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchyViewModel.swift; sourceTree = ""; }; @@ -158,6 +166,9 @@ children = ( 6337960E98AEBC0A19A67531 /* AppModel.swift */, 11C22D661122334455667788 /* AdminWebServer.swift */, + B01010101010101010101002 /* Services/RemoteModels.swift */, + B01010101010101010101003 /* Services/RemoteAPI.swift */, + B01010101010101010101004 /* Services/RemoteControlService.swift */, A1B2C3D40111223344556701 /* Security/CertificateManager.swift */, A1B2C3D40111223344556702 /* Security/ACMEClient.swift */, A1B2C3D40111223344556703 /* Security/CloudflareDNSProvider.swift */, @@ -187,6 +198,7 @@ 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */, 0A6B7D201122334455667788 /* Resources */, 35480F12EB2C7DFB546BD550 /* RootView.swift */, + B01010101010101010101001 /* RemoteModeRootView.swift */, C1D2E3F4A5B6C7D8E9F00111 /* OnboardingView.swift */, D2E3F4A5B6C7D8E9F0011223 /* OverviewView.swift */, F4A5B6C7D8E9F001122334A5 /* VoiceActionsView.swift */, @@ -314,9 +326,13 @@ B4F6C2011122334455667788 /* SchemaSettings.swift in Sources */, C3D4E5F60112233445566778 /* SwiftMeshView.swift in Sources */, 538A5B6B3E4166EE17B3A077 /* RootView.swift in Sources */, + A01010101010101010101001 /* RemoteModeRootView.swift in Sources */, C1D2E3F4A5B6C7D8E9F00112 /* OnboardingView.swift in Sources */, D6E7F8091A2B3C4D5E6F7082 /* IntelligenceGlowBorder.swift in Sources */, D2E3F4A5B6C7D8E9F0011224 /* OverviewView.swift in Sources */, + A01010101010101010101002 /* Services/RemoteModels.swift in Sources */, + A01010101010101010101003 /* Services/RemoteAPI.swift in Sources */, + A01010101010101010101004 /* Services/RemoteControlService.swift in Sources */, F4A5B6C7D8E9F001122334A6 /* VoiceActionsView.swift in Sources */, B2C3D4E5F60708001122334A /* CommandsView.swift in Sources */, D4E5F607080011223344556B /* LogsView.swift in Sources */, diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 57a138c..0b4df92 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -297,6 +297,7 @@ actor AdminWebServer { var discordOAuth: OAuthProviderSettings var redirectPath: String var allowedUserIDs: [String] + var remoteAccessToken: String } private struct HTTPRequest { @@ -347,7 +348,8 @@ actor AdminWebServer { https: nil, discordOAuth: OAuthProviderSettings(), redirectPath: "/auth/discord/callback", - allowedUserIDs: [] + allowedUserIDs: [], + remoteAccessToken: "" ) private var listener: NWListener? private var nioChannel: Channel? @@ -356,6 +358,12 @@ actor AdminWebServer { private var activeTransportUsesTLS = false private var statusProvider: (@Sendable () async -> AdminWebStatusPayload)? private var overviewProvider: (@Sendable () async -> AdminWebOverviewPayload)? + private var remoteStatusProvider: (@Sendable () async -> RemoteStatusPayload)? + private var remoteRulesProvider: (@Sendable () async -> RemoteRulesPayload)? + private var updateRemoteRule: (@Sendable (Rule) async -> Bool)? + private var remoteEventsProvider: (@Sendable () async -> RemoteEventsPayload)? + private var remoteSettingsProvider: (@Sendable () async -> AdminWebConfigPayload)? + private var updateRemoteSettings: (@Sendable (AdminWebConfigPatch) async -> Bool)? private var connectedGuildIDsProvider: (@Sendable () async -> Set)? private var currentPrefixProvider: (@Sendable () async -> String)? private var updatePrefix: (@Sendable (String) async -> Bool)? @@ -397,6 +405,12 @@ actor AdminWebServer { func configure( config: Configuration, statusProvider: @escaping @Sendable () async -> AdminWebStatusPayload, + remoteStatusProvider: @escaping @Sendable () async -> RemoteStatusPayload, + remoteRulesProvider: @escaping @Sendable () async -> RemoteRulesPayload, + updateRemoteRule: @escaping @Sendable (Rule) async -> Bool, + remoteEventsProvider: @escaping @Sendable () async -> RemoteEventsPayload, + remoteSettingsProvider: @escaping @Sendable () async -> AdminWebConfigPayload, + updateRemoteSettings: @escaping @Sendable (AdminWebConfigPatch) async -> Bool, overviewProvider: @escaping @Sendable () async -> AdminWebOverviewPayload, connectedGuildIDsProvider: @escaping @Sendable () async -> Set, currentPrefixProvider: @escaping @Sendable () async -> String, @@ -431,6 +445,12 @@ actor AdminWebServer { log: @escaping @Sendable (String) async -> Void ) async -> RuntimeState { self.statusProvider = statusProvider + self.remoteStatusProvider = remoteStatusProvider + self.remoteRulesProvider = remoteRulesProvider + self.updateRemoteRule = updateRemoteRule + self.remoteEventsProvider = remoteEventsProvider + self.remoteSettingsProvider = remoteSettingsProvider + self.updateRemoteSettings = updateRemoteSettings self.overviewProvider = overviewProvider self.connectedGuildIDsProvider = connectedGuildIDsProvider self.currentPrefixProvider = currentPrefixProvider @@ -762,6 +782,62 @@ actor AdminWebServer { return serveAsset(named: "AppIcon", ext: "png") case ("GET", "/health"): return jsonResponse(["status": "ok"]) + case ("GET", "/api/remote/status"): + guard isRemoteRequestAuthorized(request) else { + return unauthorizedResponse() + } + if let payload = await remoteStatusProvider?() { + return codableResponse(payload) + } + return jsonResponse(["error": "status_unavailable"], status: "503 Service Unavailable") + case ("GET", "/api/remote/rules"): + guard isRemoteRequestAuthorized(request) else { + return unauthorizedResponse() + } + if let payload = await remoteRulesProvider?() { + return codableResponse(payload) + } + return jsonResponse(["error": "rules_unavailable"], status: "503 Service Unavailable") + case ("POST", "/api/remote/rules/update"): + guard isRemoteRequestAuthorized(request) else { + return unauthorizedResponse() + } + guard let patch = try? decoder.decode(RemoteRuleUpsertRequest.self, from: request.body) else { + return jsonResponse(["error": "invalid_payload"], status: "400 Bad Request") + } + guard await updateRemoteRule?(patch.rule) == true else { + return jsonResponse(["error": "update_failed"], status: "400 Bad Request") + } + await logger?("Remote API updated rule \(patch.rule.name)") + return jsonResponse(["ok": true]) + case ("GET", "/api/remote/events"): + guard isRemoteRequestAuthorized(request) else { + return unauthorizedResponse() + } + if let payload = await remoteEventsProvider?() { + return codableResponse(payload) + } + return jsonResponse(["error": "events_unavailable"], status: "503 Service Unavailable") + case ("GET", "/api/remote/settings"): + guard isRemoteRequestAuthorized(request) else { + return unauthorizedResponse() + } + if let payload = await remoteSettingsProvider?() { + return codableResponse(payload) + } + return jsonResponse(["error": "settings_unavailable"], status: "503 Service Unavailable") + case ("POST", "/api/remote/settings/update"): + guard isRemoteRequestAuthorized(request) else { + return unauthorizedResponse() + } + guard let patch = try? decoder.decode(AdminWebConfigPatch.self, from: request.body) else { + return jsonResponse(["error": "invalid_payload"], status: "400 Bad Request") + } + guard await updateRemoteSettings?(patch) == true else { + return jsonResponse(["error": "update_failed"], status: "400 Bad Request") + } + await logger?("Remote API updated settings") + return jsonResponse(["ok": true]) case ("GET", "/api/status"): let payload = await statusProvider?() ?? AdminWebStatusPayload( botStatus: "stopped", @@ -1322,6 +1398,18 @@ actor AdminWebServer { return session } + private func isRemoteRequestAuthorized(_ request: HTTPRequest) -> Bool { + let expectedToken = config.remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !expectedToken.isEmpty, + let authorization = request.headers["authorization"]?.trimmingCharacters(in: .whitespacesAndNewlines), + authorization.hasPrefix("Bearer ") else { + return false + } + + let providedToken = String(authorization.dropFirst("Bearer ".count)).trimmingCharacters(in: .whitespacesAndNewlines) + return !providedToken.isEmpty && providedToken == expectedToken + } + private func validateCSRF(session: Session, request: HTTPRequest) -> Bool { request.headers["x-admin-csrf"] == session.csrfToken } diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index b175ae4..7e11770 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -266,6 +266,23 @@ final class AppModel: ObservableObject { return URL(string: "https://cdn.discordapp.com/embed/avatars/\(index).png") } + var isRemoteLaunchMode: Bool { + settings.launchMode == .remoteControl + } + + var usesLocalRuntime: Bool { + settings.launchMode != .remoteControl + } + + private func onboardingCompleted(for settings: BotSettings) -> Bool { + switch settings.launchMode { + case .remoteControl: + return settings.remoteMode.isConfigured + case .standaloneBot, .swiftMeshClusterNode: + return !settings.token.isEmpty + } + } + init() { self.ruleEngine = RuleEngine(store: ruleStore) self.pluginManager = PluginManager(bus: eventBus) @@ -308,9 +325,14 @@ final class AppModel: ObservableObject { workerModeMigrated = true migrated = true } + loadedSettings.remoteMode.normalize() + if loadedSettings.remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + loadedSettings.remoteAccessToken = generatedRemoteAccessToken() + migrated = true + } settings = loadedSettings - isOnboardingComplete = !loadedSettings.token.isEmpty + isOnboardingComplete = onboardingCompleted(for: loadedSettings) if let cachedDiscord = await discordCacheStore.load() { await discordCache.replace(with: cachedDiscord) await syncPublishedDiscordCacheFromService() @@ -329,6 +351,13 @@ final class AppModel: ObservableObject { try? await swiftMeshConfigStore.save(loadedSettings.swiftMeshSettings) } + guard loadedSettings.launchMode != .remoteControl else { + await cluster.stopAll() + await adminWebServer.stop() + await service.setOutputAllowed(false) + return + } + await service.setRuleEngine(ruleEngine) await service.setHistoryProvider { [weak self] scope in guard let self else { return [] } @@ -467,28 +496,44 @@ final class AppModel: ObservableObject { settings.adminWebUI.importedCertificateChainFile = settings.adminWebUI.normalizedImportedCertificateChainFile settings.adminWebUI.publicBaseURL = settings.adminWebUI.publicBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) settings.adminWebUI.allowedUserIDs = settings.adminWebUI.normalizedAllowedUserIDs + settings.remoteMode.normalize() + settings.remoteAccessToken = settings.remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) + if settings.remoteAccessToken.isEmpty { + settings.remoteAccessToken = generatedRemoteAccessToken() + } + isOnboardingComplete = onboardingCompleted(for: settings) Task { - await service.configureLocalAIDMReplies( - enabled: settings.localAIDMReplyEnabled, - provider: settings.localAIProvider, - preferredProvider: settings.preferredAIProvider, - endpoint: localAIEndpointForService(), - model: settings.localAIModel, - openAIAPIKey: effectiveOpenAIAPIKey(), - openAIModel: settings.openAIModel, - systemPrompt: settings.localAISystemPrompt - ) - await applyClusterSettingsRuntime( - mode: settings.clusterMode, - nodeName: settings.clusterNodeName, - leaderAddress: settings.clusterLeaderAddress, - leaderPort: settings.clusterLeaderPort, - listenPort: settings.clusterListenPort, - sharedSecret: settings.clusterSharedSecret - ) - await configureAdminWebServer() - configurePatchyMonitoring() + if self.usesLocalRuntime { + await service.configureLocalAIDMReplies( + enabled: settings.localAIDMReplyEnabled, + provider: settings.localAIProvider, + preferredProvider: settings.preferredAIProvider, + endpoint: localAIEndpointForService(), + model: settings.localAIModel, + openAIAPIKey: effectiveOpenAIAPIKey(), + openAIModel: settings.openAIModel, + systemPrompt: settings.localAISystemPrompt + ) + await applyClusterSettingsRuntime( + mode: settings.clusterMode, + nodeName: settings.clusterNodeName, + leaderAddress: settings.clusterLeaderAddress, + leaderPort: settings.clusterLeaderPort, + listenPort: settings.clusterListenPort, + sharedSecret: settings.clusterSharedSecret + ) + await configureAdminWebServer() + configurePatchyMonitoring() + } else { + patchyMonitorTask?.cancel() + patchyMonitorTask = nil + await cluster.stopAll() + await adminWebServer.stop() + await service.setOutputAllowed(false) + adminWebResolvedBaseURL = "" + adminWebPublicAccessStatus = AdminWebPublicAccessRuntimeStatus() + } do { try await store.save(settings) @@ -961,6 +1006,13 @@ final class AppModel: ObservableObject { } func startBot() async { + if isRemoteLaunchMode { + await MainActor.run { + logs.append("⚠️ Remote Control Mode does not start a local Discord bot.") + } + return + } + // Worker mode is temporarily disabled pending UX redesign. // The underlying code is preserved; re-enable by removing this guard when ready. if settings.clusterMode == .worker { @@ -1101,6 +1153,7 @@ final class AppModel: ObservableObject { /// call `completeOnboarding()` after the user gives explicit confirmation. @discardableResult func validateAndOnboard() async -> Bool { + settings.launchMode = .standaloneBot let token = normalizedDiscordToken(from: settings.token) guard !token.isEmpty else { return false } let result = await service.validateBotTokenRich(token) @@ -1119,6 +1172,26 @@ final class AppModel: ObservableObject { isOnboardingComplete = true } + func completeRemoteModeOnboarding(primaryNodeAddress: String, accessToken: String) { + settings.launchMode = .remoteControl + settings.remoteMode = RemoteModeSettings( + primaryNodeAddress: primaryNodeAddress, + accessToken: accessToken + ) + settings.remoteMode.normalize() + saveSettings() + isOnboardingComplete = true + } + + func updateRemoteModeConnection(primaryNodeAddress: String, accessToken: String) { + settings.remoteMode = RemoteModeSettings( + primaryNodeAddress: primaryNodeAddress, + accessToken: accessToken + ) + settings.remoteMode.normalize() + saveSettings() + } + /// Performs a safe API key reset with deterministic ordering: /// 1. Awaits gateway disconnect (cancels reconnect task, sets userInitiatedDisconnect). /// 2. Clears all bot runtime state. @@ -1400,6 +1473,69 @@ final class AppModel: ObservableObject { ) } + func remoteStatusSnapshot() -> RemoteStatusPayload { + let leaderName = clusterNodes.first(where: { $0.role == .leader })?.displayName + ?? clusterNodes.first?.displayName + ?? (settings.clusterMode == .standalone ? "Standalone" : "Unavailable") + + return RemoteStatusPayload( + botStatus: status.rawValue, + botUsername: botUsername, + connectedServerCount: connectedServers.count, + gatewayEventCount: gatewayEventCount, + uptimeText: uptime?.text, + webUIBaseURL: adminWebBaseURL(), + clusterMode: settings.clusterMode.rawValue, + nodeRole: clusterSnapshot.mode.rawValue, + leaderName: leaderName, + generatedAt: Date() + ) + } + + func remoteRulesSnapshot() -> RemoteRulesPayload { + let serverIDs = connectedServers.keys.sorted { + (connectedServers[$0] ?? $0).localizedCaseInsensitiveCompare(connectedServers[$1] ?? $1) == .orderedAscending + } + let servers = serverIDs.map { AdminWebSimpleOption(id: $0, name: connectedServers[$0] ?? $0) } + let textChannelsByServer = Dictionary(uniqueKeysWithValues: serverIDs.map { serverID in + let channels = (availableTextChannelsByServer[serverID] ?? []) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { AdminWebSimpleOption(id: $0.id, name: $0.name) } + return (serverID, channels) + }) + let voiceChannelsByServer = Dictionary(uniqueKeysWithValues: serverIDs.map { serverID in + let channels = (availableVoiceChannelsByServer[serverID] ?? []) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { AdminWebSimpleOption(id: $0.id, name: $0.name) } + return (serverID, channels) + }) + + return RemoteRulesPayload( + rules: ruleStore.rules, + servers: servers, + textChannelsByServer: textChannelsByServer, + voiceChannelsByServer: voiceChannelsByServer, + fetchedAt: Date() + ) + } + + func remoteEventsSnapshot() -> RemoteEventsPayload { + let recentActivity = Array(events.suffix(40).reversed()).map { event in + RemoteActivityEventPayload( + id: event.id, + timestamp: event.timestamp, + kind: event.kind.rawValue, + message: event.message + ) + } + + return RemoteEventsPayload( + activity: recentActivity, + logs: Array(logs.lines.suffix(120).reversed()), + fetchedAt: Date() + ) + } + func adminWebBaseURL() -> String { if adminWebPublicAccessStatus.isEnabled, !adminWebPublicAccessStatus.publicURL.isEmpty { return adminWebPublicAccessStatus.publicURL @@ -1873,9 +2009,9 @@ final class AppModel: ObservableObject { } func configureAdminWebServer() async { - let httpsConfiguration = await resolveAdminWebHTTPSConfiguration() + let httpsConfiguration = usesLocalRuntime ? await resolveAdminWebHTTPSConfiguration() : nil let config = AdminWebServer.Configuration( - enabled: settings.adminWebUI.enabled, + enabled: usesLocalRuntime && settings.adminWebUI.enabled, bindHost: settings.adminWebUI.bindHost, port: settings.adminWebUI.port, publicBaseURL: oauthPublicBaseURL(), @@ -1884,7 +2020,8 @@ final class AppModel: ObservableObject { redirectPath: settings.adminWebUI.redirectPath, allowedUserIDs: settings.adminWebUI.restrictAccessToSpecificUsers ? settings.adminWebUI.normalizedAllowedUserIDs - : [] + : [], + remoteAccessToken: settings.remoteAccessToken ) let runtimeState = await adminWebServer.configure( @@ -1903,6 +2040,62 @@ final class AppModel: ObservableObject { } return await MainActor.run { model.adminWebStatusSnapshot() } }, + remoteStatusProvider: { [weak self] in + guard let model = self else { + return RemoteStatusPayload( + botStatus: "stopped", + botUsername: "SwiftBot", + connectedServerCount: 0, + gatewayEventCount: 0, + uptimeText: nil, + webUIBaseURL: "", + clusterMode: ClusterMode.standalone.rawValue, + nodeRole: ClusterMode.standalone.rawValue, + leaderName: "Unavailable", + generatedAt: Date() + ) + } + return await MainActor.run { model.remoteStatusSnapshot() } + }, + remoteRulesProvider: { [weak self] in + guard let model = self else { + return RemoteRulesPayload( + rules: [], + servers: [], + textChannelsByServer: [:], + voiceChannelsByServer: [:], + fetchedAt: Date() + ) + } + return await MainActor.run { model.remoteRulesSnapshot() } + }, + updateRemoteRule: { [weak self] rule in + guard let model = self else { return false } + return await MainActor.run { model.upsertAdminWebActionRule(rule) } + }, + remoteEventsProvider: { [weak self] in + guard let model = self else { + return RemoteEventsPayload(activity: [], logs: [], fetchedAt: Date()) + } + return await MainActor.run { model.remoteEventsSnapshot() } + }, + remoteSettingsProvider: { [weak self] in + guard let model = self else { + return AdminWebConfigPayload( + commands: .init(enabled: true, prefixEnabled: true, slashEnabled: true, bugTrackingEnabled: true, prefix: "/"), + aiBots: .init(localAIDMReplyEnabled: false, preferredProvider: AIProviderPreference.apple.rawValue, openAIEnabled: false, openAIModel: "", openAIImageGenerationEnabled: false, openAIImageMonthlyLimitPerUser: 0), + wikiBridge: .init(enabled: false, enabledSources: 0, totalSources: 0), + patchy: .init(monitoringEnabled: false, enabledTargets: 0, totalTargets: 0), + swiftMesh: .init(mode: ClusterMode.standalone.rawValue, nodeName: "SwiftBot", leaderAddress: "", listenPort: 38787, offloadAIReplies: false, offloadWikiLookups: false), + general: .init(autoStart: false, webUIEnabled: false, webUIBaseURL: "") + ) + } + return await MainActor.run { model.adminWebConfigSnapshot() } + }, + updateRemoteSettings: { [weak self] patch in + guard let model = self else { return false } + return await MainActor.run { model.applyAdminWebConfigPatch(patch) } + }, overviewProvider: { [weak self] in guard let model = self else { return AdminWebOverviewPayload( diff --git a/SwiftBotApp/CommonUI.swift b/SwiftBotApp/CommonUI.swift index 443a32c..8837f9b 100644 --- a/SwiftBotApp/CommonUI.swift +++ b/SwiftBotApp/CommonUI.swift @@ -192,13 +192,28 @@ struct ViewSectionHeader: View { let symbol: String var body: some View { - HStack(spacing: 10) { - Image(systemName: symbol) - .font(.title3.weight(.semibold)) - .foregroundStyle(.secondary) + SettingsSectionHeader( + title: title, + systemImage: symbol, + titleFont: .title2.weight(.semibold) + ) + } +} + +struct SettingsSectionHeader: View { + let title: String + let systemImage: String + var titleFont: Font = .headline + + var body: some View { + Label { Text(title) - .font(.title2.weight(.semibold)) + .font(titleFont) + } icon: { + Image(systemName: systemImage) + .imageScale(.medium) } + .labelStyle(.titleAndIcon) } } @@ -244,8 +259,7 @@ struct PreferencesCard: View { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 6) { if let systemImage { - Label(title, systemImage: systemImage) - .font(.headline) + SettingsSectionHeader(title: title, systemImage: systemImage) } else { Text(title) .font(.headline) diff --git a/SwiftBotApp/GeneralPreferencesView.swift b/SwiftBotApp/GeneralPreferencesView.swift index 206f6cf..b95eaed 100644 --- a/SwiftBotApp/GeneralPreferencesView.swift +++ b/SwiftBotApp/GeneralPreferencesView.swift @@ -23,7 +23,7 @@ struct GeneralPreferencesView: View { PreferencesReadOnlyBanner(text: "Read-only on Failover nodes. These settings sync from Primary.") } - PreferencesCard("General", systemImage: "gear") { + PreferencesCard("General", systemImage: "gearshape") { Toggle("Start Bot Automatically", isOn: $app.settings.autoStart) .toggleStyle(.switch) } diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 849562f..9411d01 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -365,8 +365,15 @@ struct AdminWebUISettings: Codable, Hashable { } } +func generatedRemoteAccessToken() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() +} + struct BotSettings: Codable, Hashable { var token: String = "" + var launchMode: AppLaunchMode = .standaloneBot + var remoteMode = RemoteModeSettings() + var remoteAccessToken: String = generatedRemoteAccessToken() var prefix: String = "/" var commandsEnabled: Bool = true var prefixCommandsEnabled: Bool = true @@ -447,6 +454,9 @@ struct BotSettings: Codable, Hashable { private enum CodingKeys: String, CodingKey { case token + case launchMode + case remoteMode + case remoteAccessToken case prefix case commandsEnabled case prefixCommandsEnabled @@ -507,6 +517,9 @@ struct BotSettings: Codable, Hashable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) token = try container.decodeIfPresent(String.self, forKey: .token) ?? "" + launchMode = try container.decodeIfPresent(AppLaunchMode.self, forKey: .launchMode) ?? .standaloneBot + remoteMode = try container.decodeIfPresent(RemoteModeSettings.self, forKey: .remoteMode) ?? RemoteModeSettings() + remoteAccessToken = try container.decodeIfPresent(String.self, forKey: .remoteAccessToken) ?? generatedRemoteAccessToken() prefix = try container.decodeIfPresent(String.self, forKey: .prefix) ?? "/" commandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .commandsEnabled) ?? true prefixCommandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .prefixCommandsEnabled) ?? true @@ -563,11 +576,19 @@ struct BotSettings: Codable, Hashable { patchy = try container.decodeIfPresent(PatchySettings.self, forKey: .patchy) ?? PatchySettings() help = try container.decodeIfPresent(HelpSettings.self, forKey: .help) ?? HelpSettings() adminWebUI = try container.decodeIfPresent(AdminWebUISettings.self, forKey: .adminWebUI) ?? AdminWebUISettings() + remoteMode.normalize() + remoteAccessToken = remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) + if remoteAccessToken.isEmpty { + remoteAccessToken = generatedRemoteAccessToken() + } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(token, forKey: .token) + try container.encode(launchMode, forKey: .launchMode) + try container.encode(remoteMode, forKey: .remoteMode) + try container.encode(remoteAccessToken, forKey: .remoteAccessToken) try container.encode(prefix, forKey: .prefix) try container.encode(commandsEnabled, forKey: .commandsEnabled) try container.encode(prefixCommandsEnabled, forKey: .prefixCommandsEnabled) diff --git a/SwiftBotApp/OnboardingView.swift b/SwiftBotApp/OnboardingView.swift index 224b3cf..18f02ce 100644 --- a/SwiftBotApp/OnboardingView.swift +++ b/SwiftBotApp/OnboardingView.swift @@ -13,6 +13,8 @@ struct OnboardingGateView: View { case entry, validating, confirmed, failed // SwiftMesh path case meshSetup, meshTesting, meshConfirmed, meshFailed + // Remote control path + case remoteSetup, remoteTesting, remoteConfirmed, remoteFailed } @State private var step: Step = .choosePath @@ -23,6 +25,10 @@ struct OnboardingGateView: View { @State private var isLoadingInviteURL: Bool = false @State private var inviteLoadFailed: Bool = false @State private var movesForward: Bool = true + @State private var remoteAddressInput: String = "" + @State private var remoteAccessTokenInput: String = "" + @State private var showRemoteToken: Bool = false + @StateObject private var remoteTester = RemoteControlService() var body: some View { ZStack { @@ -57,6 +63,9 @@ struct OnboardingGateView: View { case .meshSetup, .meshTesting, .meshConfirmed, .meshFailed: meshFlow .id("meshFlow") + case .remoteSetup, .remoteTesting, .remoteConfirmed, .remoteFailed: + remoteFlow + .id("remoteFlow") } } .transition( @@ -70,7 +79,12 @@ struct OnboardingGateView: View { .padding(48) } .ignoresSafeArea() - .onAppear { tokenInput = app.settings.token } + .onAppear { + tokenInput = app.settings.token + remoteAddressInput = app.settings.remoteMode.primaryNodeAddress + remoteAccessTokenInput = app.settings.remoteMode.accessToken + remoteTester.updateConfiguration(app.settings.remoteMode) + } .onChange(of: app.workerConnectionTestInProgress) { _, inProgress in guard step == .meshTesting, !inProgress else { return } step = app.workerConnectionTestIsSuccess ? .meshConfirmed : .meshFailed @@ -92,6 +106,10 @@ struct OnboardingGateView: View { case .meshTesting: return "Testing connection to the SwiftMesh leader…" case .meshConfirmed: return "SwiftMesh connection successful." case .meshFailed: return "Could not reach the SwiftMesh leader." + case .remoteSetup: return "Connect to a primary SwiftBot node over HTTPS." + case .remoteTesting: return "Testing the remote control connection…" + case .remoteConfirmed: return "Remote control connection successful." + case .remoteFailed: return "SwiftBot could not authenticate with that primary node." } } @@ -113,6 +131,7 @@ struct OnboardingGateView: View { .controlSize(.large) Button { + app.settings.launchMode = .swiftMeshClusterNode app.settings.clusterMode = .leader step = .meshSetup } label: { @@ -127,6 +146,22 @@ struct OnboardingGateView: View { } .buttonStyle(.bordered) .controlSize(.large) + + Button { + app.settings.launchMode = .remoteControl + step = .remoteSetup + } label: { + VStack(spacing: 6) { + Image(systemName: "dot.radiowaves.left.and.right").font(.title2) + Text("Set Up Remote Control").font(.headline) + Text("Manage a primary SwiftBot node without running Discord locally.") + .font(.callout).foregroundStyle(.secondary) + } + .frame(maxWidth: 360) + .padding(20) + } + .buttonStyle(.bordered) + .controlSize(.large) } } @@ -175,6 +210,7 @@ struct OnboardingGateView: View { .buttonStyle(.bordered).controlSize(.large) Button { + app.settings.launchMode = .standaloneBot app.settings.token = tokenInput step = .validating Task { @@ -406,6 +442,144 @@ struct OnboardingGateView: View { .frame(maxWidth: 560) } + // MARK: - Remote flow + + @ViewBuilder + private var remoteFlow: some View { + switch step { + case .remoteSetup: + remoteSetupFields + case .remoteTesting: + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text("Testing connection…").foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Testing remote connection, please wait") + case .remoteConfirmed: + VStack(spacing: 16) { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) + .accessibilityHidden(true) + Text("Connected to \(remoteTester.status?.botUsername ?? "SwiftBot")") + .font(.body) + } + + if let latency = remoteTester.lastLatencyMs { + Text("Round-trip latency \(latency.formatted(.number.precision(.fractionLength(0)))) ms") + .font(.callout) + .foregroundStyle(.secondary) + } + + Button { + app.completeRemoteModeOnboarding( + primaryNodeAddress: remoteAddressInput, + accessToken: remoteAccessTokenInput + ) + } label: { + Label("Open Remote Dashboard", systemImage: "arrow.right.circle.fill") + .frame(minWidth: 220) + } + .onboardingGlassButton() + } + case .remoteFailed: + VStack(spacing: 16) { + if let error = remoteTester.lastError, !error.isEmpty { + Text(error) + .font(.callout) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } + + HStack(spacing: 12) { + Button { step = .remoteSetup } label: { + Label("Try Again", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { step = .choosePath } label: { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) + } + } + default: + EmptyView() + } + } + + private var remoteSetupFields: some View { + VStack(alignment: .leading, spacing: 16) { + TextField("https://mybot.example.com", text: $remoteAddressInput) + .onboardingTextFieldStyle() + .frame(maxWidth: 560) + + HStack(spacing: 10) { + Group { + if showRemoteToken { + TextField("Access Token", text: $remoteAccessTokenInput) + } else { + SecureField("Access Token", text: $remoteAccessTokenInput) + } + } + .onboardingTextFieldStyle() + .font(.system(.body, design: .monospaced)) + + Button { + showRemoteToken.toggle() + } label: { + Image(systemName: showRemoteToken ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(showRemoteToken ? "Hide access token" : "Show access token") + } + .frame(maxWidth: 560) + + if let error = remoteTester.lastError, !error.isEmpty { + Text(error) + .font(.callout) + .foregroundStyle(step == .remoteConfirmed ? Color.secondary : Color.red) + .multilineTextAlignment(.leading) + .frame(maxWidth: 560, alignment: .leading) + } + + HStack(spacing: 12) { + Button { step = .choosePath } label: { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + let config = RemoteModeSettings( + primaryNodeAddress: remoteAddressInput, + accessToken: remoteAccessTokenInput + ) + remoteTester.updateConfiguration(config) + step = .remoteTesting + + Task { + let ok = await remoteTester.testConnection() + step = ok ? .remoteConfirmed : .remoteFailed + } + } label: { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + .frame(minWidth: 220) + } + .buttonStyle(GlassActionButtonStyle()) + .controlSize(.large) + .disabled(remoteAddressInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || remoteAccessTokenInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(maxWidth: 560) + } + private func stepOrder(_ step: Step) -> Int { switch step { case .choosePath: return 0 @@ -415,6 +589,9 @@ struct OnboardingGateView: View { case .meshSetup: return 4 case .meshTesting: return 5 case .meshConfirmed, .meshFailed: return 6 + case .remoteSetup: return 7 + case .remoteTesting: return 8 + case .remoteConfirmed, .remoteFailed: return 9 } } } diff --git a/SwiftBotApp/PreferencesView.swift b/SwiftBotApp/PreferencesView.swift index e2135a0..5b70d4e 100644 --- a/SwiftBotApp/PreferencesView.swift +++ b/SwiftBotApp/PreferencesView.swift @@ -52,43 +52,59 @@ struct PreferencesView: View { } var body: some View { - TabView { - GeneralPreferencesView() - .tabItem { - Label("General", systemImage: "gear") + Group { + if app.isRemoteLaunchMode { + PreferencesTabContainer { + PreferencesCard( + "Remote Control Mode", + systemImage: "dot.radiowaves.left.and.right", + subtitle: "Local Discord, SwiftMesh, and Web UI runtime settings are inactive while this Mac is acting as a remote management client." + ) { + Text("Use the Remote dashboard to update the primary node connection, inspect status, edit rules, and change runtime settings on the primary.") + .font(.body) + .foregroundStyle(.secondary) + } } + } else { + TabView { + GeneralPreferencesView() + .tabItem { + Label("General", systemImage: "gear") + } - MeshPreferencesView() - .tabItem { - Label("SwiftMesh", systemImage: "point.3.connected.trianglepath.dotted") - } + MeshPreferencesView() + .tabItem { + Label("SwiftMesh", systemImage: "point.3.connected.trianglepath.dotted") + } - WebUIPreferencesView() - .tabItem { - Label("Web UI", systemImage: "globe") - } + WebUIPreferencesView() + .tabItem { + Label("Web UI", systemImage: "globe") + } - UpdatesPreferencesView() - .tabItem { - Label("Updates", systemImage: "arrow.clockwise") - } + UpdatesPreferencesView() + .tabItem { + Label("Updates", systemImage: "arrow.clockwise") + } - AdvancedPreferencesView() - .tabItem { - Label("Developer", systemImage: "wrench") + AdvancedPreferencesView() + .tabItem { + Label("Developer", systemImage: "wrench") + } } - } - .frame(width: 720, height: 480) - .overlay(alignment: .bottomTrailing) { - if hasUnsavedChanges && !app.isFailoverManagedNode { - StickySaveButton(label: "Save Settings", systemImage: "square.and.arrow.down.fill") { - app.saveSettings() - settingsSnapshot = currentSettingsSnapshot + .overlay(alignment: .bottomTrailing) { + if hasUnsavedChanges && !app.isFailoverManagedNode { + StickySaveButton(label: "Save Settings", systemImage: "square.and.arrow.down.fill") { + app.saveSettings() + settingsSnapshot = currentSettingsSnapshot + } + .padding(.trailing, 20) + .padding(.bottom, 18) + } } - .padding(.trailing, 20) - .padding(.bottom, 18) } } + .frame(width: 720, height: 480) .onAppear { settingsSnapshot = currentSettingsSnapshot } diff --git a/SwiftBotApp/RemoteModeRootView.swift b/SwiftBotApp/RemoteModeRootView.swift new file mode 100644 index 0000000..c3853cf --- /dev/null +++ b/SwiftBotApp/RemoteModeRootView.swift @@ -0,0 +1,703 @@ +import SwiftUI + +private enum RemoteSection: String, CaseIterable, Identifiable { + case status = "Status" + case rules = "Rules" + case events = "Events" + case settings = "Settings" + + var id: String { rawValue } + + var symbolName: String { + switch self { + case .status: + return "waveform.path.ecg" + case .rules: + return "slider.horizontal.3" + case .events: + return "list.bullet.rectangle.portrait" + case .settings: + return "gearshape.2" + } + } +} + +private struct RemoteSettingsDraft: Equatable { + var commandsEnabled = true + var prefixCommandsEnabled = true + var slashCommandsEnabled = true + var bugTrackingEnabled = true + var prefix = "/" + var localAIDMReplyEnabled = false + var preferredProvider = AIProviderPreference.apple.rawValue + var openAIEnabled = false + var openAIModel = "" + var openAIImageGenerationEnabled = false + var openAIImageMonthlyLimitPerUser = 0 + var wikiBridgeEnabled = false + var patchyMonitoringEnabled = false + var clusterMode = ClusterMode.standalone.rawValue + var clusterNodeName = "" + var clusterLeaderAddress = "" + var clusterListenPort = 38787 + var clusterOffloadAIReplies = false + var clusterOffloadWikiLookups = false + var autoStart = false + + init() {} + + init(payload: AdminWebConfigPayload) { + commandsEnabled = payload.commands.enabled + prefixCommandsEnabled = payload.commands.prefixEnabled + slashCommandsEnabled = payload.commands.slashEnabled + bugTrackingEnabled = payload.commands.bugTrackingEnabled + prefix = payload.commands.prefix + localAIDMReplyEnabled = payload.aiBots.localAIDMReplyEnabled + preferredProvider = payload.aiBots.preferredProvider + openAIEnabled = payload.aiBots.openAIEnabled + openAIModel = payload.aiBots.openAIModel + openAIImageGenerationEnabled = payload.aiBots.openAIImageGenerationEnabled + openAIImageMonthlyLimitPerUser = payload.aiBots.openAIImageMonthlyLimitPerUser + wikiBridgeEnabled = payload.wikiBridge.enabled + patchyMonitoringEnabled = payload.patchy.monitoringEnabled + clusterMode = payload.swiftMesh.mode + clusterNodeName = payload.swiftMesh.nodeName + clusterLeaderAddress = payload.swiftMesh.leaderAddress + clusterListenPort = payload.swiftMesh.listenPort + clusterOffloadAIReplies = payload.swiftMesh.offloadAIReplies + clusterOffloadWikiLookups = payload.swiftMesh.offloadWikiLookups + autoStart = payload.general.autoStart + } + + var patch: AdminWebConfigPatch { + AdminWebConfigPatch( + commandsEnabled: commandsEnabled, + prefixCommandsEnabled: prefixCommandsEnabled, + slashCommandsEnabled: slashCommandsEnabled, + bugTrackingEnabled: bugTrackingEnabled, + prefix: prefix, + localAIDMReplyEnabled: localAIDMReplyEnabled, + preferredAIProvider: preferredProvider, + openAIEnabled: openAIEnabled, + openAIModel: openAIModel, + openAIImageGenerationEnabled: openAIImageGenerationEnabled, + openAIImageMonthlyLimitPerUser: openAIImageMonthlyLimitPerUser, + wikiBridgeEnabled: wikiBridgeEnabled, + patchyMonitoringEnabled: patchyMonitoringEnabled, + clusterMode: clusterMode, + clusterNodeName: clusterNodeName, + clusterLeaderAddress: clusterLeaderAddress, + clusterListenPort: clusterListenPort, + clusterOffloadAIReplies: clusterOffloadAIReplies, + clusterOffloadWikiLookups: clusterOffloadWikiLookups, + autoStart: autoStart + ) + } +} + +struct RemoteModeRootView: View { + @EnvironmentObject private var app: AppModel + @StateObject private var remoteService = RemoteControlService() + @State private var selection: RemoteSection = .status + @State private var showingConnectionSheet = false + @State private var selectedRuleID: UUID? + @State private var ruleEditorText = "" + @State private var settingsDraft = RemoteSettingsDraft() + @State private var isSavingRule = false + @State private var isSavingSettings = false + + var body: some View { + NavigationSplitView { + sidebarView + } detail: { + detailView + } + .navigationSplitViewStyle(.balanced) + .background(SwiftBotGlassBackground()) + .toolbar { + toolbarContent + } + .sheet(isPresented: $showingConnectionSheet, content: connectionSheet) + .onAppear { + remoteService.updateConfiguration(app.settings.remoteMode) + if app.settings.remoteMode.isConfigured { + remoteService.startMonitoring() + } + } + .onDisappear { + remoteService.stopMonitoring() + } + .onChange(of: app.settings.remoteMode) { _, newValue in + remoteService.updateConfiguration(newValue) + if newValue.isConfigured { + remoteService.startMonitoring() + } else { + remoteService.stopMonitoring() + } + } + .onChange(of: remoteService.rulesPayload?.fetchedAt) { _, _ in + syncRuleSelection() + } + .onChange(of: selectedRuleID) { _, _ in + restoreSelectedRule() + } + .onChange(of: remoteService.settingsPayload?.general.webUIBaseURL) { _, _ in + if let payload = remoteService.settingsPayload { + settingsDraft = RemoteSettingsDraft(payload: payload) + } + } + } + + private var sidebarView: some View { + List(selection: $selection) { + ForEach(RemoteSection.allCases) { section in + Label(section.rawValue, systemImage: section.symbolName) + .tag(section) + } + } + .navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 280) + .scrollContentBackground(.hidden) + .background(.clear) + } + + private var detailView: some View { + Group { + if !remoteService.configuration.isConfigured { + RemoteDisconnectedStateView { + showingConnectionSheet = true + } + } else { + selectedSectionView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(SwiftBotGlassBackground()) + } + + @ViewBuilder + private var selectedSectionView: some View { + switch selection { + case .status: + statusView + case .rules: + rulesView + case .events: + eventsView + case .settings: + settingsView + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .primaryAction) { + Button("Connection") { + showingConnectionSheet = true + } + + Button { + Task { await remoteService.refreshAll() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(!remoteService.configuration.isConfigured || remoteService.isRefreshing) + } + } + + @ViewBuilder + private func connectionSheet() -> some View { + RemoteConnectionEditorView(initialConfiguration: app.settings.remoteMode) { configuration in + app.updateRemoteModeConnection( + primaryNodeAddress: configuration.primaryNodeAddress, + accessToken: configuration.accessToken + ) + remoteService.updateConfiguration(configuration) + Task { await remoteService.refreshAll() } + showingConnectionSheet = false + } + } + + private var statusView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + remoteErrorBanner + + PreferencesCard( + "Connection", + systemImage: "dot.radiowaves.left.and.right", + subtitle: "Remote Control Mode talks only to the primary node API. No local Discord gateway or SwiftMesh runtime is started on this Mac." + ) { + HStack(spacing: 18) { + RemoteMetricTile( + title: "Primary Node", + value: remoteService.configuration.normalizedPrimaryNodeAddress.isEmpty + ? "Not configured" + : remoteService.configuration.normalizedPrimaryNodeAddress, + accent: .cyan + ) + RemoteMetricTile( + title: "Connection", + value: remoteService.connectionState.rawValue.capitalized, + accent: remoteService.connectionState == .connected ? .green : .orange + ) + RemoteMetricTile( + title: "Latency", + value: remoteService.lastLatencyMs.map { + "\($0.formatted(.number.precision(.fractionLength(0)))) ms" + } ?? "--", + accent: .blue + ) + } + } + + PreferencesCard("Primary Status", systemImage: "server.rack") { + let payload = remoteService.status + LazyVGrid(columns: [.init(.adaptive(minimum: 170), spacing: 14)], spacing: 14) { + RemoteMetricTile(title: "Bot", value: payload?.botStatus.capitalized ?? "--", accent: .green) + RemoteMetricTile(title: "Identity", value: payload?.botUsername ?? "SwiftBot", accent: .blue) + RemoteMetricTile(title: "Node Role", value: payload?.nodeRole.capitalized ?? "--", accent: .orange) + RemoteMetricTile(title: "Leader", value: payload?.leaderName ?? "--", accent: .mint) + RemoteMetricTile(title: "Servers", value: payload.map { "\($0.connectedServerCount)" } ?? "--", accent: .indigo) + RemoteMetricTile(title: "Gateway Events", value: payload.map { "\($0.gatewayEventCount)" } ?? "--", accent: .purple) + RemoteMetricTile(title: "Uptime", value: payload?.uptimeText ?? "--", accent: .teal) + RemoteMetricTile(title: "Mode", value: payload?.clusterMode ?? "--", accent: .pink) + } + } + } + .padding(24) + } + } + + private var rulesView: some View { + HSplitView { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Rules") + .font(.title2.weight(.semibold)) + Spacer() + Button { + createDraftRule() + } label: { + Label("New Rule", systemImage: "plus") + } + } + + if let payload = remoteService.rulesPayload { + List(payload.rules, selection: $selectedRuleID) { rule in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(rule.name) + .font(.headline) + Spacer() + Circle() + .fill(rule.isEnabled ? Color.green : Color.secondary) + .frame(width: 8, height: 8) + } + Text(rule.trigger?.rawValue ?? "No trigger") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .tag(rule.id) + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + } else { + ContentUnavailableView("No Rules Loaded", systemImage: "slider.horizontal.3") + } + } + .frame(minWidth: 260, idealWidth: 300) + .padding(20) + .glassCard() + + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Rule Editor") + .font(.title2.weight(.semibold)) + Spacer() + Button("Restore") { + restoreSelectedRule() + } + .disabled(selectedRule() == nil) + + Button { + Task { await saveEditedRule() } + } label: { + if isSavingRule { + ProgressView() + .controlSize(.small) + } else { + Label("Save Rule", systemImage: "square.and.arrow.down") + } + } + .buttonStyle(GlassActionButtonStyle()) + .disabled(ruleEditorText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSavingRule) + } + + Text("Rules are edited as JSON and saved back through `/api/remote/rules/update`.") + .font(.caption) + .foregroundStyle(.secondary) + + TextEditor(text: $ruleEditorText) + .font(.system(.body, design: .monospaced)) + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.10), lineWidth: 1) + ) + } + .padding(20) + .glassCard() + } + .padding(24) + } + + private var eventsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + remoteErrorBanner + + PreferencesCard("Activity Events", systemImage: "waveform.path.ecg") { + if let payload = remoteService.eventsPayload, !payload.activity.isEmpty { + VStack(alignment: .leading, spacing: 10) { + ForEach(payload.activity) { event in + HStack(alignment: .top, spacing: 10) { + Text(event.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 90, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(event.kind.capitalized) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(event.message) + .font(.body) + } + } + } + } + } else { + ContentUnavailableView("No Recent Events", systemImage: "text.line.first.and.arrowtriangle.forward") + } + } + + PreferencesCard("Recent Logs", systemImage: "list.bullet.rectangle.portrait") { + if let logs = remoteService.eventsPayload?.logs, !logs.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(logs.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + } + } + } else { + ContentUnavailableView("No Recent Logs", systemImage: "doc.text.magnifyingglass") + } + } + } + .padding(24) + } + } + + private var settingsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + remoteErrorBanner + + PreferencesCard("Automation", systemImage: "terminal") { + Toggle("Enable Commands", isOn: $settingsDraft.commandsEnabled) + Toggle("Enable Prefix Commands", isOn: $settingsDraft.prefixCommandsEnabled) + Toggle("Enable Slash Commands", isOn: $settingsDraft.slashCommandsEnabled) + Toggle("Enable Bug Tracking", isOn: $settingsDraft.bugTrackingEnabled) + Toggle("Auto Start Bot", isOn: $settingsDraft.autoStart) + TextField("Command Prefix", text: $settingsDraft.prefix) + } + + PreferencesCard("AI + Integrations", systemImage: "sparkles.rectangle.stack.fill") { + Toggle("Enable Local AI DM Replies", isOn: $settingsDraft.localAIDMReplyEnabled) + Picker("Preferred Provider", selection: $settingsDraft.preferredProvider) { + ForEach(AIProviderPreference.allCases, id: \.rawValue) { provider in + Text(provider.rawValue).tag(provider.rawValue) + } + } + Toggle("Enable OpenAI", isOn: $settingsDraft.openAIEnabled) + TextField("OpenAI Model", text: $settingsDraft.openAIModel) + Toggle("Enable Image Generation", isOn: $settingsDraft.openAIImageGenerationEnabled) + Stepper( + value: $settingsDraft.openAIImageMonthlyLimitPerUser, + in: 0...500 + ) { + Text("Monthly Image Limit Per User: \(settingsDraft.openAIImageMonthlyLimitPerUser)") + } + Toggle("Enable WikiBridge", isOn: $settingsDraft.wikiBridgeEnabled) + Toggle("Enable Patchy Monitoring", isOn: $settingsDraft.patchyMonitoringEnabled) + } + + PreferencesCard("SwiftMesh", systemImage: "point.3.connected.trianglepath.dotted") { + Picker("Mode", selection: $settingsDraft.clusterMode) { + ForEach(ClusterMode.selectableCases, id: \.rawValue) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + TextField("Node Name", text: $settingsDraft.clusterNodeName) + TextField("Leader Address", text: $settingsDraft.clusterLeaderAddress) + Stepper(value: $settingsDraft.clusterListenPort, in: 1...65535) { + Text("Listen Port: \(settingsDraft.clusterListenPort)") + } + Toggle("Offload AI Replies", isOn: $settingsDraft.clusterOffloadAIReplies) + Toggle("Offload Wiki Lookups", isOn: $settingsDraft.clusterOffloadWikiLookups) + } + + HStack { + Spacer() + Button { + if let payload = remoteService.settingsPayload { + settingsDraft = RemoteSettingsDraft(payload: payload) + } + } label: { + Label("Restore", systemImage: "arrow.uturn.backward") + } + + Button { + Task { await saveSettingsDraft() } + } label: { + if isSavingSettings { + ProgressView() + .controlSize(.small) + } else { + Label("Save Remote Settings", systemImage: "square.and.arrow.down.fill") + } + } + .buttonStyle(GlassActionButtonStyle()) + .disabled(isSavingSettings) + } + } + .padding(24) + } + } + + @ViewBuilder + private var remoteErrorBanner: some View { + if let error = remoteService.lastError, !error.isEmpty { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + + private func syncRuleSelection() { + let rules = remoteService.rulesPayload?.rules ?? [] + guard !rules.isEmpty else { + selectedRuleID = nil + ruleEditorText = "" + return + } + + if let selectedRuleID, + rules.contains(where: { $0.id == selectedRuleID }) { + return + } + + selectedRuleID = rules.first?.id + if let rule = rules.first { + ruleEditorText = encodeRule(rule) + } + } + + private func selectedRule() -> Rule? { + remoteService.rulesPayload?.rules.first(where: { $0.id == selectedRuleID }) + } + + private func restoreSelectedRule() { + guard let rule = selectedRule() else { return } + ruleEditorText = encodeRule(rule) + } + + private func createDraftRule() { + var rule = Rule.empty() + rule.name = "Remote Rule" + selectedRuleID = rule.id + ruleEditorText = encodeRule(rule) + } + + private func saveEditedRule() async { + guard let rule = decodeRule(from: ruleEditorText) else { + remoteService.lastError = "The rule JSON is invalid." + return + } + + isSavingRule = true + defer { isSavingRule = false } + let didSave = await remoteService.upsertRule(rule) + if didSave { + selectedRuleID = rule.id + } + } + + private func saveSettingsDraft() async { + isSavingSettings = true + defer { isSavingSettings = false } + _ = await remoteService.updateSettings(settingsDraft.patch) + } + + private func encodeRule(_ rule: Rule) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = (try? encoder.encode(rule)) ?? Data() + return String(data: data, encoding: .utf8) ?? "" + } + + private func decodeRule(from json: String) -> Rule? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(Rule.self, from: data) + } +} + +private struct RemoteMetricTile: View { + let title: String + let value: String + let accent: Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.title3.weight(.semibold)) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(accent.opacity(0.20), lineWidth: 1) + ) + } +} + +private struct RemoteDisconnectedStateView: View { + let onConnect: () -> Void + + var body: some View { + VStack(spacing: 18) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 44)) + .foregroundStyle(.secondary) + Text("Configure Remote Control") + .font(.title2.weight(.semibold)) + Text("Add the primary node address and bearer token to manage SwiftBot remotely.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + Button(action: onConnect) { + Label("Open Connection Setup", systemImage: "link.badge.plus") + } + .buttonStyle(GlassActionButtonStyle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct RemoteConnectionEditorView: View { + @State private var address: String + @State private var accessToken: String + @State private var showToken = false + @StateObject private var tester: RemoteControlService + + let onSave: (RemoteModeSettings) -> Void + + init(initialConfiguration: RemoteModeSettings, onSave: @escaping (RemoteModeSettings) -> Void) { + _address = State(initialValue: initialConfiguration.primaryNodeAddress) + _accessToken = State(initialValue: initialConfiguration.accessToken) + _tester = StateObject(wrappedValue: RemoteControlService(configuration: initialConfiguration)) + self.onSave = onSave + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Remote Setup") + .font(.title2.weight(.semibold)) + + TextField("https://mybot.example.com", text: $address) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 10) { + Group { + if showToken { + TextField("Access Token", text: $accessToken) + } else { + SecureField("Access Token", text: $accessToken) + } + } + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + + Button { + showToken.toggle() + } label: { + Image(systemName: showToken ? "eye.slash" : "eye") + } + .buttonStyle(.plain) + } + + if let error = tester.lastError, !error.isEmpty { + Text(error) + .font(.callout) + .foregroundStyle(.red) + } + + if let latency = tester.lastLatencyMs, tester.connectionState == .connected { + Text("Connected in \(latency.formatted(.number.precision(.fractionLength(0)))) ms") + .font(.callout) + .foregroundStyle(.secondary) + } + + HStack { + Spacer() + Button { + let configuration = currentConfiguration() + tester.updateConfiguration(configuration) + Task { _ = await tester.testConnection() } + } label: { + if tester.isTestingConnection { + ProgressView() + } else { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + } + } + .disabled(!currentConfiguration().isConfigured || tester.isTestingConnection) + + Button { + onSave(currentConfiguration()) + } label: { + Label("Save", systemImage: "checkmark.circle.fill") + } + .buttonStyle(GlassActionButtonStyle()) + .disabled(!currentConfiguration().isConfigured) + } + } + .padding(24) + .frame(width: 520) + .background(SwiftBotGlassBackground()) + } + + private func currentConfiguration() -> RemoteModeSettings { + var configuration = RemoteModeSettings(primaryNodeAddress: address, accessToken: accessToken) + configuration.normalize() + return configuration + } +} diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index 224c7d6..caf9706 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -11,6 +11,10 @@ struct RootView: View { OnboardingGateView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) + } else if app.isRemoteLaunchMode { + RemoteModeRootView() + .frame(minWidth: 1200, minHeight: 760) + .toggleStyle(.switch) } else { HSplitView { DashboardSidebar(selection: $selection) diff --git a/SwiftBotApp/Services/RemoteAPI.swift b/SwiftBotApp/Services/RemoteAPI.swift new file mode 100644 index 0000000..62d8784 --- /dev/null +++ b/SwiftBotApp/Services/RemoteAPI.swift @@ -0,0 +1,110 @@ +import Foundation + +struct RemoteAPI { + enum Error: LocalizedError { + case missingConfiguration + case invalidBaseURL + case invalidResponse + case requestFailed(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .missingConfiguration: + return "Enter the primary node address and access token first." + case .invalidBaseURL: + return "The primary node address is invalid." + case .invalidResponse: + return "The primary node returned an invalid response." + case .requestFailed(let statusCode, let message): + if message.isEmpty { + return "Request failed with HTTP \(statusCode)." + } + return "Request failed with HTTP \(statusCode): \(message)" + } + } + } + + private let baseURL: URL + private let accessToken: String + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(configuration: RemoteModeSettings, session: URLSession = .shared) throws { + let normalizedAddress = configuration.normalizedPrimaryNodeAddress + let normalizedToken = configuration.normalizedAccessToken + guard !normalizedAddress.isEmpty, !normalizedToken.isEmpty else { + throw Error.missingConfiguration + } + guard let baseURL = URL(string: normalizedAddress) else { + throw Error.invalidBaseURL + } + + self.baseURL = baseURL + self.accessToken = normalizedToken + self.session = session + } + + func get(_ path: String, as type: Response.Type = Response.self) async throws -> Response { + let request = try makeRequest(path: path, method: "GET") + return try await send(request, decode: type) + } + + func post(_ path: String, body: RequestBody) async throws { + let request = try makeRequest(path: path, method: "POST", body: body) + let _: RemoteOKResponse = try await send(request, decode: RemoteOKResponse.self) + } + + private func makeRequest(path: String, method: String) throws -> URLRequest { + try makeRequest(path: path, method: method, body: Optional.none) + } + + private func makeRequest(path: String, method: String, body: RequestBody?) throws -> URLRequest { + guard let url = URL(string: path, relativeTo: baseURL) else { + throw Error.invalidBaseURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 15 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let body { + request.httpBody = try encoder.encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + return request + } + + private func send(_ request: URLRequest, decode type: Response.Type) async throws -> Response { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw Error.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + let message = Self.errorMessage(from: data) + throw Error.requestFailed(statusCode: httpResponse.statusCode, message: message) + } + + guard !data.isEmpty else { + throw Error.invalidResponse + } + + do { + return try decoder.decode(type, from: data) + } catch { + throw Error.invalidResponse + } + } + + private static func errorMessage(from data: Data) -> String { + if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = payload["error"] as? String { + return error + } + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/SwiftBotApp/Services/RemoteControlService.swift b/SwiftBotApp/Services/RemoteControlService.swift new file mode 100644 index 0000000..881dc51 --- /dev/null +++ b/SwiftBotApp/Services/RemoteControlService.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftUI + +@MainActor +final class RemoteControlService: ObservableObject { + @Published var configuration = RemoteModeSettings() + @Published private(set) var connectionState: RemoteConnectionState = .disconnected + @Published private(set) var status: RemoteStatusPayload? + @Published private(set) var rulesPayload: RemoteRulesPayload? + @Published private(set) var eventsPayload: RemoteEventsPayload? + @Published private(set) var settingsPayload: AdminWebConfigPayload? + @Published private(set) var lastLatencyMs: Double? + @Published private(set) var isRefreshing = false + @Published private(set) var isTestingConnection = false + @Published var lastError: String? + + private var pollingTask: Task? + + init(configuration: RemoteModeSettings = RemoteModeSettings()) { + var normalized = configuration + normalized.normalize() + self.configuration = normalized + } + + deinit { + pollingTask?.cancel() + } + + func updateConfiguration(_ configuration: RemoteModeSettings) { + var normalized = configuration + normalized.normalize() + self.configuration = normalized + } + + @discardableResult + func testConnection() async -> Bool { + guard configuration.isConfigured else { + connectionState = .disconnected + lastError = RemoteAPI.Error.missingConfiguration.localizedDescription + return false + } + + isTestingConnection = true + connectionState = .connecting + defer { isTestingConnection = false } + + do { + let api = try RemoteAPI(configuration: configuration) + let clock = ContinuousClock() + let startedAt = clock.now + let status: RemoteStatusPayload = try await api.get("/api/remote/status") + let duration = startedAt.duration(to: clock.now) + + self.status = status + self.lastLatencyMs = milliseconds(from: duration) + self.connectionState = .connected + self.lastError = nil + return true + } catch { + connectionState = .failed + lastError = error.localizedDescription + return false + } + } + + func refreshAll() async { + guard configuration.isConfigured else { + connectionState = .disconnected + lastError = RemoteAPI.Error.missingConfiguration.localizedDescription + return + } + + isRefreshing = true + connectionState = .connecting + defer { isRefreshing = false } + + do { + let api = try RemoteAPI(configuration: configuration) + let clock = ContinuousClock() + let startedAt = clock.now + let status: RemoteStatusPayload = try await api.get("/api/remote/status") + let duration = startedAt.duration(to: clock.now) + + async let rules: RemoteRulesPayload = api.get("/api/remote/rules") + async let events: RemoteEventsPayload = api.get("/api/remote/events") + async let settings: AdminWebConfigPayload = api.get("/api/remote/settings") + + self.status = status + self.rulesPayload = try await rules + self.eventsPayload = try await events + self.settingsPayload = try await settings + self.lastLatencyMs = milliseconds(from: duration) + self.connectionState = .connected + self.lastError = nil + } catch { + connectionState = .failed + lastError = error.localizedDescription + } + } + + func startMonitoring(intervalSeconds: Double = 8) { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + guard let self else { return } + await self.refreshAll() + + let sleepDuration = UInt64(max(intervalSeconds, 2) * 1_000_000_000) + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: sleepDuration) + if Task.isCancelled { break } + await self.refreshAll() + } + } + } + + func stopMonitoring() { + pollingTask?.cancel() + pollingTask = nil + } + + @discardableResult + func upsertRule(_ rule: Rule) async -> Bool { + do { + let api = try RemoteAPI(configuration: configuration) + try await api.post("/api/remote/rules/update", body: RemoteRuleUpsertRequest(rule: rule)) + await refreshAll() + return true + } catch { + lastError = error.localizedDescription + return false + } + } + + @discardableResult + func updateSettings(_ patch: AdminWebConfigPatch) async -> Bool { + do { + let api = try RemoteAPI(configuration: configuration) + try await api.post("/api/remote/settings/update", body: patch) + await refreshAll() + return true + } catch { + lastError = error.localizedDescription + return false + } + } + + private func milliseconds(from duration: Duration) -> Double { + let components = duration.components + let seconds = Double(components.seconds) + let attoseconds = Double(components.attoseconds) + return max(0, seconds * 1_000 + attoseconds / 1_000_000_000_000_000) + } +} diff --git a/SwiftBotApp/Services/RemoteModels.swift b/SwiftBotApp/Services/RemoteModels.swift new file mode 100644 index 0000000..1b71f27 --- /dev/null +++ b/SwiftBotApp/Services/RemoteModels.swift @@ -0,0 +1,135 @@ +import Foundation + +enum AppLaunchMode: String, Codable, CaseIterable, Identifiable, Hashable { + case standaloneBot + case swiftMeshClusterNode + case remoteControl + + var id: String { rawValue } + + var title: String { + switch self { + case .standaloneBot: + return "Standalone Bot" + case .swiftMeshClusterNode: + return "SwiftMesh Cluster Node" + case .remoteControl: + return "Remote Control Mode" + } + } + + var subtitle: String { + switch self { + case .standaloneBot: + return "Run Discord, rules, and actions on this Mac." + case .swiftMeshClusterNode: + return "Join SwiftMesh as a primary or failover node." + case .remoteControl: + return "Manage a primary node over HTTPS without running the bot locally." + } + } + + var symbolName: String { + switch self { + case .standaloneBot: + return "server.rack" + case .swiftMeshClusterNode: + return "point.3.connected.trianglepath.dotted" + case .remoteControl: + return "dot.radiowaves.left.and.right" + } + } +} + +struct RemoteModeSettings: Codable, Hashable { + var primaryNodeAddress: String = "" + var accessToken: String = "" + + var normalizedPrimaryNodeAddress: String { + Self.normalizeBaseURL(primaryNodeAddress) + } + + var normalizedAccessToken: String { + accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isConfigured: Bool { + !normalizedPrimaryNodeAddress.isEmpty && !normalizedAccessToken.isEmpty + } + + mutating func normalize() { + primaryNodeAddress = normalizedPrimaryNodeAddress + accessToken = normalizedAccessToken + } + + static func normalizeBaseURL(_ rawValue: String) -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard var components = URLComponents(string: candidate), + let scheme = components.scheme, + let host = components.host, + !scheme.isEmpty, + !host.isEmpty else { + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + components.scheme = scheme.lowercased() + components.host = host.lowercased() + components.path = "" + components.query = nil + components.fragment = nil + + return components.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? trimmed + } +} + +enum RemoteConnectionState: String, Equatable { + case disconnected + case connecting + case connected + case failed +} + +struct RemoteStatusPayload: Codable { + let botStatus: String + let botUsername: String + let connectedServerCount: Int + let gatewayEventCount: Int + let uptimeText: String? + let webUIBaseURL: String + let clusterMode: String + let nodeRole: String + let leaderName: String + let generatedAt: Date +} + +struct RemoteRulesPayload: Codable { + let rules: [Rule] + let servers: [AdminWebSimpleOption] + let textChannelsByServer: [String: [AdminWebSimpleOption]] + let voiceChannelsByServer: [String: [AdminWebSimpleOption]] + let fetchedAt: Date +} + +struct RemoteRuleUpsertRequest: Codable { + let rule: Rule +} + +struct RemoteActivityEventPayload: Codable, Identifiable { + let id: UUID + let timestamp: Date + let kind: String + let message: String +} + +struct RemoteEventsPayload: Codable { + let activity: [RemoteActivityEventPayload] + let logs: [String] + let fetchedAt: Date +} + +struct RemoteOKResponse: Codable { + let ok: Bool +} diff --git a/SwiftBotApp/SettingsView.swift b/SwiftBotApp/SettingsView.swift index 285e1dc..0c13262 100644 --- a/SwiftBotApp/SettingsView.swift +++ b/SwiftBotApp/SettingsView.swift @@ -319,14 +319,7 @@ struct GeneralSettingsView: View { @ViewBuilder private func sectionTitle(_ title: String, symbol: String) -> some View { - Label { - Text(title) - .font(.headline) - } icon: { - Image(systemName: symbol) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - } + SettingsSectionHeader(title: title, systemImage: symbol) } @ViewBuilder @@ -633,8 +626,7 @@ struct GeneralSettingsView: View { ) -> some View { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 6) { - Label(title, systemImage: symbol) - .font(.headline) + SettingsSectionHeader(title: title, systemImage: symbol) Text(subtitle) .font(.subheadline) From 6a7ec112492cb2ad0afc351e50f8aa2b68a60fb4 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 16:03:37 +1300 Subject: [PATCH 2/8] [Beta] Introduction of SwitBot Remote --- SwiftBot-Info.plist | 11 ++ SwiftBot.xcodeproj/project.pbxproj | 20 ++- SwiftBotApp/AdminWebServer.swift | 82 ++++++++- SwiftBotApp/AppModel.swift | 24 +++ SwiftBotApp/ModeSelectionView.swift | 55 ++++++ SwiftBotApp/OnboardingRootView.swift | 134 +++++++++++++++ SwiftBotApp/OnboardingStyles.swift | 156 +++++++++++++++++ SwiftBotApp/OnboardingView.swift | 142 --------------- SwiftBotApp/RemoteSetupView.swift | 238 ++++++++++++++++++++++++++ SwiftBotApp/RootView.swift | 2 +- SwiftBotApp/StandaloneSetupView.swift | 219 ++++++++++++++++++++++++ SwiftBotApp/SwiftBotApp.swift | 26 +++ SwiftBotApp/SwiftMeshSetupView.swift | 174 +++++++++++++++++++ 13 files changed, 1134 insertions(+), 149 deletions(-) create mode 100644 SwiftBotApp/ModeSelectionView.swift create mode 100644 SwiftBotApp/OnboardingRootView.swift create mode 100644 SwiftBotApp/OnboardingStyles.swift create mode 100644 SwiftBotApp/RemoteSetupView.swift create mode 100644 SwiftBotApp/StandaloneSetupView.swift create mode 100644 SwiftBotApp/SwiftMeshSetupView.swift diff --git a/SwiftBot-Info.plist b/SwiftBot-Info.plist index 9466bf8..67a21f6 100644 --- a/SwiftBot-Info.plist +++ b/SwiftBot-Info.plist @@ -7,6 +7,17 @@ NSAllowsArbitraryLoads + CFBundleURLTypes + + + CFBundleURLName + com.swiftbot.auth + CFBundleURLSchemes + + swiftbot + + + SUEnableAutomaticChecks SUFeedURL diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index a98c9d6..7182c3e 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -57,6 +57,12 @@ B2C3D4E5F60708001122334A /* CommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60708001122A3 /* CommandsView.swift */; }; B4F6C2011122334455667788 /* SchemaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F6C2021122334455667788 /* SchemaSettings.swift */; }; C1D2E3F4A5B6C7D8E9F00112 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B6C7D8E9F00111 /* OnboardingView.swift */; }; + 5AEC748EA501439CBE3442FC /* OnboardingRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA77BCA23D894E4E98F5B874 /* OnboardingRootView.swift */; }; + 3B969F2C6A7C429B9E9392E0 /* ModeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142D6839427040358B4FBA90 /* ModeSelectionView.swift */; }; + 45BDC95CDE8D4005A5A70F85 /* StandaloneSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE30A3D04296486A87AFEC73 /* StandaloneSetupView.swift */; }; + EBEDA37ACA12477FB8096E6D /* SwiftMeshSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2338A77A835B45B3963A71D7 /* SwiftMeshSetupView.swift */; }; + 49AEED7B963B4B6F842F7A10 /* RemoteSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199651172D924276B1B7FA3B /* RemoteSetupView.swift */; }; + 222494946C4E49E09016F964 /* OnboardingStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2206311680E46EFA928E726 /* OnboardingStyles.swift */; }; C3D4E5F60112233445566778 /* SwiftMeshView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F70112233445566778 /* SwiftMeshView.swift */; }; D1A2B3C40102030405060708 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A2B3C50102030405060708 /* DiagnosticsView.swift */; }; D2E3F4A5B6C7D8E9F0011224 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E3F4A5B6C7D8E9F0011223 /* OverviewView.swift */; }; @@ -112,6 +118,12 @@ AA2000011122334455667707 /* AdvancedPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPreferencesView.swift; sourceTree = ""; }; B4F6C2021122334455667788 /* SchemaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaSettings.swift; sourceTree = ""; }; C1D2E3F4A5B6C7D8E9F00111 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + EA77BCA23D894E4E98F5B874 /* OnboardingRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/OnboardingRootView.swift; sourceTree = ""; }; + 142D6839427040358B4FBA90 /* ModeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/ModeSelectionView.swift; sourceTree = ""; }; + DE30A3D04296486A87AFEC73 /* StandaloneSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/StandaloneSetupView.swift; sourceTree = ""; }; + 2338A77A835B45B3963A71D7 /* SwiftMeshSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/SwiftMeshSetupView.swift; sourceTree = ""; }; + 199651172D924276B1B7FA3B /* RemoteSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/RemoteSetupView.swift; sourceTree = ""; }; + F2206311680E46EFA928E726 /* OnboardingStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/OnboardingStyles.swift; sourceTree = ""; }; C3D4E5F6070800112233445C /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; C3D4E5F70112233445566778 /* SwiftMeshView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftMeshView.swift; sourceTree = ""; }; D1A2B3C50102030405060708 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = ""; }; @@ -301,7 +313,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 259ADBC5CA9C03B29493726F /* AppModel.swift in Sources */, + 5AEC748EA501439CBE3442FC /* OnboardingRootView.swift in Sources */, + 3B969F2C6A7C429B9E9392E0 /* ModeSelectionView.swift in Sources */, + 45BDC95CDE8D4005A5A70F85 /* StandaloneSetupView.swift in Sources */, + EBEDA37ACA12477FB8096E6D /* SwiftMeshSetupView.swift in Sources */, + 49AEED7B963B4B6F842F7A10 /* RemoteSetupView.swift in Sources */, + 222494946C4E49E09016F964 /* OnboardingStyles.swift in Sources */, +259ADBC5CA9C03B29493726F /* AppModel.swift in Sources */, 11C22D551122334455667788 /* AdminWebServer.swift in Sources */, A1B2C3D40111223344556601 /* Security/CertificateManager.swift in Sources */, A1B2C3D40111223344556602 /* Security/ACMEClient.swift in Sources */, diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 0b4df92..4d860ab 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -5,6 +5,17 @@ import NIOCore import NIOPosix @preconcurrency import NIOSSL +// MARK: - Architecture Note +// +// AdminWebServer intentionally exposes only stateless HTTP endpoints. +// No WebSocket endpoints exist for the admin UI. +// Real-time events are handled internally via the Discord gateway WebSocket +// inside DiscordService.swift (outbound connection to Discord only). +// +// Authentication supports both: +// - Cookie-based: swiftbot_admin_session (for browser WebUI) +// - Bearer token: Authorization: Bearer (for remote clients) + struct AdminWebStatusPayload: Codable { let botStatus: String let botUsername: String @@ -1220,10 +1231,42 @@ actor AdminWebServer { _ = await refreshSwiftMesh?() await logger?("Admin Web UI requested SwiftMesh refresh") return jsonResponse(["ok": true]) + + // MARK: - OAuth Authentication + // + // The Discord OAuth routes are currently used for SwiftBot Remote + // and WebUI authentication. + // + // Flow: + // + // Remote Client → /auth/discord + // → Discord OAuth + // → /auth/discord/callback + // → session created + // → /api/auth/session returns token + // + // Remote clients then authenticate API requests using: + // + // Authorization: Bearer + // + // NOTE FOR FUTURE SWIFTMESH WORK: + // + // SwiftMesh nodes currently authenticate using mesh tokens. + // However, this OAuth identity system may later be reused for: + // + // • administrative access to cluster nodes + // • remote mesh management + // • node approval flows + // + // SwiftMesh authentication should remain separate from user OAuth + // unless explicitly designed to share the same identity layer. + // case ("GET", "/auth/discord/login"): return await handleDiscordLogin() case ("POST", "/auth/logout"): return handleLogout(request: request) + case ("GET", "/api/auth/session"): + return handleSessionInfo(request: request) default: return httpResponse(status: "404 Not Found", body: Data("Not Found".utf8)) } @@ -1389,13 +1432,42 @@ actor AdminWebServer { ) } + private func handleSessionInfo(request: HTTPRequest) -> Data { + guard let session = authenticatedSession(for: request) else { + return unauthorizedResponse() + } + + return jsonResponse([ + "user": session.username, + "discordUserID": session.userID, + "globalName": session.globalName ?? "", + "discriminator": session.discriminator ?? "", + "avatar": session.avatar ?? "", + "permissions": ["admin"], + "sessionToken": session.id, + "expiresAt": ISO8601DateFormatter().string(from: session.expiresAt) + ]) + } + private func authenticatedSession(for request: HTTPRequest) -> Session? { - guard let sessionID = cookie(named: "swiftbot_admin_session", request: request), - let session = sessions[sessionID], - session.expiresAt > Date() else { - return nil + // First try cookie-based session (WebUI) + if let sessionID = cookie(named: "swiftbot_admin_session", request: request), + let session = sessions[sessionID], + session.expiresAt > Date() { + return session + } + + // Then try Bearer token (Remote client) + if let authorization = request.headers["authorization"], + authorization.hasPrefix("Bearer ") { + let sessionID = String(authorization.dropFirst("Bearer ".count)).trimmingCharacters(in: .whitespaces) + if let session = sessions[sessionID], + session.expiresAt > Date() { + return session + } } - return session + + return nil } private func isRemoteRequestAuthorized(_ request: HTTPRequest) -> Bool { diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 7e11770..94be10d 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -1183,6 +1183,22 @@ final class AppModel: ObservableObject { isOnboardingComplete = true } + /// Handles OAuth session token received via deep link for remote authentication. + /// Stores the session token in Keychain and updates remote mode settings. + func handleRemoteAuthSession(_ sessionToken: String) { + // Store session token in Keychain for secure persistence + KeychainHelper.save(sessionToken, account: "remote-session-token") + + // Update the remote mode settings with the session token + var currentMode = settings.remoteMode + currentMode.accessToken = sessionToken + settings.remoteMode = currentMode + saveSettings() + + // Post notification so UI can react to successful auth + NotificationCenter.default.post(name: .remoteAuthSessionReceived, object: sessionToken) + } + func updateRemoteModeConnection(primaryNodeAddress: String, accessToken: String) { settings.remoteMode = RemoteModeSettings( primaryNodeAddress: primaryNodeAddress, @@ -4084,3 +4100,11 @@ actor ClusterStatusPollingService { } } } + + +// MARK: - Notification Names + +extension Notification.Name { + /// Posted when a remote authentication session token is received via deep link. + static let remoteAuthSessionReceived = Notification.Name("remoteAuthSessionReceived") +} diff --git a/SwiftBotApp/ModeSelectionView.swift b/SwiftBotApp/ModeSelectionView.swift new file mode 100644 index 0000000..b471ffa --- /dev/null +++ b/SwiftBotApp/ModeSelectionView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +// MARK: - Mode Selection View + +struct ModeSelectionView: View { + @Binding var mode: SetupMode? + + var body: some View { + VStack(spacing: 16) { + ForEach(SetupMode.allCases) { setupMode in + ModeSelectionButton(mode: setupMode) { + mode = setupMode + } + } + } + } +} + +// MARK: - Mode Selection Button + +private struct ModeSelectionButton: View { + let mode: SetupMode + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 6) { + Image(systemName: mode.icon) + .font(.title2) + + Text(mode.title) + .font(.headline) + + Text(mode.subtitle) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: 360) + .padding(20) + } + .buttonStyle(.bordered) + .controlSize(.large) + } +} + +// MARK: - Preview + +#Preview { + @Previewable @State var selectedMode: SetupMode? + + ModeSelectionView(mode: $selectedMode) + .padding() + .frame(width: 500, height: 400) +} diff --git a/SwiftBotApp/OnboardingRootView.swift b/SwiftBotApp/OnboardingRootView.swift new file mode 100644 index 0000000..2c34df4 --- /dev/null +++ b/SwiftBotApp/OnboardingRootView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +// MARK: - Setup Mode + +enum SetupMode: String, CaseIterable, Identifiable { + case standalone + case mesh + case remote + + var id: String { rawValue } + + var title: String { + switch self { + case .standalone: return "Set Up Standalone Bot" + case .mesh: return "Set Up SwiftMesh" + case .remote: return "Connect to SwiftBot" + } + } + + var subtitle: String { + switch self { + case .standalone: return "Run SwiftBot locally on this Mac." + case .mesh: return "Join a SwiftMesh cluster." + case .remote: return "Control an existing SwiftBot node remotely." + } + } + + var icon: String { + switch self { + case .standalone: return "server.rack" + case .mesh: return "point.3.connected.trianglepath.dotted" + case .remote: return "dot.radiowaves.left.and.right" + } + } +} + +// MARK: - Onboarding Root View + +struct OnboardingRootView: View { + @EnvironmentObject var app: AppModel + + @State private var mode: SetupMode? + @State private var movesForward: Bool = true + + var body: some View { + ZStack { + SwiftBotGlassBackground() + OnboardingAnimatedSymbolBackground() + .allowsHitTesting(false) + + VStack(spacing: 32) { + // Icon + title (always visible) + VStack(spacing: 12) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 80, height: 80) + .accessibilityHidden(true) + + Text("Welcome to SwiftBot") + .font(.largeTitle.weight(.bold)) + + Text(stepSubtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + // Step content + ZStack { + switch mode { + case .standalone: + StandaloneSetupView(onBack: { navigateTo(nil) }) + .id("standalone") + case .mesh: + SwiftMeshSetupView(onBack: { navigateTo(nil) }) + .id("mesh") + case .remote: + RemoteSetupView(onBack: { navigateTo(nil) }) + .id("remote") + case nil: + ModeSelectionView(mode: Binding( + get: { mode }, + set: { newMode in + if let newMode = newMode { + navigateTo(newMode) + } + } + )) + .id("modeSelection") + } + } + .transition( + .asymmetric( + insertion: .move(edge: movesForward ? .trailing : .leading).combined(with: .opacity), + removal: .move(edge: movesForward ? .leading : .trailing).combined(with: .opacity) + ) + ) + .animation(.smooth(duration: 0.26), value: mode) + } + .padding(48) + } + .ignoresSafeArea() + } + + // MARK: - Navigation + + private func navigateTo(_ newMode: SetupMode?) { + movesForward = newMode != nil + mode = newMode + } + + // MARK: - Subtitle + + private var stepSubtitle: String { + switch mode { + case nil: + return "How would you like to set up SwiftBot?" + case .standalone: + return "Enter your Discord bot token to get started." + case .mesh: + return "Enter your SwiftMesh connection details." + case .remote: + return "Connect to a primary SwiftBot node over HTTPS." + } + } +} + +// MARK: - Preview + +#Preview { + OnboardingRootView() + .environmentObject(AppModel()) + .frame(width: 800, height: 600) +} diff --git a/SwiftBotApp/OnboardingStyles.swift b/SwiftBotApp/OnboardingStyles.swift new file mode 100644 index 0000000..0d8c838 --- /dev/null +++ b/SwiftBotApp/OnboardingStyles.swift @@ -0,0 +1,156 @@ +import SwiftUI + +// MARK: - Background Views + +struct OnboardingAnimatedSymbolBackground: View { + @Environment(\.colorScheme) private var colorScheme + @State private var animationStart = Date() + + private let symbols = [ + "book.pages.fill", + "hammer.fill", + "terminal.fill", + "waveform.path.ecg", + "sparkles", + "point.3.connected.trianglepath.dotted", + "server.rack", + "person.3.sequence", + "gearshape.2.fill", + "cpu.fill", + "wrench.and.screwdriver.fill", + "bolt.horizontal.circle.fill" + ] + + var body: some View { + GeometryReader { proxy in + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { timeline in + animatedCanvas(size: proxy.size, date: timeline.date) + } + } + .clipped() + .opacity(colorScheme == .dark ? 0.78 : 0.96) + } + + @ViewBuilder + private func animatedCanvas(size: CGSize, date: Date) -> some View { + let width = max(size.width, 1) + let height = max(size.height, 1) + let diagonal = hypot(width, height) + let elapsed = date.timeIntervalSince(animationStart) + + Canvas { context, _ in + let trackWidth = diagonal * 2.2 + let trackHeight = diagonal * 1.6 + let rowStep: CGFloat = 108 + let rows = Int(trackHeight / rowStep) + 3 + let iconSize: CGFloat = 40 + let spacing: CGFloat = 50 + let step = iconSize + spacing + let cols = Int(trackWidth / step) + 12 + + context.opacity = colorScheme == .dark ? 0.10 : 0.18 + context.translateBy(x: width / 2, y: height / 2) + context.rotate(by: .radians(-.pi / 4)) + + var resolvedSymbols: [String: GraphicsContext.ResolvedSymbol] = [:] + for symbol in symbols { + if let resolved = context.resolveSymbol(id: symbol) { + resolvedSymbols[symbol] = resolved + } + } + + for row in 0.. Int { + let m = max(modulus, 1) + let r = value % m + return r >= 0 ? r : r + m + } + + private func deterministicInt(_ row: Int, seed: Int, modulus: Int) -> Int { + let m = max(modulus, 1) + let mixed = (row &* 73) ^ (seed &* 131) ^ (row &* seed &* 17) + let r = mixed % m + return r >= 0 ? r : r + m + } + + private func greatestCommonDivisor(_ a: Int, _ b: Int) -> Int { + var x = abs(a) + var y = abs(b) + while y != 0 { + let t = x % y + x = y + y = t + } + return max(x, 1) + } +} + +// MARK: - View Modifiers for Onboarding + +extension View { + /// Standard text field style for onboarding inputs + func onboardingTextFieldStyle() -> some View { + self + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + .white.opacity(0.08), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.white.opacity(0.22), lineWidth: 1) + ) + } + + /// Glass-style button for onboarding actions + func onboardingGlassButton() -> some View { + self + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + .white.opacity(0.10), + in: Capsule() + ) + .overlay( + Capsule() + .strokeBorder(.white.opacity(0.24), lineWidth: 1) + ) + } +} diff --git a/SwiftBotApp/OnboardingView.swift b/SwiftBotApp/OnboardingView.swift index 18f02ce..1722efc 100644 --- a/SwiftBotApp/OnboardingView.swift +++ b/SwiftBotApp/OnboardingView.swift @@ -596,145 +596,3 @@ struct OnboardingGateView: View { } } -private struct OnboardingAnimatedSymbolBackground: View { - @Environment(\.colorScheme) private var colorScheme - @State private var animationStart = Date() - - private let symbols = [ - "book.pages.fill", - "hammer.fill", - "terminal.fill", - "waveform.path.ecg", - "sparkles", - "point.3.connected.trianglepath.dotted", - "server.rack", - "person.3.sequence", - "gearshape.2.fill", - "cpu.fill", - "wrench.and.screwdriver.fill", - "bolt.horizontal.circle.fill" - ] - - var body: some View { - GeometryReader { proxy in - TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { timeline in - animatedCanvas(size: proxy.size, date: timeline.date) - } - } - .clipped() - .opacity(colorScheme == .dark ? 0.78 : 0.96) - } - - @ViewBuilder - private func animatedCanvas(size: CGSize, date: Date) -> some View { - let width = max(size.width, 1) - let height = max(size.height, 1) - let diagonal = hypot(width, height) - let elapsed = date.timeIntervalSince(animationStart) - - Canvas { context, _ in - let trackWidth = diagonal * 2.2 - let trackHeight = diagonal * 1.6 - let rowStep: CGFloat = 108 - let rows = Int(trackHeight / rowStep) + 3 - let iconSize: CGFloat = 40 - let spacing: CGFloat = 50 - let step = iconSize + spacing - let cols = Int(trackWidth / step) + 12 - - context.opacity = colorScheme == .dark ? 0.10 : 0.18 - context.translateBy(x: width / 2, y: height / 2) - context.rotate(by: .radians(-.pi / 4)) - - var resolvedSymbols: [String: GraphicsContext.ResolvedSymbol] = [:] - for symbol in symbols { - if let resolved = context.resolveSymbol(id: symbol) { - resolvedSymbols[symbol] = resolved - } - } - - for row in 0.. Int { - let m = max(modulus, 1) - let r = value % m - return r >= 0 ? r : r + m - } - - private func deterministicInt(_ row: Int, seed: Int, modulus: Int) -> Int { - let m = max(modulus, 1) - let mixed = (row &* 73) ^ (seed &* 131) ^ (row &* seed &* 17) - let r = mixed % m - return r >= 0 ? r : r + m - } - - private func greatestCommonDivisor(_ a: Int, _ b: Int) -> Int { - var x = abs(a) - var y = abs(b) - while y != 0 { - let t = x % y - x = y - y = t - } - return max(x, 1) - } -} - -private extension View { - func onboardingTextFieldStyle() -> some View { - self - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(.white.opacity(0.22), lineWidth: 1) - ) - } - - func onboardingGlassButton() -> some View { - self - .buttonStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.white.opacity(0.10), in: Capsule()) - .overlay( - Capsule() - .strokeBorder(.white.opacity(0.24), lineWidth: 1) - ) - } -} diff --git a/SwiftBotApp/RemoteSetupView.swift b/SwiftBotApp/RemoteSetupView.swift new file mode 100644 index 0000000..8e472e0 --- /dev/null +++ b/SwiftBotApp/RemoteSetupView.swift @@ -0,0 +1,238 @@ +import SwiftUI + +// MARK: - Remote Setup View + +struct RemoteSetupView: View { + @EnvironmentObject var app: AppModel + let onBack: () -> Void + + @State private var remoteAddressInput: String = "" + @State private var step: RemoteStep = .setup + @StateObject private var remoteTester = RemoteControlService() + + private enum RemoteStep { + case setup, authenticating, testing, confirmed, failed + } + + var body: some View { + switch step { + case .setup: + remoteSetupFields + case .authenticating: + authenticatingView + case .testing: + testingView + case .confirmed: + confirmedView + case .failed: + failedView + } + } + + // MARK: - Setup Fields + + private var remoteSetupFields: some View { + VStack(alignment: .leading, spacing: 16) { + TextField("https://mybot.example.com", text: $remoteAddressInput) + .onboardingTextFieldStyle() + .frame(maxWidth: 560) + + if let error = remoteTester.lastError, !error.isEmpty { + Text(error) + .font(.callout) + .foregroundStyle(.red) + .multilineTextAlignment(.leading) + .frame(maxWidth: 560, alignment: .leading) + } + + HStack(spacing: 12) { + Button(action: onBack) { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + startOAuthFlow() + } label: { + Label("Sign in with Discord", systemImage: "person.badge.key") + .frame(minWidth: 220) + } + .buttonStyle(GlassActionButtonStyle()) + .controlSize(.large) + .disabled( + remoteAddressInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } + } + .frame(maxWidth: 560) + .onAppear { + remoteAddressInput = app.settings.remoteMode.primaryNodeAddress + app.settings.launchMode = .remoteControl + + // Listen for auth session received via deep link + NotificationCenter.default.addObserver( + forName: .remoteAuthSessionReceived, + object: nil, + queue: .main + ) { _ in + handleAuthCompleted() + } + } + } + + // MARK: - OAuth Flow + + private func startOAuthFlow() { + let normalizedAddress = RemoteModeSettings.normalizeBaseURL(remoteAddressInput) + guard let authURL = URL(string: "\(normalizedAddress)/auth/discord") else { + remoteTester.lastError = "Invalid server URL" + return + } + + // Store the server address for later use + app.settings.remoteMode = RemoteModeSettings( + primaryNodeAddress: normalizedAddress, + accessToken: "" + ) + + // Open OAuth URL in browser + NSWorkspace.shared.open(authURL) + step = .authenticating + } + + private func handleAuthCompleted() { + guard step == .authenticating else { return } + + // Update remote tester with new configuration + remoteTester.updateConfiguration(app.settings.remoteMode) + + // Test the connection + step = .testing + Task { + let ok = await remoteTester.testConnection() + step = ok ? .confirmed : .failed + } + } + + // MARK: - Authenticating View + + private var authenticatingView: some View { + VStack(spacing: 16) { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Waiting for Discord authentication…") + .foregroundStyle(.secondary) + } + + Text("Complete the login in your browser. The app will automatically continue once authenticated.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + + Button { step = .setup } label: { + Label("Cancel", systemImage: "xmark") + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Waiting for Discord authentication, please complete login in browser") + } + + // MARK: - Testing View + + private var testingView: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Testing connection…") + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Testing remote connection, please wait") + } + + // MARK: - Confirmed View + + private var confirmedView: some View { + VStack(spacing: 16) { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) + .accessibilityHidden(true) + Text("Connected to \(remoteTester.status?.botUsername ?? "SwiftBot")") + .font(.body) + } + + if let latency = remoteTester.lastLatencyMs { + Text("Round-trip latency \(latency.formatted(.number.precision(.fractionLength(0)))) ms") + .font(.callout) + .foregroundStyle(.secondary) + } + + // Display connection details + VStack(alignment: .leading, spacing: 4) { + if let status = remoteTester.status { + Text("Node: \(status.botUsername)") + .font(.callout) + Text("Cluster Mode: \(status.clusterMode.capitalized)") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: 560, alignment: .leading) + + Button { + app.completeRemoteModeOnboarding( + primaryNodeAddress: remoteAddressInput, + accessToken: app.settings.remoteMode.accessToken + ) + } label: { + Label("Open Remote Dashboard", systemImage: "arrow.right.circle.fill") + .frame(minWidth: 220) + } + .onboardingGlassButton() + } + } + + // MARK: - Failed View + + private var failedView: some View { + VStack(spacing: 16) { + if let error = remoteTester.lastError, !error.isEmpty { + Text(error) + .font(.callout) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } + + HStack(spacing: 12) { + Button { step = .setup } label: { + Label("Try Again", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button(action: onBack) { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) + } + } + } +} + +// MARK: - Preview + +#Preview { + RemoteSetupView(onBack: {}) + .environmentObject(AppModel()) + .padding() + .frame(width: 600, height: 400) +} diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index caf9706..db91b9a 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -8,7 +8,7 @@ struct RootView: View { var body: some View { if !app.isOnboardingComplete { - OnboardingGateView() + OnboardingRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) } else if app.isRemoteLaunchMode { diff --git a/SwiftBotApp/StandaloneSetupView.swift b/SwiftBotApp/StandaloneSetupView.swift new file mode 100644 index 0000000..14d4ce8 --- /dev/null +++ b/SwiftBotApp/StandaloneSetupView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +// MARK: - Standalone Setup View + +struct StandaloneSetupView: View { + @EnvironmentObject var app: AppModel + let onBack: () -> Void + + @State private var tokenInput: String = "" + @State private var showToken: Bool = false + @State private var step: StandaloneStep = .entry + @State private var inviteURL: String? = nil + @State private var inviteConfirmed: Bool = false + @State private var isLoadingInviteURL: Bool = false + @State private var inviteLoadFailed: Bool = false + + private enum StandaloneStep { + case entry, validating, confirmed, failed + } + + var body: some View { + VStack(spacing: 16) { + // Token field + HStack { + Group { + if showToken { + TextField("Bot token", text: $tokenInput) + } else { + SecureField("Bot token", text: $tokenInput) + } + } + .onboardingTextFieldStyle() + .font(.system(.body, design: .monospaced)) + .disabled(step == .validating || step == .confirmed) + + Button { + showToken.toggle() + } label: { + Image(systemName: showToken ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(showToken ? "Hide token" : "Show token") + } + .frame(maxWidth: 560) + + // Error + if step == .failed, let result = app.lastTokenValidationResult { + Text(result.errorMessage) + .font(.callout) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } + + // Actions + switch step { + case .entry, .failed: + actionButtons + case .validating: + validatingView + case .confirmed: + confirmedView + } + } + .onAppear { + tokenInput = app.settings.token + } + } + + // MARK: - Subviews + + private var actionButtons: some View { + HStack(spacing: 12) { + Button(action: onBack) { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + app.settings.launchMode = .standaloneBot + app.settings.token = tokenInput + step = .validating + Task { + let ok = await app.validateAndOnboard() + step = ok ? .confirmed : .failed + } + } label: { + Label("Validate Token", systemImage: "checkmark.shield.fill") + .frame(minWidth: 200) + } + .buttonStyle(GlassActionButtonStyle()) + .controlSize(.large) + .disabled(tokenInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + private var validatingView: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Validating…") + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Validating token, please wait") + } + + private var confirmedView: some View { + VStack(spacing: 16) { + if let result = app.lastTokenValidationResult { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityHidden(true) + Text("Connected as **\(result.username ?? "Bot")**") + } + .font(.body) + } + + // Invite link states + if isLoadingInviteURL { + loadingInviteView + } else if inviteLoadFailed { + failedInviteView + } + + if let url = inviteURL { + inviteLinkView(url: url) + + Toggle(isOn: $inviteConfirmed) { + Text("I have invited SwiftBot already") + .font(.callout) + } + .toggleStyle(.switch) + .frame(maxWidth: 560, alignment: .center) + } + + Button { app.completeOnboarding() } label: { + Label("Go to Dashboard", systemImage: "arrow.right.circle.fill") + .frame(minWidth: 200) + } + .onboardingGlassButton() + .disabled(inviteURL != nil && !inviteConfirmed) + } + .task { + if inviteURL == nil { + isLoadingInviteURL = true + inviteURL = await app.generateInviteURL() + isLoadingInviteURL = false + inviteLoadFailed = (inviteURL == nil) + } + } + } + + private var loadingInviteView: some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Generating invite link…") + .font(.callout) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Generating invite link, please wait") + } + + private var failedInviteView: some View { + Text("Could not generate an invite link. Your bot's client ID may not be available yet — you can invite the bot manually from the Discord Developer Portal.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } + + private func inviteLinkView(url: String) -> some View { + VStack(spacing: 8) { + Text("Invite your bot to a server:") + .font(.callout) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } label: { + Label("Copy Invite Link", systemImage: "doc.on.doc") + } + .onboardingGlassButton() + .accessibilityHint("Copies the bot invite link to your clipboard") + + Button { + if let u = URL(string: url) { + NSWorkspace.shared.open(u) + } + } label: { + Label("Open Invite", systemImage: "arrow.up.right.square") + } + .onboardingGlassButton() + .accessibilityHint("Opens the Discord bot authorization page in your browser") + } + } + .padding(12) + .background( + .ultraThinMaterial, + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } +} + +// MARK: - Preview + +#Preview { + StandaloneSetupView(onBack: {}) + .environmentObject(AppModel()) + .padding() + .frame(width: 600, height: 400) +} diff --git a/SwiftBotApp/SwiftBotApp.swift b/SwiftBotApp/SwiftBotApp.swift index ce261a8..cc8bd86 100644 --- a/SwiftBotApp/SwiftBotApp.swift +++ b/SwiftBotApp/SwiftBotApp.swift @@ -91,6 +91,9 @@ struct SwiftBotApp: App { applyWindowChromeIfAvailable() updater.checkForUpdatesInBackground() } + .onOpenURL { url in + handleDeepLink(url) + } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -111,4 +114,27 @@ struct SwiftBotApp: App { } .windowResizability(.contentSize) } + + private func handleDeepLink(_ url: URL) { + guard url.scheme == "swiftbot" else { return } + + switch url.host { + case "auth": + handleAuthDeepLink(url) + default: + break + } + } + + private func handleAuthDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { return } + + // Extract session token from deep link: swiftbot://auth?session= + if let sessionToken = queryItems.first(where: { $0.name == "session" })?.value, + !sessionToken.isEmpty { + // Store session token for remote authentication + appModel.handleRemoteAuthSession(sessionToken) + } + } } diff --git a/SwiftBotApp/SwiftMeshSetupView.swift b/SwiftBotApp/SwiftMeshSetupView.swift new file mode 100644 index 0000000..feb1ec2 --- /dev/null +++ b/SwiftBotApp/SwiftMeshSetupView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +// MARK: - SwiftMesh Setup View + +struct SwiftMeshSetupView: View { + @EnvironmentObject var app: AppModel + let onBack: () -> Void + + @State private var step: MeshStep = .setup + + private enum MeshStep { + case setup, testing, confirmed, failed + } + + var body: some View { + switch step { + case .setup: + meshSetupFields + case .testing: + testingView + case .confirmed: + confirmedView + case .failed: + failedView + } + } + + // MARK: - Setup Fields + + private var meshSetupFields: some View { + VStack(alignment: .leading, spacing: 12) { + TextField("Node Name", text: $app.settings.clusterNodeName) + .onboardingTextFieldStyle() + .frame(maxWidth: 560) + + TextField("Cluster Address (host:port)", text: $app.settings.clusterLeaderAddress) + .onboardingTextFieldStyle() + .frame(maxWidth: 560) + + HStack { + Text("Listen Port") + .font(.callout) + Spacer() + TextField("Port", text: Binding( + get: { String(app.settings.clusterListenPort) }, + set: { if let v = Int($0) { app.settings.clusterListenPort = v } } + )) + .onboardingTextFieldStyle() + .frame(width: 110) + } + .frame(maxWidth: 560) + + SecureField("Mesh Token", text: $app.settings.clusterSharedSecret) + .onboardingTextFieldStyle() + .frame(maxWidth: 560) + + HStack(spacing: 12) { + Button(action: onBack) { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + step = .testing + app.testWorkerLeaderConnection() + } label: { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + .frame(minWidth: 200) + } + .buttonStyle(GlassActionButtonStyle()) + .controlSize(.large) + .disabled(app.settings.clusterLeaderAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .frame(maxWidth: 560, alignment: .leading) + } + .frame(maxWidth: 560) + .onAppear { + app.settings.launchMode = .swiftMeshClusterNode + } + .onChange(of: app.workerConnectionTestInProgress) { _, inProgress in + guard step == .testing, !inProgress else { return } + step = app.workerConnectionTestIsSuccess ? .confirmed : .failed + } + } + + // MARK: - Testing View + + private var testingView: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Testing connection…") + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Testing SwiftMesh connection, please wait") + } + + // MARK: - Confirmed View + + private var confirmedView: some View { + VStack(spacing: 16) { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) + .accessibilityHidden(true) + Text(app.workerConnectionTestStatus) + .font(.body) + } + + Button { + app.saveSettings() + app.completeOnboarding() + } label: { + Label("Go to Dashboard", systemImage: "arrow.right.circle.fill") + .frame(minWidth: 200) + } + .buttonStyle(GlassActionButtonStyle()) + .controlSize(.large) + } + } + + // MARK: - Failed View + + private var failedView: some View { + VStack(spacing: 16) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.title2) + .accessibilityHidden(true) + Text(app.workerConnectionTestStatus) + .font(.body) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + Button { step = .setup } label: { + Label("Try Again", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + app.settings.clusterMode = .standalone + app.saveSettings() + app.completeOnboarding() + } label: { + Label("Set Up Later (Limited Mode)", systemImage: "clock.arrow.2.circlepath") + .frame(minWidth: 200) + } + .buttonStyle(GlassActionButtonStyle()) + .controlSize(.large) + } + + Text("Limited Mode launches SwiftBot without Discord or SwiftMesh. Configure both from Settings after launch.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } + } +} + +// MARK: - Preview + +#Preview { + SwiftMeshSetupView(onBack: {}) + .environmentObject(AppModel()) + .padding() + .frame(width: 600, height: 400) +} From 434463ba9a9ea8a73d6f0d2dfa7b442f53717cd5 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 16:22:25 +1300 Subject: [PATCH 3/8] Update AdminWebServer.swift --- SwiftBotApp/AdminWebServer.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 4d860ab..2bb48f3 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -1267,6 +1267,8 @@ actor AdminWebServer { return handleLogout(request: request) case ("GET", "/api/auth/session"): return handleSessionInfo(request: request) + case ("GET", "/api/server/info"): + return await handleServerInfo(request: request) default: return httpResponse(status: "404 Not Found", body: Data("Not Found".utf8)) } @@ -1449,6 +1451,30 @@ actor AdminWebServer { ]) } + private func handleServerInfo(request: HTTPRequest) async -> Data { + guard authenticatedSession(for: request) != nil else { + return unauthorizedResponse() + } + + // Get status info for Discord connection state + let status = await statusProvider?() + let discordConnected = status?.botStatus == "online" || status?.botStatus == "connected" + + // Get config info for cluster details + let config = await configProvider?() + let clusterMode = config?.swiftMesh.mode ?? "standalone" + let nodeName = config?.swiftMesh.nodeName ?? "SwiftBot" + let meshEnabled = config?.general.webUIEnabled ?? false + + return jsonResponse([ + "nodeName": nodeName, + "version": "1.0", + "clusterMode": clusterMode, + "meshEnabled": meshEnabled, + "discordConnected": discordConnected + ]) + } + private func authenticatedSession(for request: HTTPRequest) -> Session? { // First try cookie-based session (WebUI) if let sessionID = cookie(named: "swiftbot_admin_session", request: request), From 11fbd466d60764bde681c3b6da4f79132e57728e Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 16:53:57 +1300 Subject: [PATCH 4/8] SwiftBot Remote View --- SwiftBotApp/AdminWebServer.swift | 2 +- SwiftBotApp/AppModel.swift | 35 +++++++++++++++++++++++++++++++ SwiftBotApp/PreferencesView.swift | 34 +++++++++++++++++++++++++++++- SwiftBotApp/RemoteSetupView.swift | 2 +- SwiftBotApp/RootView.swift | 19 ++++++++++++++++- 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 2bb48f3..e520d06 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -1239,7 +1239,7 @@ actor AdminWebServer { // // Flow: // - // Remote Client → /auth/discord + // Remote Client → /auth/discord/login // → Discord OAuth // → /auth/discord/callback // → session created diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 94be10d..dbe8f70 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -4,6 +4,29 @@ import SwiftUI import UpdateEngine import Darwin +// MARK: - View Mode + +enum ViewMode: String, Codable, CaseIterable, Identifiable { + case local + case remote + + var id: String { rawValue } + + var displayName: String { + switch self { + case .local: return "Local Dashboard" + case .remote: return "Remote Dashboard" + } + } + + var icon: String { + switch self { + case .local: return "desktopcomputer" + case .remote: return "dot.radiowaves.left.and.right" + } + } +} + struct BugAutoFixPendingApproval { let bugMessageID: String let channelID: String @@ -165,6 +188,18 @@ final class AppModel: ObservableObject { /// `true` once a valid token has been confirmed — gates the main dashboard. @Published var isOnboardingComplete: Bool = false + + // MARK: - View Mode + + /// The current view mode (local or remote dashboard). Persisted across launches. + @AppStorage("swiftbot.viewMode") + private var viewModeRaw: String = ViewMode.local.rawValue + + var viewMode: ViewMode { + get { ViewMode(rawValue: viewModeRaw) ?? .local } + set { viewModeRaw = newValue.rawValue } + } + /// OAuth2 client ID resolved from a validated token; used to build the invite URL. @Published var resolvedClientID: String? = nil /// Result from the most recent rich token validation; exposed for onboarding UI error display. diff --git a/SwiftBotApp/PreferencesView.swift b/SwiftBotApp/PreferencesView.swift index 5b70d4e..e59e071 100644 --- a/SwiftBotApp/PreferencesView.swift +++ b/SwiftBotApp/PreferencesView.swift @@ -2,6 +2,10 @@ import SwiftUI struct PreferencesView: View { @EnvironmentObject var app: AppModel + + // Persist selected tab to fix toolbar rendering issues + @AppStorage("swiftbot.preferences.selectedTab") + private var selectedTab = 0 @State private var settingsSnapshot = PreferencesSnapshot() @@ -66,31 +70,36 @@ struct PreferencesView: View { } } } else { - TabView { + TabView(selection: $selectedTab) { GeneralPreferencesView() .tabItem { Label("General", systemImage: "gear") } + .tag(0) MeshPreferencesView() .tabItem { Label("SwiftMesh", systemImage: "point.3.connected.trianglepath.dotted") } + .tag(1) WebUIPreferencesView() .tabItem { Label("Web UI", systemImage: "globe") } + .tag(2) UpdatesPreferencesView() .tabItem { Label("Updates", systemImage: "arrow.clockwise") } + .tag(3) AdvancedPreferencesView() .tabItem { Label("Developer", systemImage: "wrench") } + .tag(4) } .overlay(alignment: .bottomTrailing) { if hasUnsavedChanges && !app.isFailoverManagedNode { @@ -108,6 +117,29 @@ struct PreferencesView: View { .onAppear { settingsSnapshot = currentSettingsSnapshot } + .background( + // Hidden view that observes onboarding state and closes window when complete + PreferencesWindowCloser() + ) + } +} + +// Separate view to handle window closing without affecting PreferencesView identity +private struct PreferencesWindowCloser: View { + @EnvironmentObject var app: AppModel + + var body: some View { + EmptyView() + .onChange(of: app.isOnboardingComplete) { oldValue, newValue in + // Only close when transitioning TO complete (finishing setup) + // NOT when transitioning FROM complete (starting setup) + if newValue && !oldValue { + // Close the Preferences window + DispatchQueue.main.async { + NSApp.keyWindow?.close() + } + } + } } } diff --git a/SwiftBotApp/RemoteSetupView.swift b/SwiftBotApp/RemoteSetupView.swift index 8e472e0..f790d01 100644 --- a/SwiftBotApp/RemoteSetupView.swift +++ b/SwiftBotApp/RemoteSetupView.swift @@ -85,7 +85,7 @@ struct RemoteSetupView: View { private func startOAuthFlow() { let normalizedAddress = RemoteModeSettings.normalizeBaseURL(remoteAddressInput) - guard let authURL = URL(string: "\(normalizedAddress)/auth/discord") else { + guard let authURL = URL(string: "\(normalizedAddress)/auth/discord/login") else { remoteTester.lastError = "Invalid server URL" return } diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index db91b9a..9a670c5 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -11,7 +11,7 @@ struct RootView: View { OnboardingRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) - } else if app.isRemoteLaunchMode { + } else if app.viewMode == .remote || app.isRemoteLaunchMode { RemoteModeRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) @@ -202,6 +202,23 @@ struct DashboardSidebar: View { .padding(6) } + // View Mode Switcher + VStack(spacing: 8) { + Text("View Mode") + .font(.caption2) + .foregroundStyle(.secondary) + + Picker("View Mode", selection: $app.viewMode) { + ForEach(ViewMode.allCases) { mode in + Label(mode.displayName, systemImage: mode.icon) + .tag(mode) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 12) + Group { if !isPrimaryServiceRunning { Button { From 33f8fd712db52f9c85ab5f20537edced9a930657 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 18:12:35 +1300 Subject: [PATCH 5/8] [Beta] Remote App and OAuth Prep --- SwiftBotApp/AdminWebServer.swift | 48 +++++++++++++++++++++++--- SwiftBotApp/RemoteSetupView.swift | 48 ++++++++++++++------------ SwiftBotApp/WebUIPreferencesView.swift | 35 +++++++++++++++++++ 3 files changed, 104 insertions(+), 27 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index e520d06..710964c 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -333,6 +333,7 @@ actor AdminWebServer { private struct PendingState { let value: String let expiresAt: Date + let appRedirectURL: String? } private struct DiscordUser { @@ -1262,7 +1263,7 @@ actor AdminWebServer { // unless explicitly designed to share the same identity layer. // case ("GET", "/auth/discord/login"): - return await handleDiscordLogin() + return await handleDiscordLogin(request: request) case ("POST", "/auth/logout"): return handleLogout(request: request) case ("GET", "/api/auth/session"): @@ -1353,7 +1354,7 @@ actor AdminWebServer { return httpResponse(status: "404 Not Found", body: Data("Not Found".utf8)) } - private func handleDiscordLogin() async -> Data { + private func handleDiscordLogin(request: HTTPRequest) async -> Data { let clientID = config.discordOAuth.clientID.trimmingCharacters(in: .whitespacesAndNewlines) let clientSecret = config.discordOAuth.clientSecret.trimmingCharacters(in: .whitespacesAndNewlines) guard !clientID.isEmpty, !clientSecret.isEmpty else { @@ -1361,7 +1362,12 @@ actor AdminWebServer { } let state = randomToken() - pendingStates[state] = PendingState(value: state, expiresAt: Date().addingTimeInterval(stateTTL)) + let appRedirectURL = validatedAppRedirectURL(from: request.query["return_to"]) + pendingStates[state] = PendingState( + value: state, + expiresAt: Date().addingTimeInterval(stateTTL), + appRedirectURL: appRedirectURL?.absoluteString + ) let uri = redirectURI() await logger?("[OAuth] Redirect URI: \(uri)") @@ -1387,7 +1393,7 @@ actor AdminWebServer { guard let code = request.query["code"], let state = request.query["state"] else { return httpResponse(status: "400 Bad Request", body: Data("Missing code or state.".utf8)) } - guard pendingStates.removeValue(forKey: state) != nil else { + guard let pendingState = pendingStates.removeValue(forKey: state) else { return httpResponse(status: "400 Bad Request", body: Data("State expired or invalid.".utf8)) } @@ -1412,8 +1418,12 @@ actor AdminWebServer { sessions[session.id] = session persistSessions() await logger?("Admin Web UI login for \(user.username) (\(user.id))") + let redirectTarget = remoteAuthRedirectURL( + from: pendingState.appRedirectURL, + sessionID: session.id + ) ?? "/" return redirectResponse( - to: "/", + to: redirectTarget, headers: ["Set-Cookie": sessionCookie(for: session.id)] ) } catch { @@ -1497,6 +1507,10 @@ actor AdminWebServer { } private func isRemoteRequestAuthorized(_ request: HTTPRequest) -> Bool { + if authenticatedSession(for: request) != nil { + return true + } + let expectedToken = config.remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !expectedToken.isEmpty, let authorization = request.headers["authorization"]?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1508,6 +1522,30 @@ actor AdminWebServer { return !providedToken.isEmpty && providedToken == expectedToken } + private func validatedAppRedirectURL(from rawValue: String?) -> URL? { + guard let rawValue, + let url = URL(string: rawValue), + url.scheme?.lowercased() == "swiftbot", + url.host?.lowercased() == "auth" else { + return nil + } + return url + } + + private func remoteAuthRedirectURL(from rawValue: String?, sessionID: String) -> String? { + guard let rawValue, + let url = URL(string: rawValue), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "session" } + queryItems.append(URLQueryItem(name: "session", value: sessionID)) + components.queryItems = queryItems + return components.url?.absoluteString + } + private func validateCSRF(session: Session, request: HTTPRequest) -> Bool { request.headers["x-admin-csrf"] == session.csrfToken } diff --git a/SwiftBotApp/RemoteSetupView.swift b/SwiftBotApp/RemoteSetupView.swift index f790d01..fdf5993 100644 --- a/SwiftBotApp/RemoteSetupView.swift +++ b/SwiftBotApp/RemoteSetupView.swift @@ -15,17 +15,22 @@ struct RemoteSetupView: View { } var body: some View { - switch step { - case .setup: - remoteSetupFields - case .authenticating: - authenticatingView - case .testing: - testingView - case .confirmed: - confirmedView - case .failed: - failedView + Group { + switch step { + case .setup: + remoteSetupFields + case .authenticating: + authenticatingView + case .testing: + testingView + case .confirmed: + confirmedView + case .failed: + failedView + } + } + .onReceive(NotificationCenter.default.publisher(for: .remoteAuthSessionReceived)) { _ in + handleAuthCompleted() } } @@ -69,15 +74,6 @@ struct RemoteSetupView: View { .onAppear { remoteAddressInput = app.settings.remoteMode.primaryNodeAddress app.settings.launchMode = .remoteControl - - // Listen for auth session received via deep link - NotificationCenter.default.addObserver( - forName: .remoteAuthSessionReceived, - object: nil, - queue: .main - ) { _ in - handleAuthCompleted() - } } } @@ -85,16 +81,24 @@ struct RemoteSetupView: View { private func startOAuthFlow() { let normalizedAddress = RemoteModeSettings.normalizeBaseURL(remoteAddressInput) - guard let authURL = URL(string: "\(normalizedAddress)/auth/discord/login") else { + guard var components = URLComponents(string: "\(normalizedAddress)/auth/discord/login") else { + remoteTester.lastError = "Invalid server URL" + return + } + components.queryItems = [ + URLQueryItem(name: "return_to", value: "swiftbot://auth") + ] + guard let authURL = components.url else { remoteTester.lastError = "Invalid server URL" return } // Store the server address for later use - app.settings.remoteMode = RemoteModeSettings( + app.updateRemoteModeConnection( primaryNodeAddress: normalizedAddress, accessToken: "" ) + remoteTester.lastError = nil // Open OAuth URL in browser NSWorkspace.shared.open(authURL) diff --git a/SwiftBotApp/WebUIPreferencesView.swift b/SwiftBotApp/WebUIPreferencesView.swift index 8f42613..bd3eccd 100644 --- a/SwiftBotApp/WebUIPreferencesView.swift +++ b/SwiftBotApp/WebUIPreferencesView.swift @@ -786,6 +786,7 @@ private struct AdminWebStatusRow: View { struct AdminWebAuthenticationSection: View { @EnvironmentObject var app: AppModel + @State private var didCopyRemoteAccessToken = false private var hostname: String { app.settings.adminWebUI.normalizedHostname @@ -863,6 +864,40 @@ struct AdminWebAuthenticationSection: View { } } } + + Divider() + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 10) { + Text("Remote App Access Token") + .font(.headline.weight(.semibold)) + + Text("Use this bearer token to connect a SwiftBot desktop client without the browser-based sign-in flow.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Text(app.settings.remoteAccessToken) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(app.settings.remoteAccessToken, forType: .string) + didCopyRemoteAccessToken = true + } label: { + Label(didCopyRemoteAccessToken ? "Copied" : "Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + } + } + } + .onChange(of: app.settings.remoteAccessToken) { _, _ in + didCopyRemoteAccessToken = false } } } From a05c5d3c755633148468da26a307660ff4d65eb6 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 18:29:38 +1300 Subject: [PATCH 6/8] [Beta] Fixed issue where you get stuck in remote Note this UI is incorrect and will be changed. UI rework will be completed in future for this view as it should match local view --- SwiftBotApp/AppModel.swift | 4 ++++ SwiftBotApp/RootView.swift | 23 +++++------------------ SwiftBotApp/SwiftBotApp.swift | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index dbe8f70..e3512ae 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -305,6 +305,10 @@ final class AppModel: ObservableObject { settings.launchMode == .remoteControl } + var canSwitchDashboardViewMode: Bool { + !isRemoteLaunchMode && !isFailoverManagedNode + } + var usesLocalRuntime: Bool { settings.launchMode != .remoteControl } diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index 9a670c5..ab34597 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -11,7 +11,7 @@ struct RootView: View { OnboardingRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) - } else if app.viewMode == .remote || app.isRemoteLaunchMode { + } else if shouldShowRemoteDashboard { RemoteModeRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) @@ -55,6 +55,10 @@ struct RootView: View { } } // end else isOnboardingComplete } + + private var shouldShowRemoteDashboard: Bool { + app.isRemoteLaunchMode || (app.canSwitchDashboardViewMode && app.viewMode == .remote) + } } private struct BetaBadgeView: View { @@ -202,23 +206,6 @@ struct DashboardSidebar: View { .padding(6) } - // View Mode Switcher - VStack(spacing: 8) { - Text("View Mode") - .font(.caption2) - .foregroundStyle(.secondary) - - Picker("View Mode", selection: $app.viewMode) { - ForEach(ViewMode.allCases) { mode in - Label(mode.displayName, systemImage: mode.icon) - .tag(mode) - } - } - .pickerStyle(.segmented) - .frame(maxWidth: .infinity) - } - .padding(.horizontal, 12) - Group { if !isPrimaryServiceRunning { Button { diff --git a/SwiftBotApp/SwiftBotApp.swift b/SwiftBotApp/SwiftBotApp.swift index cc8bd86..617edd1 100644 --- a/SwiftBotApp/SwiftBotApp.swift +++ b/SwiftBotApp/SwiftBotApp.swift @@ -105,6 +105,21 @@ struct SwiftBotApp: App { } .disabled(!updater.canCheckForUpdates) } + if appModel.canSwitchDashboardViewMode { + CommandMenu("View") { + Button("Local Dashboard") { + appModel.viewMode = .local + } + .keyboardShortcut("1", modifiers: [.command, .option]) + .disabled(appModel.viewMode == .local) + + Button("Remote Dashboard") { + appModel.viewMode = .remote + } + .keyboardShortcut("2", modifiers: [.command, .option]) + .disabled(appModel.viewMode == .remote) + } + } } Settings { From 35d19b6902db789c722f46dd49fc650161b8135e Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 19:13:23 +1300 Subject: [PATCH 7/8] [Beta] Ongoing fixes for Remote View This feature is in beta and May cause issues --- SwiftBot.xcodeproj/project.pbxproj | 14 + SwiftBotApp/AppModel.swift | 63 ++++- SwiftBotApp/OverviewView.swift | 138 +++++----- SwiftBotApp/RootView.swift | 106 +++++--- SwiftBotApp/Services/BotDataProvider.swift | 161 ++++++++++++ SwiftBotApp/Services/LocalBotProvider.swift | 138 ++++++++++ SwiftBotApp/Services/RemoteBotProvider.swift | 260 +++++++++++++++++++ 7 files changed, 780 insertions(+), 100 deletions(-) create mode 100644 SwiftBotApp/Services/BotDataProvider.swift create mode 100644 SwiftBotApp/Services/LocalBotProvider.swift create mode 100644 SwiftBotApp/Services/RemoteBotProvider.swift diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index 7182c3e..5ad00c7 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -45,6 +45,10 @@ A01010101010101010101002 /* Services/RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101002 /* Services/RemoteModels.swift */; }; A01010101010101010101003 /* Services/RemoteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101003 /* Services/RemoteAPI.swift */; }; A01010101010101010101004 /* Services/RemoteControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101004 /* Services/RemoteControlService.swift */; }; + A01010101010101010101005 /* Services/BotDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101005 /* Services/BotDataProvider.swift */; }; + A01010101010101010101006 /* Services/LocalBotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101006 /* Services/LocalBotProvider.swift */; }; + A01010101010101010101007 /* Services/RemoteBotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01010101010101010101007 /* Services/RemoteBotProvider.swift */; }; + A7B810001122334455667788 /* PatchyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B811001122334455667788 /* PatchyView.swift */; }; A7B812001122334455667788 /* PatchyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B813001122334455667788 /* PatchyViewModel.swift */; }; AA1000011122334455667701 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667701 /* PreferencesView.swift */; }; @@ -106,6 +110,10 @@ B01010101010101010101002 /* Services/RemoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteModels.swift; sourceTree = ""; }; B01010101010101010101003 /* Services/RemoteAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteAPI.swift; sourceTree = ""; }; B01010101010101010101004 /* Services/RemoteControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteControlService.swift; sourceTree = ""; }; + B01010101010101010101005 /* Services/BotDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/BotDataProvider.swift; sourceTree = ""; }; + B01010101010101010101006 /* Services/LocalBotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/LocalBotProvider.swift; sourceTree = ""; }; + B01010101010101010101007 /* Services/RemoteBotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RemoteBotProvider.swift; sourceTree = ""; }; + A1B2C3D4E5F60708001122A3 /* CommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsView.swift; sourceTree = ""; }; A7B811001122334455667788 /* PatchyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchyView.swift; sourceTree = ""; }; A7B813001122334455667788 /* PatchyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchyViewModel.swift; sourceTree = ""; }; @@ -181,6 +189,9 @@ B01010101010101010101002 /* Services/RemoteModels.swift */, B01010101010101010101003 /* Services/RemoteAPI.swift */, B01010101010101010101004 /* Services/RemoteControlService.swift */, + B01010101010101010101005 /* Services/BotDataProvider.swift */, + B01010101010101010101006 /* Services/LocalBotProvider.swift */, + B01010101010101010101007 /* Services/RemoteBotProvider.swift */, A1B2C3D40111223344556701 /* Security/CertificateManager.swift */, A1B2C3D40111223344556702 /* Security/ACMEClient.swift */, A1B2C3D40111223344556703 /* Security/CloudflareDNSProvider.swift */, @@ -351,6 +362,9 @@ A01010101010101010101002 /* Services/RemoteModels.swift in Sources */, A01010101010101010101003 /* Services/RemoteAPI.swift in Sources */, A01010101010101010101004 /* Services/RemoteControlService.swift in Sources */, + A01010101010101010101005 /* Services/BotDataProvider.swift in Sources */, + A01010101010101010101006 /* Services/LocalBotProvider.swift in Sources */, + A01010101010101010101007 /* Services/RemoteBotProvider.swift in Sources */, F4A5B6C7D8E9F001122334A6 /* VoiceActionsView.swift in Sources */, B2C3D4E5F60708001122334A /* CommandsView.swift in Sources */, D4E5F607080011223344556B /* LogsView.swift in Sources */, diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index e3512ae..093ecd4 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -197,7 +197,63 @@ final class AppModel: ObservableObject { var viewMode: ViewMode { get { ViewMode(rawValue: viewModeRaw) ?? .local } - set { viewModeRaw = newValue.rawValue } + set { + viewModeRaw = newValue.rawValue + // Update provider when view mode changes + updateProvider() + } + } + + // MARK: - Bot Data Provider + + /// The current data provider (local or remote). Views should use this instead of accessing AppModel directly. + @Published var provider: AnyBotDataProvider? + + private var localProvider: LocalBotProvider? + private var remoteProvider: RemoteBotProvider? + private var localProviderBox: AnyBotDataProvider? + private var remoteProviderBox: AnyBotDataProvider? + + private func updateProvider() { + switch viewMode { + case .local: + if localProvider == nil { + let localProvider = LocalBotProvider(app: self) + self.localProvider = localProvider + self.localProviderBox = AnyBotDataProvider(localProvider) + } + provider = localProviderBox + case .remote: + // Remote provider is created when connection is configured + if remoteProvider == nil && settings.remoteMode.isConfigured { + try? createRemoteProvider() + } + provider = remoteProviderBox + } + } + + func createRemoteProvider(baseURL: String? = nil, token: String? = nil) throws { + let url = baseURL ?? settings.remoteMode.normalizedPrimaryNodeAddress + let accessToken = token ?? settings.remoteMode.normalizedAccessToken + + guard !url.isEmpty, !accessToken.isEmpty else { + throw RemoteBotProviderError.missingConfiguration + } + + let remoteProvider = try RemoteBotProvider(baseURL: url, token: accessToken) + self.remoteProvider = remoteProvider + self.remoteProviderBox = AnyBotDataProvider(remoteProvider) + if viewMode == .remote { + provider = remoteProviderBox + } + } + + func clearRemoteProvider() { + remoteProvider = nil + remoteProviderBox = nil + if viewMode == .remote { + provider = nil + } } /// OAuth2 client ID resolved from a validated token; used to build the invite URL. @@ -372,6 +428,11 @@ final class AppModel: ObservableObject { settings = loadedSettings isOnboardingComplete = onboardingCompleted(for: loadedSettings) + + // Initialize the appropriate data provider + await MainActor.run { + self.updateProvider() + } if let cachedDiscord = await discordCacheStore.load() { await discordCache.replace(with: cachedDiscord) await syncPublishedDiscordCacheFromService() diff --git a/SwiftBotApp/OverviewView.swift b/SwiftBotApp/OverviewView.swift index 1157d78..8541d94 100644 --- a/SwiftBotApp/OverviewView.swift +++ b/SwiftBotApp/OverviewView.swift @@ -2,8 +2,13 @@ import AppKit import SwiftUI import UniformTypeIdentifiers +/// Overview view that works with any BotDataProvider (local or remote). +/// Uses the provider protocol to access bot data, enabling a unified UI shell. struct OverviewView: View { + /// The bot data provider (injected via environment from unified shell) + @EnvironmentObject var provider: AnyBotDataProvider @EnvironmentObject var app: AppModel + var onOpenSwiftMesh: (() -> Void)? @AppStorage("overview.metric.order.v1") private var metricOrderStorage = "" @AppStorage("overview.metric.hidden.v1") private var metricHiddenStorage = "" @@ -28,28 +33,42 @@ struct OverviewView: View { let members: [VoiceMemberPresence] } + // MARK: - Data Access via Provider + + private var settings: BotSettings { provider.settings } + private var status: BotStatus { provider.status } + private var stats: StatCounter { provider.stats } + private var voiceLog: [VoiceEventLogEntry] { provider.voiceLog } + private var commandLog: [CommandLogEntry] { provider.commandLog } + private var activeVoice: [VoiceMemberPresence] { provider.activeVoice } + private var uptime: UptimeInfo? { provider.uptime } + private var connectedServers: [String: String] { provider.connectedServers } + private var clusterSnapshot: ClusterSnapshot { provider.clusterSnapshot } + private var clusterNodes: [ClusterNodeStatus] { provider.clusterNodes } + private var rules: [Rule] { provider.rules } + private var recentVoice: [VoiceEventLogEntry] { - Array(app.voiceLog.prefix(5)) + Array(voiceLog.prefix(5)) } private var recentCommands: [CommandLogEntry] { - Array(app.commandLog.prefix(5)) + Array(commandLog.prefix(5)) } private var workerJobCount: Int { - app.commandLog.filter { $0.executionRoute == "Worker" || $0.executionRoute == "Remote" }.count + commandLog.filter { $0.executionRoute == "Worker" || $0.executionRoute == "Remote" }.count } private var aiProviderSummary: String { - app.settings.preferredAIProvider.rawValue + settings.preferredAIProvider.rawValue } private var enabledWikiSourceCount: Int { - app.settings.wikiBot.sources.filter(\.enabled).count + settings.wikiBot.sources.filter(\.enabled).count } private var enabledWikiCommandCount: Int { - app.settings.wikiBot.sources + settings.wikiBot.sources .filter(\.enabled) .reduce(into: 0) { count, source in count += source.commands.filter(\.enabled).count @@ -57,33 +76,29 @@ struct OverviewView: View { } private var patchyTargetCount: Int { - app.settings.patchy.sourceTargets.count + settings.patchy.sourceTargets.count } private var patchyEnabledTargetCount: Int { - app.settings.patchy.sourceTargets.filter(\.isEnabled).count - } - - private var actionRuleCount: Int { - app.ruleStore.rules.count + settings.patchy.sourceTargets.filter(\.isEnabled).count } private var enabledActionRuleCount: Int { - app.ruleStore.rules.filter(\.isEnabled).count + rules.filter(\.isEnabled).count } private var helpSummary: String { - "\(app.settings.help.mode.rawValue) · \(app.settings.help.tone.rawValue)" + "\(settings.help.mode.rawValue) · \(settings.help.tone.rawValue)" } private var groupedActiveVoice: [VoiceChannelGroup] { - let grouped = Dictionary(grouping: app.activeVoice) { member in + let grouped = Dictionary(grouping: activeVoice) { member in "\(member.guildId):\(member.channelId)" } return grouped.map { key, members in let first = members.first - let serverName = first.map { app.connectedServers[$0.guildId] ?? $0.guildId } ?? "Unknown Server" + let serverName = first.map { connectedServers[$0.guildId] ?? $0.guildId } ?? "Unknown Server" let channelName = first?.channelName ?? "Voice Channel" let orderedMembers = members.sorted { lhs, rhs in lhs.username.localizedCaseInsensitiveCompare(rhs.username) == .orderedAscending @@ -103,39 +118,39 @@ struct OverviewView: View { } private var availableMetricWidgets: [MetricWidget] { - if app.settings.clusterMode == .worker { + if settings.clusterMode == .worker { return [ MetricWidget( id: "status", title: "Status", value: app.primaryServiceStatusText, - subtitle: app.clusterSnapshot.serverStatusText, + subtitle: clusterSnapshot.serverStatusText, symbol: "bolt.horizontal.circle.fill", - detail: "Auto Start \(app.settings.autoStart ? "On" : "Off")", + detail: "Auto Start \(settings.autoStart ? "On" : "Off")", color: .green ), MetricWidget( id: "meshMode", title: "Mesh Mode", - value: app.settings.clusterMode.displayName, - subtitle: app.settings.clusterNodeName, + value: settings.clusterMode.displayName, + subtitle: settings.clusterNodeName, symbol: "point.3.connected.trianglepath.dotted", - detail: "Primary \(app.settings.clusterLeaderAddress.isEmpty ? "Not set" : "Configured")", + detail: "Primary \(settings.clusterLeaderAddress.isEmpty ? "Not set" : "Configured")", color: .purple ), MetricWidget( id: "listenPort", title: "Listen Port", - value: "\(app.clusterSnapshot.listenPort)", + value: "\(clusterSnapshot.listenPort)", subtitle: "worker HTTP service", symbol: "antenna.radiowaves.left.and.right", - detail: "Node \(app.settings.clusterNodeName.isEmpty ? "Unnamed" : app.settings.clusterNodeName)", + detail: "Node \(settings.clusterNodeName.isEmpty ? "Unnamed" : settings.clusterNodeName)", color: .blue ), MetricWidget( id: "inVoice", title: "In Voice", - value: "\(app.activeVoice.count)", + value: "\(activeVoice.count)", subtitle: "users right now", symbol: "person.3.sequence.fill", detail: "Live presence", @@ -144,7 +159,7 @@ struct OverviewView: View { MetricWidget( id: "wikibridge", title: "WikiBridge", - value: app.settings.wikiBot.isEnabled ? "Enabled" : "Disabled", + value: settings.wikiBot.isEnabled ? "Enabled" : "Disabled", subtitle: "\(enabledWikiSourceCount) sources", symbol: "book.pages.fill", detail: "\(enabledWikiCommandCount) commands", @@ -153,7 +168,7 @@ struct OverviewView: View { MetricWidget( id: "patchy", title: "Patchy", - value: app.settings.patchy.monitoringEnabled ? "Monitoring On" : "Monitoring Off", + value: settings.patchy.monitoringEnabled ? "Monitoring On" : "Monitoring Off", subtitle: "\(patchyEnabledTargetCount)/\(patchyTargetCount) targets", symbol: "hammer.fill", detail: "Jobs \(workerJobCount)", @@ -163,7 +178,7 @@ struct OverviewView: View { id: "actions", title: "Actions", value: "\(enabledActionRuleCount) active", - subtitle: "\(actionRuleCount) total rules", + subtitle: "\(rules.count) total rules", symbol: "bolt.circle", detail: helpSummary, color: .red @@ -175,34 +190,34 @@ struct OverviewView: View { MetricWidget( id: "status", title: "Status", - value: app.status.rawValue.capitalized, - subtitle: app.uptime?.text ?? "--", + value: status.rawValue.capitalized, + subtitle: uptime?.text ?? "--", symbol: "bolt.horizontal.circle.fill", - detail: "Auto Start \(app.settings.autoStart ? "On" : "Off")", + detail: "Auto Start \(settings.autoStart ? "On" : "Off")", color: .green ), MetricWidget( id: "servers", title: "Servers", - value: "\(app.connectedServers.count)", + value: "\(connectedServers.count)", subtitle: "servers connected", symbol: "server.rack", - detail: app.settings.clusterMode == .standalone ? "Standalone" : app.settings.clusterMode.displayName, + detail: settings.clusterMode == .standalone ? "Standalone" : settings.clusterMode.displayName, color: .blue ), MetricWidget( id: "inVoice", title: "In Voice", - value: "\(app.activeVoice.count)", + value: "\(activeVoice.count)", subtitle: "users right now", symbol: "person.3.sequence.fill", - detail: "Route \(app.clusterSnapshot.lastJobRoute.rawValue.capitalized)", + detail: "Route \(clusterSnapshot.lastJobRoute.rawValue.capitalized)", color: .orange ), MetricWidget( id: "commandsRun", title: "Commands Run", - value: "\(app.stats.commandsRun)", + value: "\(stats.commandsRun)", subtitle: "this session", symbol: "terminal.fill", detail: "Recent commands activity", @@ -211,7 +226,7 @@ struct OverviewView: View { MetricWidget( id: "wikibridge", title: "WikiBridge", - value: app.settings.wikiBot.isEnabled ? "Enabled" : "Disabled", + value: settings.wikiBot.isEnabled ? "Enabled" : "Disabled", subtitle: "\(enabledWikiSourceCount) sources", symbol: "book.pages.fill", detail: "\(enabledWikiCommandCount) commands", @@ -220,7 +235,7 @@ struct OverviewView: View { MetricWidget( id: "patchy", title: "Patchy", - value: app.settings.patchy.monitoringEnabled ? "Monitoring On" : "Monitoring Off", + value: settings.patchy.monitoringEnabled ? "Monitoring On" : "Monitoring Off", subtitle: "\(patchyEnabledTargetCount)/\(patchyTargetCount) targets", symbol: "hammer.fill", detail: "Help \(helpSummary)", @@ -230,18 +245,18 @@ struct OverviewView: View { id: "actions", title: "Actions", value: "\(enabledActionRuleCount) active", - subtitle: "\(actionRuleCount) total rules", + subtitle: "\(rules.count) total rules", symbol: "bolt.circle", - detail: "Errors \(app.stats.errors)", + detail: "Errors \(stats.errors)", color: .indigo ), MetricWidget( id: "aiBots", title: "AI Bots", value: aiProviderSummary, - subtitle: app.settings.localAIDMReplyEnabled ? "DM replies enabled" : "DM replies disabled", + subtitle: settings.localAIDMReplyEnabled ? "DM replies enabled" : "DM replies disabled", symbol: "sparkles", - detail: "Guild AI \(app.settings.behavior.useAIInGuildChannels ? "On" : "Off")", + detail: "Guild AI \(settings.behavior.useAIInGuildChannels ? "On" : "Off")", color: .purple ) ] @@ -320,7 +335,7 @@ struct OverviewView: View { symbol: widget.symbol, detail: widget.detail, color: widget.color, - appleIntelligenceGlowEnabled: widget.id == "aiBots" && app.settings.preferredAIProvider == .apple + appleIntelligenceGlowEnabled: widget.id == "aiBots" && settings.preferredAIProvider == .apple ) .rotationEffect(.degrees(isEditingDashboard ? wiggleAmplitude(for: widget.id) : 0)) .animation( @@ -362,9 +377,9 @@ struct OverviewView: View { } } - if app.settings.clusterMode != .standalone { + if settings.clusterMode != .standalone { OverviewClusterSummaryCard( - nodes: app.clusterNodes, + nodes: clusterNodes, onOpenSwiftMesh: onOpenSwiftMesh ) } @@ -400,13 +415,13 @@ struct OverviewView: View { } HStack(spacing: 12) { - DashboardPanel(title: app.settings.clusterMode == .worker ? "Worker Activity" : "Currently In Voice") { - if app.settings.clusterMode == .worker { - InfoRow(label: "Server", value: app.clusterSnapshot.serverStatusText) - InfoRow(label: "Last Job", value: app.clusterSnapshot.lastJobSummary) - InfoRow(label: "Last Node", value: app.clusterSnapshot.lastJobNode) - InfoRow(label: "Diagnostics", value: app.clusterSnapshot.diagnostics) - } else if app.activeVoice.isEmpty { + DashboardPanel(title: settings.clusterMode == .worker ? "Worker Activity" : "Currently In Voice") { + if settings.clusterMode == .worker { + InfoRow(label: "Server", value: clusterSnapshot.serverStatusText) + InfoRow(label: "Last Job", value: clusterSnapshot.lastJobSummary) + InfoRow(label: "Last Node", value: clusterSnapshot.lastJobNode) + InfoRow(label: "Diagnostics", value: clusterSnapshot.diagnostics) + } else if activeVoice.isEmpty { PlaceholderPanelLine(text: "No one is in voice right now") } else { ForEach(groupedActiveVoice) { group in @@ -420,7 +435,7 @@ struct OverviewView: View { ForEach(group.members) { member in VoicePresenceMemberRow( member: member, - avatarURL: app.avatarURL(forUserId: member.userId, guildId: member.guildId) ?? app.fallbackAvatarURL(forUserId: member.userId) + avatarURL: provider.avatarURL(forUserId: member.userId, guildId: member.guildId) ) } } @@ -433,11 +448,11 @@ struct OverviewView: View { } DashboardPanel(title: "Bot Info") { - InfoRow(label: "Uptime", value: app.settings.clusterMode == .worker ? "--" : (app.uptime?.text ?? "--")) - InfoRow(label: "Errors", value: "\(app.stats.errors)") - InfoRow(label: "State", value: app.settings.clusterMode == .worker ? app.primaryServiceStatusText : app.status.rawValue.capitalized) - if app.settings.clusterMode != .standalone { - InfoRow(label: "Cluster", value: app.clusterSnapshot.mode.rawValue) + InfoRow(label: "Uptime", value: settings.clusterMode == .worker ? "--" : (uptime?.text ?? "--")) + InfoRow(label: "Errors", value: "\(stats.errors)") + InfoRow(label: "State", value: settings.clusterMode == .worker ? app.primaryServiceStatusText : status.rawValue.capitalized) + if settings.clusterMode != .standalone { + InfoRow(label: "Cluster", value: clusterSnapshot.mode.rawValue) } } } @@ -450,7 +465,7 @@ struct OverviewView: View { .onAppear { syncDashboardPreferences() } - .onChange(of: app.settings.clusterMode) { _, _ in + .onChange(of: settings.clusterMode) { _, _ in syncDashboardPreferences() } .onChange(of: isEditingDashboard) { _, isEditing in @@ -538,6 +553,7 @@ private struct OverviewMetricDropDelegate: DropDelegate { } struct OverviewClusterSummaryCard: View { + @EnvironmentObject var provider: AnyBotDataProvider @EnvironmentObject var app: AppModel let nodes: [ClusterNodeStatus] var onOpenSwiftMesh: (() -> Void)? @@ -586,8 +602,8 @@ struct OverviewClusterSummaryCard: View { .strokeBorder(.white.opacity(0.20), lineWidth: 1) ) .shadow(color: .black.opacity(0.08), radius: 14, x: 0, y: 8) - .task(id: app.settings.clusterMode) { - guard app.settings.clusterMode != .standalone else { return } + .task(id: provider.settings.clusterMode) { + guard provider.settings.clusterMode != .standalone else { return } await app.pollClusterStatus() } } diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index ab34597..18d17d4 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -2,6 +2,9 @@ import AppKit import Charts import SwiftUI +/// Unified root view that works with both local and remote providers. +/// The provider-based shell allows the same UI components to be used +/// regardless of whether the bot is running locally or remotely. struct RootView: View { @EnvironmentObject var app: AppModel @State private var selection: SidebarItem = .overview @@ -11,53 +14,80 @@ struct RootView: View { OnboardingRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) - } else if shouldShowRemoteDashboard { + } else if let provider = app.provider { + UnifiedRootView(selection: $selection) + .environmentObject(provider) + .frame(minWidth: 1200, minHeight: 760) + .toggleStyle(.switch) + } else { + fallbackView + } + } + + @ViewBuilder + private var fallbackView: some View { + if shouldShowRemoteDashboard { RemoteModeRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) } else { - HSplitView { - DashboardSidebar(selection: $selection) - .frame(minWidth: 230, idealWidth: 250, maxWidth: 280) + ProgressView("Loading dashboard...") + .frame(minWidth: 1200, minHeight: 760) + .toggleStyle(.switch) + } + } - Group { - switch selection { - case .overview: - OverviewView(onOpenSwiftMesh: { - withAnimation(.easeInOut(duration: 0.2)) { - selection = .swiftMesh - } - }) - case .patchy: PatchyView() - case .voice: VoiceView() - case .commands: CommandsView() - case .commandLog: CommandLogView() - case .wikiBridge: WikiBridgeView() - case .logs: LogsView() - case .aiBots: AIBotsView() - case .diagnostics: DiagnosticsView() - case .swiftMesh: SwiftMeshView() - } + private var shouldShowRemoteDashboard: Bool { + app.isRemoteLaunchMode || (app.canSwitchDashboardViewMode && app.viewMode == .remote) + } +} + +// MARK: - Unified Shell + +/// Unified shell that uses BotDataProvider for both local and remote modes. +/// This view receives the provider via environment and renders the appropriate UI. +struct UnifiedRootView: View { + @Binding var selection: SidebarItem + @EnvironmentObject var provider: AnyBotDataProvider + @EnvironmentObject var app: AppModel + + var body: some View { + HSplitView { + DashboardSidebar(selection: $selection) + .frame(minWidth: 230, idealWidth: 250, maxWidth: 280) + + Group { + switch selection { + case .overview: + OverviewView(onOpenSwiftMesh: { + withAnimation(.easeInOut(duration: 0.2)) { + selection = .swiftMesh + } + }) + case .patchy: PatchyView() + case .voice: VoiceView() + case .commands: CommandsView() + case .commandLog: CommandLogView() + case .wikiBridge: WikiBridgeView() + case .logs: LogsView() + case .aiBots: AIBotsView() + case .diagnostics: DiagnosticsView() + case .swiftMesh: SwiftMeshView() } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(SwiftBotGlassBackground()) } - .padding(.top, -30) - .ignoresSafeArea(.container, edges: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(SwiftBotGlassBackground()) - .toggleStyle(.switch) - .overlay(alignment: .topTrailing) { - if app.isBetaBuild { - BetaBadgeView() - .padding(.top, 14) - .padding(.trailing, 18) - } + } + .padding(.top, -30) + .ignoresSafeArea(.container, edges: .top) + .background(SwiftBotGlassBackground()) + .overlay(alignment: .topTrailing) { + if app.isBetaBuild { + BetaBadgeView() + .padding(.top, 14) + .padding(.trailing, 18) } - } // end else isOnboardingComplete - } - - private var shouldShowRemoteDashboard: Bool { - app.isRemoteLaunchMode || (app.canSwitchDashboardViewMode && app.viewMode == .remote) + } } } diff --git a/SwiftBotApp/Services/BotDataProvider.swift b/SwiftBotApp/Services/BotDataProvider.swift new file mode 100644 index 0000000..261af0c --- /dev/null +++ b/SwiftBotApp/Services/BotDataProvider.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftUI +import Combine + +/// A protocol that abstracts the data source for the SwiftBot dashboard. +/// This allows the same UI to be used for both local and remote bot instances. +protocol BotDataProvider: ObservableObject { + var changePublisher: AnyPublisher { get } + + // MARK: - State Properties + + var settings: BotSettings { get } + var status: BotStatus { get } + var stats: StatCounter { get } + var events: [ActivityEvent] { get } + var commandLog: [CommandLogEntry] { get } + var voiceLog: [VoiceEventLogEntry] { get } + var activeVoice: [VoiceMemberPresence] { get } + var uptime: UptimeInfo? { get } + var connectedServers: [String: String] { get } + var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] { get } + var availableTextChannelsByServer: [String: [GuildTextChannel]] { get } + var availableRolesByServer: [String: [GuildRole]] { get } + var clusterSnapshot: ClusterSnapshot { get } + var clusterNodes: [ClusterNodeStatus] { get } + + // MARK: - Bot Info + + var botUsername: String { get } + var botAvatarURL: URL? { get } + func avatarURL(forUserId userId: String, guildId: String?) -> URL? + func fallbackAvatarURL(forUserId userId: String) -> URL? + + // MARK: - Rules + + var rules: [Rule] { get } + func upsertRule(_ rule: Rule) async throws + func deleteRule(_ id: UUID) async throws + + // MARK: - Patchy + + var patchyLastCycleAt: Date? { get } + var patchyIsCycleRunning: Bool { get } + var patchyDebugLogs: [String] { get } + func addPatchyTarget(_ target: PatchySourceTarget) async throws + func updatePatchyTarget(_ target: PatchySourceTarget) async throws + func deletePatchyTarget(_ id: UUID) async throws + func togglePatchyTargetEnabled(_ id: UUID) async throws + func sendPatchyTest(targetID: UUID) async throws + func runPatchyManualCheck() async throws + + // MARK: - Lifecycle & Actions + + func refresh() async + func saveSettings(_ settings: BotSettings) async throws + func startBot() async throws + func stopBot() async throws +} + +// MARK: - Type-Erased Wrapper + +/// A type-erased wrapper for BotDataProvider that can be used with @EnvironmentObject. +/// This solves the "type 'any BotDataProvider' cannot conform to 'ObservableObject'" issue. +@MainActor +final class AnyBotDataProvider: ObservableObject, BotDataProvider { + private let _base: any BotDataProvider + private var changeCancellable: AnyCancellable? + + let objectWillChange = ObservableObjectPublisher() + + // MARK: - BotDataProvider Properties (forwarded) + + var settings: BotSettings { _base.settings } + var status: BotStatus { _base.status } + var stats: StatCounter { _base.stats } + var events: [ActivityEvent] { _base.events } + var commandLog: [CommandLogEntry] { _base.commandLog } + var voiceLog: [VoiceEventLogEntry] { _base.voiceLog } + var activeVoice: [VoiceMemberPresence] { _base.activeVoice } + var uptime: UptimeInfo? { _base.uptime } + var connectedServers: [String: String] { _base.connectedServers } + var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] { _base.availableVoiceChannelsByServer } + var availableTextChannelsByServer: [String: [GuildTextChannel]] { _base.availableTextChannelsByServer } + var availableRolesByServer: [String: [GuildRole]] { _base.availableRolesByServer } + var clusterSnapshot: ClusterSnapshot { _base.clusterSnapshot } + var clusterNodes: [ClusterNodeStatus] { _base.clusterNodes } + var botUsername: String { _base.botUsername } + var botAvatarURL: URL? { _base.botAvatarURL } + var rules: [Rule] { _base.rules } + var patchyLastCycleAt: Date? { _base.patchyLastCycleAt } + var patchyIsCycleRunning: Bool { _base.patchyIsCycleRunning } + var patchyDebugLogs: [String] { _base.patchyDebugLogs } + var changePublisher: AnyPublisher { objectWillChange.eraseToAnyPublisher() } + + // MARK: - Initialization + + init(_ base: any BotDataProvider) { + self._base = base + self.changeCancellable = base.changePublisher.sink { [weak self] in + self?.objectWillChange.send() + } + } + + // MARK: - BotDataProvider Methods (forwarded) + + func avatarURL(forUserId userId: String, guildId: String?) -> URL? { + _base.avatarURL(forUserId: userId, guildId: guildId) + } + + func fallbackAvatarURL(forUserId userId: String) -> URL? { + _base.fallbackAvatarURL(forUserId: userId) + } + + func upsertRule(_ rule: Rule) async throws { + try await _base.upsertRule(rule) + } + + func deleteRule(_ id: UUID) async throws { + try await _base.deleteRule(id) + } + + func addPatchyTarget(_ target: PatchySourceTarget) async throws { + try await _base.addPatchyTarget(target) + } + + func updatePatchyTarget(_ target: PatchySourceTarget) async throws { + try await _base.updatePatchyTarget(target) + } + + func deletePatchyTarget(_ id: UUID) async throws { + try await _base.deletePatchyTarget(id) + } + + func togglePatchyTargetEnabled(_ id: UUID) async throws { + try await _base.togglePatchyTargetEnabled(id) + } + + func sendPatchyTest(targetID: UUID) async throws { + try await _base.sendPatchyTest(targetID: targetID) + } + + func runPatchyManualCheck() async throws { + try await _base.runPatchyManualCheck() + } + + func refresh() async { + await _base.refresh() + } + + func saveSettings(_ settings: BotSettings) async throws { + try await _base.saveSettings(settings) + } + + func startBot() async throws { + try await _base.startBot() + } + + func stopBot() async throws { + try await _base.stopBot() + } +} diff --git a/SwiftBotApp/Services/LocalBotProvider.swift b/SwiftBotApp/Services/LocalBotProvider.swift new file mode 100644 index 0000000..060d914 --- /dev/null +++ b/SwiftBotApp/Services/LocalBotProvider.swift @@ -0,0 +1,138 @@ +import Foundation +import Combine + +/// A BotDataProvider implementation that wraps the local AppModel. +/// This allows the same UI to be used for both local and remote bot instances. +@MainActor +final class LocalBotProvider: BotDataProvider { + private let app: AppModel + private var appChangeCancellable: AnyCancellable? + + let objectWillChange = ObservableObjectPublisher() + + var changePublisher: AnyPublisher { + objectWillChange.eraseToAnyPublisher() + } + + // MARK: - State Properties + + var settings: BotSettings { app.settings } + var status: BotStatus { app.status } + var stats: StatCounter { app.stats } + var events: [ActivityEvent] { app.events } + var commandLog: [CommandLogEntry] { app.commandLog } + var voiceLog: [VoiceEventLogEntry] { app.voiceLog } + var activeVoice: [VoiceMemberPresence] { app.activeVoice } + var uptime: UptimeInfo? { app.uptime } + var connectedServers: [String: String] { app.connectedServers } + var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] { app.availableVoiceChannelsByServer } + var availableTextChannelsByServer: [String: [GuildTextChannel]] { app.availableTextChannelsByServer } + var availableRolesByServer: [String: [GuildRole]] { app.availableRolesByServer } + var clusterSnapshot: ClusterSnapshot { app.clusterSnapshot } + var clusterNodes: [ClusterNodeStatus] { app.clusterNodes } + var rules: [Rule] { app.ruleStore.rules } + + // MARK: - Bot Info + + var botUsername: String { + app.botUsername + } + + var botAvatarURL: URL? { + app.botAvatarURL + } + + // MARK: - Patchy + + var patchyLastCycleAt: Date? { + app.patchyLastCycleAt + } + + var patchyIsCycleRunning: Bool { + app.patchyIsCycleRunning + } + + var patchyDebugLogs: [String] { + app.patchyDebugLogs + } + + // MARK: - Initialization + + init(app: AppModel) { + self.app = app + self.appChangeCancellable = app.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + } + + // MARK: - BotDataProvider Methods + + func avatarURL(forUserId userId: String, guildId: String?) -> URL? { + app.avatarURL(forUserId: userId, guildId: guildId) + } + + func fallbackAvatarURL(forUserId userId: String) -> URL? { + app.fallbackAvatarURL(forUserId: userId) + } + + func refresh() async { + // Local provider doesn't need explicit refresh - AppModel updates automatically + } + + func saveSettings(_ settings: BotSettings) async throws { + app.settings = settings + app.saveSettings() + } + + func startBot() async throws { + await app.startBot() + } + + func stopBot() async throws { + await app.stopBot() + } + + // MARK: - Rules + + func upsertRule(_ rule: Rule) async throws { + // Find and update existing rule, or append new one + if let index = app.ruleStore.rules.firstIndex(where: { $0.id == rule.id }) { + app.ruleStore.rules[index] = rule + } else { + app.ruleStore.rules.append(rule) + } + app.ruleStore.scheduleAutoSave() + } + + func deleteRule(_ id: UUID) async throws { + guard let index = app.ruleStore.rules.firstIndex(where: { $0.id == id }) else { return } + app.ruleStore.rules.remove(at: index) + app.ruleStore.scheduleAutoSave() + } + + // MARK: - Patchy + + func addPatchyTarget(_ target: PatchySourceTarget) async throws { + // TODO: Implement when PatchyService is available + } + + func updatePatchyTarget(_ target: PatchySourceTarget) async throws { + // TODO: Implement when PatchyService is available + } + + func deletePatchyTarget(_ id: UUID) async throws { + // TODO: Implement when PatchyService is available + } + + func togglePatchyTargetEnabled(_ id: UUID) async throws { + // TODO: Implement when PatchyService is available + } + + func sendPatchyTest(targetID: UUID) async throws { + // TODO: Implement when PatchyService is available + } + + func runPatchyManualCheck() async throws { + // TODO: Implement when PatchyService is available + } +} diff --git a/SwiftBotApp/Services/RemoteBotProvider.swift b/SwiftBotApp/Services/RemoteBotProvider.swift new file mode 100644 index 0000000..5b01c19 --- /dev/null +++ b/SwiftBotApp/Services/RemoteBotProvider.swift @@ -0,0 +1,260 @@ +import Foundation +import SwiftUI +import Combine + +/// A BotDataProvider implementation that talks to a remote SwiftBot instance via HTTPS. +final class RemoteBotProvider: BotDataProvider { + @Published private(set) var settings = BotSettings() + @Published private(set) var status: BotStatus = .stopped + @Published private(set) var stats = StatCounter() + @Published private(set) var events: [ActivityEvent] = [] + @Published private(set) var commandLog: [CommandLogEntry] = [] + @Published private(set) var voiceLog: [VoiceEventLogEntry] = [] + @Published private(set) var activeVoice: [VoiceMemberPresence] = [] + @Published private(set) var uptime: UptimeInfo? + @Published private(set) var connectedServers: [String: String] = [:] + @Published private(set) var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] = [:] + @Published private(set) var availableTextChannelsByServer: [String: [GuildTextChannel]] = [:] + @Published private(set) var availableRolesByServer: [String: [GuildRole]] = [:] + @Published private(set) var clusterSnapshot = ClusterSnapshot() + @Published private(set) var clusterNodes: [ClusterNodeStatus] = [] + + @Published private(set) var botUsername: String = "Remote Bot" + @Published private(set) var botAvatarURL: URL? + + @Published private(set) var rules: [Rule] = [] + + @Published private(set) var patchyLastCycleAt: Date? + @Published private(set) var patchyIsCycleRunning: Bool = false + @Published private(set) var patchyDebugLogs: [String] = [] + + private let api: RemoteAPI + private var refreshTask: Task? + private let refreshInterval: TimeInterval = 8 + + var changePublisher: AnyPublisher { + objectWillChange.eraseToAnyPublisher() + } + + init(baseURL: String, token: String) throws { + let configuration = RemoteModeSettings(primaryNodeAddress: baseURL, accessToken: token) + self.api = try RemoteAPI(configuration: configuration) + + // Start background refresh + startBackgroundRefresh() + } + + func avatarURL(forUserId userId: String, guildId: String?) -> URL? { + // Remote provider doesn't easily have access to all avatar hashes yet, + // fallback to standard Discord URLs if possible or return nil + return nil + } + + func fallbackAvatarURL(forUserId userId: String) -> URL? { + guard let numericID = UInt64(userId) else { + return URL(string: "https://cdn.discordapp.com/embed/avatars/0.png") + } + let index = Int(numericID % 6) + return URL(string: "https://cdn.discordapp.com/embed/avatars/\(index).png") + } + + func refresh() async { + do { + async let statusPayload: RemoteStatusPayload = api.get("/api/remote/status") + async let rulesPayload: RemoteRulesPayload = api.get("/api/remote/rules") + async let eventsPayload: RemoteEventsPayload = api.get("/api/remote/events") + async let configPayload: AdminWebConfigPayload = api.get("/api/remote/settings") + + let s = try await statusPayload + let r = try await rulesPayload + let e = try await eventsPayload + let c = try await configPayload + + await MainActor.run { + self.updateState(status: s, rules: r, events: e, config: c) + } + } catch { + print("RemoteBotProvider refresh failed: \(error)") + } + } + + private func updateState( + status: RemoteStatusPayload, + rules: RemoteRulesPayload, + events: RemoteEventsPayload, + config: AdminWebConfigPayload + ) { + self.botUsername = status.botUsername + self.status = BotStatus(rawValue: status.botStatus.lowercased()) ?? .stopped + + // Construct partial BotSettings from config payload + var s = BotSettings() + s.prefix = config.commands.prefix + s.commandsEnabled = config.commands.enabled + s.prefixCommandsEnabled = config.commands.prefixEnabled + s.slashCommandsEnabled = config.commands.slashEnabled + s.bugTrackingEnabled = config.commands.bugTrackingEnabled + s.autoStart = config.general.autoStart + + s.localAIDMReplyEnabled = config.aiBots.localAIDMReplyEnabled + s.preferredAIProvider = AIProviderPreference(rawValue: config.aiBots.preferredProvider) ?? .apple + s.openAIEnabled = config.aiBots.openAIEnabled + s.openAIModel = config.aiBots.openAIModel + s.openAIImageGenerationEnabled = config.aiBots.openAIImageGenerationEnabled + s.openAIImageMonthlyLimitPerUser = config.aiBots.openAIImageMonthlyLimitPerUser + + s.clusterMode = ClusterMode(rawValue: config.swiftMesh.mode) ?? .standalone + s.clusterNodeName = config.swiftMesh.nodeName + s.clusterLeaderAddress = config.swiftMesh.leaderAddress + s.clusterListenPort = config.swiftMesh.listenPort + s.clusterOffloadAIReplies = config.swiftMesh.offloadAIReplies + s.clusterOffloadWikiLookups = config.swiftMesh.offloadWikiLookups + + s.wikiBot.isEnabled = config.wikiBridge.enabled + s.patchy.monitoringEnabled = config.patchy.monitoringEnabled + + self.settings = s + + // Update stats + self.stats.commandsRun = status.gatewayEventCount // Approximation + + // Update rules + self.rules = rules.rules + + // Update events/logs + self.events = events.activity.map { ActivityEvent(timestamp: $0.timestamp, kind: parseActivityKind($0.kind), message: $0.message) } + self.commandLog = [] // Need specific endpoint for this + self.voiceLog = [] // Need specific endpoint for this + + // Update uptime (use generatedAt as proxy since remote API doesn't provide startedAt yet) + self.uptime = UptimeInfo(startedAt: Date().addingTimeInterval(-parseUptimeText(status.uptimeText))) + + // Update servers + self.connectedServers = Dictionary(uniqueKeysWithValues: rules.servers.map { ($0.id, $0.name) }) + + // Update channels/roles + self.availableTextChannelsByServer = rules.textChannelsByServer.mapValues { $0.map { GuildTextChannel(id: $0.id, name: $0.name) } } + self.availableVoiceChannelsByServer = rules.voiceChannelsByServer.mapValues { $0.map { GuildVoiceChannel(id: $0.id, name: $0.name) } } + + // Cluster info + self.clusterSnapshot.mode = ClusterMode(rawValue: status.clusterMode) ?? .standalone + } + + func saveSettings(_ settings: BotSettings) async throws { + let patch = AdminWebConfigPatch( + commandsEnabled: settings.commandsEnabled, + prefixCommandsEnabled: settings.prefixCommandsEnabled, + slashCommandsEnabled: settings.slashCommandsEnabled, + bugTrackingEnabled: settings.bugTrackingEnabled, + prefix: settings.prefix, + localAIDMReplyEnabled: settings.localAIDMReplyEnabled, + preferredAIProvider: settings.preferredAIProvider.rawValue, + openAIEnabled: settings.openAIEnabled, + openAIModel: settings.openAIModel, + openAIImageGenerationEnabled: settings.openAIImageGenerationEnabled, + openAIImageMonthlyLimitPerUser: settings.openAIImageMonthlyLimitPerUser, + wikiBridgeEnabled: settings.wikiBot.isEnabled, + patchyMonitoringEnabled: settings.patchy.monitoringEnabled, + clusterMode: settings.clusterMode.rawValue, + clusterNodeName: settings.clusterNodeName, + clusterLeaderAddress: settings.clusterLeaderAddress, + clusterListenPort: settings.clusterListenPort, + clusterOffloadAIReplies: settings.clusterOffloadAIReplies, + clusterOffloadWikiLookups: settings.clusterOffloadWikiLookups, + autoStart: settings.autoStart + ) + try await api.post("/api/remote/settings/update", body: patch) + await refresh() + } + + func upsertRule(_ rule: Rule) async throws { + try await api.post("/api/remote/rules/update", body: RemoteRuleUpsertRequest(rule: rule)) + await refresh() + } + + func deleteRule(_ id: UUID) async throws { + // Remote API needs a delete endpoint or handle it via upsert with a flag + // For now, not implemented in RemoteAPI + } + + func startBot() async throws { + // Remote API needs a start endpoint + } + + func stopBot() async throws { + // Remote API needs a stop endpoint + } + + func addPatchyTarget(_ target: PatchySourceTarget) async throws { + // Remote API needs patchy endpoints + } + + func updatePatchyTarget(_ target: PatchySourceTarget) async throws { + // Remote API needs patchy endpoints + } + + func deletePatchyTarget(_ id: UUID) async throws { + // Remote API needs patchy endpoints + } + + func togglePatchyTargetEnabled(_ id: UUID) async throws { + // Remote API needs patchy endpoints + } + + func sendPatchyTest(targetID: UUID) async throws { + // Remote API needs patchy endpoints + } + + func runPatchyManualCheck() async throws { + // Remote API needs patchy endpoints + } + + private func startBackgroundRefresh() { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + guard let self else { return } + await self.refresh() + + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(refreshInterval * 1_000_000_000)) + if Task.isCancelled { break } + await self.refresh() + } + } + } + + deinit { + refreshTask?.cancel() + } +} + +// MARK: - Helpers + +private func parseActivityKind(_ kindString: String) -> ActivityEvent.Kind { + ActivityEvent.Kind(rawValue: kindString) ?? .info +} + +private func parseUptimeText(_ text: String?) -> TimeInterval { + guard let text = text, !text.isEmpty else { return 0 } + // Parse format like "2h 30m" or "1d 2h 30m" to seconds + var totalSeconds: TimeInterval = 0 + let components = text.components(separatedBy: " ") + for component in components { + if component.hasSuffix("d"), let days = Int(component.dropLast()) { + totalSeconds += TimeInterval(days * 86400) + } else if component.hasSuffix("h"), let hours = Int(component.dropLast()) { + totalSeconds += TimeInterval(hours * 3600) + } else if component.hasSuffix("m"), let minutes = Int(component.dropLast()) { + totalSeconds += TimeInterval(minutes * 60) + } else if component.hasSuffix("s"), let seconds = Int(component.dropLast()) { + totalSeconds += TimeInterval(seconds) + } + } + return totalSeconds +} + +// MARK: - Errors + +enum RemoteBotProviderError: Error { + case missingConfiguration +} From 6c85d1c6d2e29bc959440dad59d18e7378cbe67c Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 19:23:44 +1300 Subject: [PATCH 8/8] [Beta] - Last commit before merge --- SwiftBotApp/AdvancedPreferencesView.swift | 6 +- SwiftBotApp/AppModel.swift | 59 ++----- SwiftBotApp/ModeSelectionView.swift | 10 +- SwiftBotApp/OnboardingRootView.swift | 4 +- SwiftBotApp/RemoteSetupView.swift | 1 - SwiftBotApp/RootView.swift | 18 +-- SwiftBotApp/SwiftBotApp.swift | 179 +++++++++++++++------- 7 files changed, 162 insertions(+), 115 deletions(-) diff --git a/SwiftBotApp/AdvancedPreferencesView.swift b/SwiftBotApp/AdvancedPreferencesView.swift index 3ffe2e0..9190f14 100644 --- a/SwiftBotApp/AdvancedPreferencesView.swift +++ b/SwiftBotApp/AdvancedPreferencesView.swift @@ -21,7 +21,7 @@ struct AdvancedPreferencesView: View { )) .toggleStyle(.switch) - Text("Unlock experimental SwiftBot features including Bug Auto-Fix and Codex automation. These tools are under active development.") + Text("Unlock experimental SwiftBot features including SwiftBot Remote beta, Bug Auto-Fix, and Codex automation. These tools are under active development.") .font(.caption) .foregroundStyle(.secondary) } @@ -32,8 +32,8 @@ struct AdvancedPreferencesView: View { PreferencesCard("Experimental Tools", systemImage: "wrench") { Text( app.settings.devFeaturesEnabled - ? "Developer Mode is active. Bug Auto-Fix and other experimental tools are available below." - : "Enable Developer Mode above to configure Bug Auto-Fix and other experimental tools." + ? "Developer Mode is active. SwiftBot Remote beta, Bug Auto-Fix, and other experimental tools are available below." + : "Enable Developer Mode above to configure SwiftBot Remote beta, Bug Auto-Fix, and other experimental tools." ) .font(.caption) .foregroundStyle(.secondary) diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 093ecd4..3f12296 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -197,9 +197,8 @@ final class AppModel: ObservableObject { var viewMode: ViewMode { get { ViewMode(rawValue: viewModeRaw) ?? .local } - set { + set { viewModeRaw = newValue.rawValue - // Update provider when view mode changes updateProvider() } } @@ -210,50 +209,15 @@ final class AppModel: ObservableObject { @Published var provider: AnyBotDataProvider? private var localProvider: LocalBotProvider? - private var remoteProvider: RemoteBotProvider? private var localProviderBox: AnyBotDataProvider? - private var remoteProviderBox: AnyBotDataProvider? private func updateProvider() { - switch viewMode { - case .local: - if localProvider == nil { - let localProvider = LocalBotProvider(app: self) - self.localProvider = localProvider - self.localProviderBox = AnyBotDataProvider(localProvider) - } - provider = localProviderBox - case .remote: - // Remote provider is created when connection is configured - if remoteProvider == nil && settings.remoteMode.isConfigured { - try? createRemoteProvider() - } - provider = remoteProviderBox - } - } - - func createRemoteProvider(baseURL: String? = nil, token: String? = nil) throws { - let url = baseURL ?? settings.remoteMode.normalizedPrimaryNodeAddress - let accessToken = token ?? settings.remoteMode.normalizedAccessToken - - guard !url.isEmpty, !accessToken.isEmpty else { - throw RemoteBotProviderError.missingConfiguration - } - - let remoteProvider = try RemoteBotProvider(baseURL: url, token: accessToken) - self.remoteProvider = remoteProvider - self.remoteProviderBox = AnyBotDataProvider(remoteProvider) - if viewMode == .remote { - provider = remoteProviderBox - } - } - - func clearRemoteProvider() { - remoteProvider = nil - remoteProviderBox = nil - if viewMode == .remote { - provider = nil + if localProvider == nil { + let localProvider = LocalBotProvider(app: self) + self.localProvider = localProvider + self.localProviderBox = AnyBotDataProvider(localProvider) } + provider = localProviderBox } /// OAuth2 client ID resolved from a validated token; used to build the invite URL. @@ -361,10 +325,18 @@ final class AppModel: ObservableObject { settings.launchMode == .remoteControl } + var remoteControlFeatureEnabled: Bool { + isBetaBuild && settings.devFeaturesEnabled + } + var canSwitchDashboardViewMode: Bool { !isRemoteLaunchMode && !isFailoverManagedNode } + var canOpenRemoteDashboardFromLocalApp: Bool { + remoteControlFeatureEnabled && canSwitchDashboardViewMode + } + var usesLocalRuntime: Bool { settings.launchMode != .remoteControl } @@ -1268,6 +1240,7 @@ final class AppModel: ObservableObject { /// Persists settings through the Keychain path, then flips `isOnboardingComplete`. /// Must only be called after a successful `validateAndOnboard()`. func completeOnboarding() { + viewMode = .local saveSettings() isOnboardingComplete = true } @@ -1279,6 +1252,7 @@ final class AppModel: ObservableObject { accessToken: accessToken ) settings.remoteMode.normalize() + viewMode = .remote saveSettings() isOnboardingComplete = true } @@ -1346,6 +1320,7 @@ final class AppModel: ObservableObject { func runInitialSetup() { resolvedClientID = nil lastTokenValidationResult = nil + viewMode = .local isOnboardingComplete = false } diff --git a/SwiftBotApp/ModeSelectionView.swift b/SwiftBotApp/ModeSelectionView.swift index b471ffa..0453dae 100644 --- a/SwiftBotApp/ModeSelectionView.swift +++ b/SwiftBotApp/ModeSelectionView.swift @@ -3,11 +3,18 @@ import SwiftUI // MARK: - Mode Selection View struct ModeSelectionView: View { + @EnvironmentObject var app: AppModel @Binding var mode: SetupMode? + + private var availableModes: [SetupMode] { + SetupMode.allCases.filter { setupMode in + setupMode != .remote || app.remoteControlFeatureEnabled + } + } var body: some View { VStack(spacing: 16) { - ForEach(SetupMode.allCases) { setupMode in + ForEach(availableModes) { setupMode in ModeSelectionButton(mode: setupMode) { mode = setupMode } @@ -52,4 +59,5 @@ private struct ModeSelectionButton: View { ModeSelectionView(mode: $selectedMode) .padding() .frame(width: 500, height: 400) + .environmentObject(AppModel()) } diff --git a/SwiftBotApp/OnboardingRootView.swift b/SwiftBotApp/OnboardingRootView.swift index 2c34df4..bb9a390 100644 --- a/SwiftBotApp/OnboardingRootView.swift +++ b/SwiftBotApp/OnboardingRootView.swift @@ -13,7 +13,7 @@ enum SetupMode: String, CaseIterable, Identifiable { switch self { case .standalone: return "Set Up Standalone Bot" case .mesh: return "Set Up SwiftMesh" - case .remote: return "Connect to SwiftBot" + case .remote: return "Connect to SwiftBot Remote" } } @@ -21,7 +21,7 @@ enum SetupMode: String, CaseIterable, Identifiable { switch self { case .standalone: return "Run SwiftBot locally on this Mac." case .mesh: return "Join a SwiftMesh cluster." - case .remote: return "Control an existing SwiftBot node remotely." + case .remote: return "Control an existing SwiftBot node remotely. Beta feature." } } diff --git a/SwiftBotApp/RemoteSetupView.swift b/SwiftBotApp/RemoteSetupView.swift index fdf5993..e37a49f 100644 --- a/SwiftBotApp/RemoteSetupView.swift +++ b/SwiftBotApp/RemoteSetupView.swift @@ -73,7 +73,6 @@ struct RemoteSetupView: View { .frame(maxWidth: 560) .onAppear { remoteAddressInput = app.settings.remoteMode.primaryNodeAddress - app.settings.launchMode = .remoteControl } } diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index 18d17d4..98e2447 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -14,6 +14,10 @@ struct RootView: View { OnboardingRootView() .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) + } else if shouldShowRemoteDashboard { + RemoteModeRootView() + .frame(minWidth: 1200, minHeight: 760) + .toggleStyle(.switch) } else if let provider = app.provider { UnifiedRootView(selection: $selection) .environmentObject(provider) @@ -26,19 +30,13 @@ struct RootView: View { @ViewBuilder private var fallbackView: some View { - if shouldShowRemoteDashboard { - RemoteModeRootView() - .frame(minWidth: 1200, minHeight: 760) - .toggleStyle(.switch) - } else { - ProgressView("Loading dashboard...") - .frame(minWidth: 1200, minHeight: 760) - .toggleStyle(.switch) - } + ProgressView("Loading dashboard...") + .frame(minWidth: 1200, minHeight: 760) + .toggleStyle(.switch) } private var shouldShowRemoteDashboard: Bool { - app.isRemoteLaunchMode || (app.canSwitchDashboardViewMode && app.viewMode == .remote) + app.isRemoteLaunchMode || (app.canOpenRemoteDashboardFromLocalApp && app.viewMode == .remote) } } diff --git a/SwiftBotApp/SwiftBotApp.swift b/SwiftBotApp/SwiftBotApp.swift index 617edd1..790c119 100644 --- a/SwiftBotApp/SwiftBotApp.swift +++ b/SwiftBotApp/SwiftBotApp.swift @@ -18,65 +18,100 @@ struct SwiftBotApp: App { NSApp.applicationIconImage = image } - private func applyWindowChromeIfAvailable() { - DispatchQueue.main.async { - guard let window = NSApp.windows.first else { return } - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.isMovableByWindowBackground = false - window.styleMask.insert(.fullSizeContentView) - window.isOpaque = false - window.backgroundColor = .clear - window.hasShadow = true - window.titlebarSeparatorStyle = .none - window.setContentBorderThickness(0, for: .minY) - window.setContentBorderThickness(0, for: .maxY) - window.collectionBehavior.remove([.fullScreenPrimary, .fullScreenAuxiliary]) - - let cornerRadius: CGFloat = 12 - - if let contentView = window.contentView { - contentView.wantsLayer = true - contentView.layer?.cornerRadius = 0 - contentView.layer?.masksToBounds = false - contentView.layer?.borderWidth = 0 - contentView.layer?.backgroundColor = NSColor.clear.cgColor - } + private func applyMainWindowChrome(to window: NSWindow) { + guard window.identifier != .settingsWindow else { return } + guard !(window.titleVisibility == .hidden && window.backgroundColor == .clear && window.isOpaque == false) else { return } + + window.identifier = .mainWindow + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + window.styleMask.insert(.fullSizeContentView) + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = true + window.titlebarSeparatorStyle = .none + window.setContentBorderThickness(0, for: .minY) + window.setContentBorderThickness(0, for: .maxY) + window.collectionBehavior.remove([.fullScreenPrimary, .fullScreenAuxiliary]) + + let cornerRadius: CGFloat = 12 + + if let contentView = window.contentView { + contentView.wantsLayer = true + contentView.layer?.cornerRadius = 0 + contentView.layer?.masksToBounds = false + contentView.layer?.borderWidth = 0 + contentView.layer?.backgroundColor = NSColor.clear.cgColor + } - if let frameView = window.contentView?.superview { - frameView.wantsLayer = true - frameView.layer?.cornerRadius = cornerRadius - frameView.layer?.cornerCurve = .continuous - frameView.layer?.masksToBounds = true - frameView.layer?.borderWidth = 0 - frameView.layer?.borderColor = NSColor.clear.cgColor - frameView.layer?.backgroundColor = NSColor.clear.cgColor - } + if let frameView = window.contentView?.superview { + frameView.wantsLayer = true + frameView.layer?.cornerRadius = cornerRadius + frameView.layer?.cornerCurve = .continuous + frameView.layer?.masksToBounds = true + frameView.layer?.borderWidth = 0 + frameView.layer?.borderColor = NSColor.clear.cgColor + frameView.layer?.backgroundColor = NSColor.clear.cgColor + } - if let chromeView = window.contentView?.superview?.superview { - chromeView.wantsLayer = true - chromeView.layer?.cornerRadius = cornerRadius - chromeView.layer?.cornerCurve = .continuous - chromeView.layer?.borderWidth = 0 - chromeView.layer?.borderColor = NSColor.clear.cgColor - chromeView.layer?.backgroundColor = NSColor.clear.cgColor - } + if let chromeView = window.contentView?.superview?.superview { + chromeView.wantsLayer = true + chromeView.layer?.cornerRadius = cornerRadius + chromeView.layer?.cornerCurve = .continuous + chromeView.layer?.borderWidth = 0 + chromeView.layer?.borderColor = NSColor.clear.cgColor + chromeView.layer?.backgroundColor = NSColor.clear.cgColor + } - let trafficLightButtons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] - for type in trafficLightButtons { - guard let button = window.standardWindowButton(type) else { continue } - var origin = button.frame.origin - origin.x += 14 - origin.y -= 10 - button.setFrameOrigin(origin) - } + let trafficLightButtons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + for type in trafficLightButtons { + guard let button = window.standardWindowButton(type) else { continue } + var origin = button.frame.origin + origin.x += 14 + origin.y -= 10 + button.setFrameOrigin(origin) + } - if let zoomButton = window.standardWindowButton(.zoomButton) { - zoomButton.action = #selector(NSWindow.performZoom(_:)) - zoomButton.target = window - } + if let zoomButton = window.standardWindowButton(.zoomButton) { + zoomButton.action = #selector(NSWindow.performZoom(_:)) + zoomButton.target = window + } + + window.invalidateShadow() + } + + private func restoreSettingsWindowChrome(_ window: NSWindow) { + window.identifier = .settingsWindow + window.ignoresMouseEvents = false + window.level = .normal + window.hasShadow = true + window.isMovableByWindowBackground = false + window.titlebarAppearsTransparent = false + window.styleMask.remove(.fullSizeContentView) + window.isOpaque = true + window.backgroundColor = .windowBackgroundColor + window.titlebarSeparatorStyle = .automatic + window.setContentBorderThickness(0, for: .minY) + window.setContentBorderThickness(0, for: .maxY) + + if let contentView = window.contentView { + contentView.wantsLayer = false + contentView.layer?.backgroundColor = nil + } - window.invalidateShadow() + if let frameView = window.contentView?.superview { + frameView.wantsLayer = false + frameView.layer?.cornerRadius = 0 + frameView.layer?.borderWidth = 0 + frameView.layer?.backgroundColor = nil + } + + if let chromeView = window.contentView?.superview?.superview { + chromeView.wantsLayer = false + chromeView.layer?.cornerRadius = 0 + chromeView.layer?.borderWidth = 0 + chromeView.layer?.backgroundColor = nil } } @@ -88,12 +123,14 @@ struct SwiftBotApp: App { .frame(minWidth: 1200, minHeight: 760) .onAppear { applyAppIconIfAvailable() - applyWindowChromeIfAvailable() updater.checkForUpdatesInBackground() } .onOpenURL { url in handleDeepLink(url) } + .background(WindowAccessor { window in + applyMainWindowChrome(to: window) + }) } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -105,7 +142,7 @@ struct SwiftBotApp: App { } .disabled(!updater.canCheckForUpdates) } - if appModel.canSwitchDashboardViewMode { + if appModel.canOpenRemoteDashboardFromLocalApp { CommandMenu("View") { Button("Local Dashboard") { appModel.viewMode = .local @@ -126,6 +163,9 @@ struct SwiftBotApp: App { PreferencesView() .environmentObject(appModel) .environmentObject(updater) + .background(WindowAccessor { window in + restoreSettingsWindowChrome(window) + }) } .windowResizability(.contentSize) } @@ -153,3 +193,30 @@ struct SwiftBotApp: App { } } } + +private extension NSUserInterfaceItemIdentifier { + static let mainWindow = NSUserInterfaceItemIdentifier("SwiftBotMainWindow") + static let settingsWindow = NSUserInterfaceItemIdentifier("SwiftBotSettingsWindow") +} + +private struct WindowAccessor: NSViewRepresentable { + let onResolve: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + onResolve(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + if let window = nsView.window { + onResolve(window) + } + } + } +}