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
}
}
}