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 fd80b75..5ad00c7 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -41,6 +41,14 @@ 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 */; }; + 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 */; }; @@ -53,6 +61,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 */; }; @@ -92,6 +106,14 @@ 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 = ""; }; + 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 = ""; }; @@ -104,6 +126,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 = ""; }; @@ -158,6 +186,12 @@ children = ( 6337960E98AEBC0A19A67531 /* AppModel.swift */, 11C22D661122334455667788 /* AdminWebServer.swift */, + 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 */, @@ -187,6 +221,7 @@ 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */, 0A6B7D201122334455667788 /* Resources */, 35480F12EB2C7DFB546BD550 /* RootView.swift */, + B01010101010101010101001 /* RemoteModeRootView.swift */, C1D2E3F4A5B6C7D8E9F00111 /* OnboardingView.swift */, D2E3F4A5B6C7D8E9F0011223 /* OverviewView.swift */, F4A5B6C7D8E9F001122334A5 /* VoiceActionsView.swift */, @@ -289,7 +324,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 */, @@ -314,9 +355,16 @@ 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 */, + 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/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 57a138c..710964c 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 @@ -297,6 +308,7 @@ actor AdminWebServer { var discordOAuth: OAuthProviderSettings var redirectPath: String var allowedUserIDs: [String] + var remoteAccessToken: String } private struct HTTPRequest { @@ -321,6 +333,7 @@ actor AdminWebServer { private struct PendingState { let value: String let expiresAt: Date + let appRedirectURL: String? } private struct DiscordUser { @@ -347,7 +360,8 @@ actor AdminWebServer { https: nil, discordOAuth: OAuthProviderSettings(), redirectPath: "/auth/discord/callback", - allowedUserIDs: [] + allowedUserIDs: [], + remoteAccessToken: "" ) private var listener: NWListener? private var nioChannel: Channel? @@ -356,6 +370,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 +417,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 +457,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 +794,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", @@ -1144,10 +1232,44 @@ 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/login + // → 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() + return await handleDiscordLogin(request: request) case ("POST", "/auth/logout"): 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)) } @@ -1232,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 { @@ -1240,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)") @@ -1266,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)) } @@ -1291,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 { @@ -1313,13 +1444,106 @@ 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 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? { - guard let sessionID = cookie(named: "swiftbot_admin_session", request: request), - let session = sessions[sessionID], - session.expiresAt > Date() else { + // 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 nil + } + + 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), + authorization.hasPrefix("Bearer ") else { + return false + } + + let providedToken = String(authorization.dropFirst("Bearer ".count)).trimmingCharacters(in: .whitespacesAndNewlines) + 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 session + 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 { 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 b175ae4..3f12296 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,38 @@ 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 + 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 localProviderBox: AnyBotDataProvider? + + private func updateProvider() { + 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. @Published var resolvedClientID: String? = nil /// Result from the most recent rich token validation; exposed for onboarding UI error display. @@ -266,6 +321,35 @@ final class AppModel: ObservableObject { return URL(string: "https://cdn.discordapp.com/embed/avatars/\(index).png") } + var isRemoteLaunchMode: Bool { + 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 + } + + 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 +392,19 @@ 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) + + // Initialize the appropriate data provider + await MainActor.run { + self.updateProvider() + } if let cachedDiscord = await discordCacheStore.load() { await discordCache.replace(with: cachedDiscord) await syncPublishedDiscordCacheFromService() @@ -329,6 +423,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 +568,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 +1078,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 +1225,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) @@ -1115,10 +1240,48 @@ 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 } + func completeRemoteModeOnboarding(primaryNodeAddress: String, accessToken: String) { + settings.launchMode = .remoteControl + settings.remoteMode = RemoteModeSettings( + primaryNodeAddress: primaryNodeAddress, + accessToken: accessToken + ) + settings.remoteMode.normalize() + viewMode = .remote + saveSettings() + 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, + 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. @@ -1157,6 +1320,7 @@ final class AppModel: ObservableObject { func runInitialSetup() { resolvedClientID = nil lastTokenValidationResult = nil + viewMode = .local isOnboardingComplete = false } @@ -1400,6 +1564,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 +2100,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 +2111,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 +2131,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( @@ -3891,3 +4175,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/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/ModeSelectionView.swift b/SwiftBotApp/ModeSelectionView.swift new file mode 100644 index 0000000..0453dae --- /dev/null +++ b/SwiftBotApp/ModeSelectionView.swift @@ -0,0 +1,63 @@ +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(availableModes) { 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) + .environmentObject(AppModel()) +} 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/OnboardingRootView.swift b/SwiftBotApp/OnboardingRootView.swift new file mode 100644 index 0000000..bb9a390 --- /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 Remote" + } + } + + 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. Beta feature." + } + } + + 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 224b3cf..1722efc 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,158 +442,157 @@ struct OnboardingGateView: View { .frame(maxWidth: 560) } - private func stepOrder(_ step: Step) -> Int { + // MARK: - Remote flow + + @ViewBuilder + private var remoteFlow: some View { switch step { - case .choosePath: return 0 - case .entry, .failed: return 1 - case .validating: return 2 - case .confirmed: return 3 - case .meshSetup: return 4 - case .meshTesting: return 5 - case .meshConfirmed, .meshFailed: return 6 - } - } -} + 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) + } -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" - ] + if let latency = remoteTester.lastLatencyMs { + Text("Round-trip latency \(latency.formatted(.number.precision(.fractionLength(0)))) ms") + .font(.callout) + .foregroundStyle(.secondary) + } - 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) + Button { + app.completeRemoteModeOnboarding( + primaryNodeAddress: remoteAddressInput, + accessToken: remoteAccessTokenInput + ) + } label: { + Label("Open Remote Dashboard", systemImage: "arrow.right.circle.fill") + .frame(minWidth: 220) + } + .onboardingGlassButton() } - } - .clipped() - .opacity(colorScheme == .dark ? 0.78 : 0.96) - } + case .remoteFailed: + VStack(spacing: 16) { + if let error = remoteTester.lastError, !error.isEmpty { + Text(error) + .font(.callout) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } - @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 + 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() + } + } - for row in 0.. Int { - let m = max(modulus, 1) - let r = value % m - return r >= 0 ? r : r + m - } + HStack(spacing: 12) { + Button { step = .choosePath } label: { + Label("Back", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + .controlSize(.large) - 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 - } + Button { + let config = RemoteModeSettings( + primaryNodeAddress: remoteAddressInput, + accessToken: remoteAccessTokenInput + ) + remoteTester.updateConfiguration(config) + step = .remoteTesting - 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 + 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) + } } - 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) - ) + .frame(maxWidth: 560) } - 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) - ) + private func stepOrder(_ step: Step) -> Int { + switch step { + case .choosePath: return 0 + case .entry, .failed: return 1 + case .validating: return 2 + case .confirmed: return 3 + 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/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/PreferencesView.swift b/SwiftBotApp/PreferencesView.swift index e2135a0..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() @@ -52,46 +56,90 @@ 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(selection: $selectedTab) { + GeneralPreferencesView() + .tabItem { + Label("General", systemImage: "gear") + } + .tag(0) - MeshPreferencesView() - .tabItem { - Label("SwiftMesh", systemImage: "point.3.connected.trianglepath.dotted") - } + MeshPreferencesView() + .tabItem { + Label("SwiftMesh", systemImage: "point.3.connected.trianglepath.dotted") + } + .tag(1) - WebUIPreferencesView() - .tabItem { - Label("Web UI", systemImage: "globe") - } + WebUIPreferencesView() + .tabItem { + Label("Web UI", systemImage: "globe") + } + .tag(2) - UpdatesPreferencesView() - .tabItem { - Label("Updates", systemImage: "arrow.clockwise") - } + UpdatesPreferencesView() + .tabItem { + Label("Updates", systemImage: "arrow.clockwise") + } + .tag(3) - AdvancedPreferencesView() - .tabItem { - Label("Developer", systemImage: "wrench") + AdvancedPreferencesView() + .tabItem { + Label("Developer", systemImage: "wrench") + } + .tag(4) } - } - .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 } + .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/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/RemoteSetupView.swift b/SwiftBotApp/RemoteSetupView.swift new file mode 100644 index 0000000..e37a49f --- /dev/null +++ b/SwiftBotApp/RemoteSetupView.swift @@ -0,0 +1,241 @@ +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 { + 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() + } + } + + // 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 + } + } + + // MARK: - OAuth Flow + + private func startOAuthFlow() { + let normalizedAddress = RemoteModeSettings.normalizeBaseURL(remoteAddressInput) + 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.updateRemoteModeConnection( + primaryNodeAddress: normalizedAddress, + accessToken: "" + ) + remoteTester.lastError = nil + + // 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 224c7d6..98e2447 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -2,54 +2,90 @@ 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 var body: some View { if !app.isOnboardingComplete { - OnboardingGateView() + 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) .frame(minWidth: 1200, minHeight: 760) .toggleStyle(.switch) } else { - HSplitView { - DashboardSidebar(selection: $selection) - .frame(minWidth: 230, idealWidth: 250, maxWidth: 280) + fallbackView + } + } + + @ViewBuilder + private var fallbackView: some View { + 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.canOpenRemoteDashboardFromLocalApp && 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 + } } } 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/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/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 +} 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) 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..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 } - 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 - } + 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]) - 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 cornerRadius: CGFloat = 12 - 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 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 zoomButton = window.standardWindowButton(.zoomButton) { - zoomButton.action = #selector(NSWindow.performZoom(_:)) - zoomButton.target = window - } + 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 + } + + 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) + } - window.invalidateShadow() + 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 + } + + 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,9 +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) @@ -102,13 +142,81 @@ struct SwiftBotApp: App { } .disabled(!updater.canCheckForUpdates) } + if appModel.canOpenRemoteDashboardFromLocalApp { + 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 { PreferencesView() .environmentObject(appModel) .environmentObject(updater) + .background(WindowAccessor { window in + restoreSettingsWindowChrome(window) + }) } .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) + } + } +} + +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) + } + } + } } 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) +} 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 } } }