diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..8303242 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,139 @@ +# SwiftLint configuration for SwiftBot — macOS SwiftUI app +# https://github.com/realm/SwiftLint + +# ── Opt-in rules ────────────────────────────────────────────────────────────── +opt_in_rules: + - array_init + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - enum_case_associated_values_count + - explicit_init + - fallthrough + - fatal_error_message + - file_header + - first_where + - flatmap_over_map_reduce + - identical_operands + - joined_default_parameter + - last_where + - legacy_multiple + - literal_expression_end_indentation + - lower_acl_than_parent + - modifier_order + - multiline_arguments + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - operator_usage_whitespace + - overridden_super_call + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - redundant_nil_coalescing + - redundant_type_annotation + - single_test_class + - sorted_first_last + - toggle_bool + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - weak_delegate + - xct_specific_matcher + - yoda_condition + +# ── Disabled rules ───────────────────────────────────────────────────────────── +disabled_rules: + # SwiftUI views routinely have long bodies — not meaningful to enforce here + - function_body_length + # SwiftUI previews and complex configurators often exceed the default + - closure_body_length + # Large model/view files are common in a single-module app + - file_length + # Long argument lists appear frequently in Discord model types + - function_parameter_count + # Force-unwrapping is sometimes appropriate in tests / unavoidable crash paths + - force_unwrapping + # SwiftUI DSL can have deeply nested closures + - nesting + # Type bodies are intentionally large in feature-rich model objects + - type_body_length + # Trailing commas in multi-line Swift are stylistic — not enforced + - trailing_comma + # HTTP routing switch statements are inherently high-complexity; not actionable + - cyclomatic_complexity + +# ── Rule configuration ───────────────────────────────────────────────────────── +line_length: + warning: 150 + error: 300 + ignores_comments: true + ignores_urls: true + ignores_function_declarations: false + ignores_interpolated_strings: true + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 60 + error: 80 + excluded: + - id + - x + - y + - z + - op + - db + - ok + - to + - up + +type_name: + min_length: 3 + max_length: + warning: 60 + error: 80 + +large_tuple: + warning: 3 + error: 4 + +multiline_arguments: + first_argument_location: any_line + only_enforce_after_first_closure_on_first_line: true + +enum_case_associated_values_count: + warning: 6 + error: 8 + +# ── Analyser rules (requires --enable-analyzer-rules flag) ──────────────────── +analyzer_rules: + - explicit_self + - unused_declaration + - unused_import + +# ── Paths ────────────────────────────────────────────────────────────────────── +included: + - SwiftBotApp + - Tools + +excluded: + - .build + - DerivedData + - Pods + - SwiftBotApp/Resources + - Tests + +# ── Reporter ─────────────────────────────────────────────────────────────────── +reporter: xcode diff --git a/Package.swift b/Package.swift index d1bc595..ebfa7fe 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,6 @@ let package = Package( .executable(name: "SparklePublisher", targets: ["SparklePublisher"]) ], dependencies: [ - .package(path: "Sources/UpdateEngine"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.9.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.12.3"), .package(url: "https://github.com/apple/swift-asn1.git", from: "1.1.0"), @@ -22,7 +21,6 @@ let package = Package( .executableTarget( name: "SwiftBot", dependencies: [ - .product(name: "UpdateEngine", package: "UpdateEngine"), .product(name: "X509", package: "swift-certificates"), .product(name: "Crypto", package: "swift-crypto"), .product(name: "SwiftASN1", package: "swift-asn1"), @@ -42,8 +40,7 @@ let package = Package( .testTarget( name: "SwiftBotTests", dependencies: [ - "SwiftBot", - .product(name: "UpdateEngine", package: "UpdateEngine") + "SwiftBot" ], path: "Tests/SwiftBotTests" ) diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index b5bb688..2594934 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -3,347 +3,453 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - 080011223344556677AABBCE /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07080011223344556677AABB /* SettingsView.swift */; }; - 08DCC0CBE7324CA3B5253825 /* Models/RuleEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD8A97AEC9B4F4AAAEF2C7B /* Models/RuleEngineModels.swift */; }; - 0A6B7D101122334455667788 /* Resources in Resources */ = {isa = PBXBuildFile; fileRef = 0A6B7D201122334455667788 /* Resources */; }; - 11223344556677AABBCCDDFF /* CommonUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0011223344556677AABBCCDD /* CommonUI.swift */; }; - 11C22D331122334455667788 /* ClusterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C22D441122334455667788 /* ClusterCoordinator.swift */; }; - 11C22D551122334455667788 /* AdminWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C22D661122334455667788 /* AdminWebServer.swift */; }; - 11D1A1B2C3D4E5F607182930 /* Services/CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182931 /* Services/CommandProcessor.swift */; }; - 11D1A1B2C3D4E5F607182932 /* Services/DiscordAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182933 /* Services/DiscordAIService.swift */; }; - 11D1A1B2C3D4E5F607182934 /* Services/DiscordGatewayConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182935 /* Services/DiscordGatewayConnection.swift */; }; - 11D1A1B2C3D4E5F607182936 /* Services/DiscordGuildRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182937 /* Services/DiscordGuildRESTClient.swift */; }; - 11D1A1B2C3D4E5F607182938 /* Services/DiscordIdentityRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182939 /* Services/DiscordIdentityRESTClient.swift */; }; - 11D1A1B2C3D4E5F60718293A /* Services/DiscordInteractionRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F60718293B /* Services/DiscordInteractionRESTClient.swift */; }; - 11D1A1B2C3D4E5F60718293C /* Services/DiscordMessageRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F60718293D /* Services/DiscordMessageRESTClient.swift */; }; - 11D1A1B2C3D4E5F60718293E /* Services/GatewayEventDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F60718293F /* Services/GatewayEventDispatcher.swift */; }; - 11D1A1B2C3D4E5F607182940 /* Services/RuleExecutionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182941 /* Services/RuleExecutionService.swift */; }; - 11D1A1B2C3D4E5F607182942 /* Services/VoicePresenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182943 /* Services/VoicePresenceStore.swift */; }; - 11D1A1B2C3D4E5F607182944 /* Services/VoiceRuleStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182945 /* Services/VoiceRuleStateStore.swift */; }; - 11D1A1B2C3D4E5F607182946 /* Services/WikiLookupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D1A1B2C3D4E5F607182947 /* Services/WikiLookupService.swift */; }; - 1F7A11C22D33445566778899 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 1F7A11C22D33445566778898 /* Sparkle */; }; - 20382A9EF51DD3FD3E6D9FA2 /* SwiftBotApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA30CC27A218AF533E2E4C0E /* SwiftBotApp.swift */; }; - 212CE68E56C348E2B16F8E20 /* Models/EventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2038AEE60943658748C2A8 /* Models/EventBus.swift */; }; - 222494946C4E49E09016F964 /* SwiftBotApp/OnboardingStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2206311680E46EFA928E726 /* SwiftBotApp/OnboardingStyles.swift */; }; - 259ADBC5CA9C03B29493726F /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6337960E98AEBC0A19A67531 /* AppModel.swift */; }; - 2A8C10001122334455667788 /* UpdateEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 2A8C10101122334455667788 /* UpdateEngine */; }; - 2D93A1C4B5E6F708192A3B4C /* AppModel+SlashCommandHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D93A1C3B5E6F708192A3B4C /* AppModel+SlashCommandHelpers.swift */; }; - 2E1AB44CA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E1AB44BA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift */; }; - 2FB770B8A11D22E33F44C550 /* AppModelTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB770B7A11D22E33F44C550 /* AppModelTypes.swift */; }; - 346EE31F9E1D4CB28BCE37A8 /* Models/BotSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7436E2097EB456B85B3138E /* Models/BotSettings.swift */; }; - 3B969F2C6A7C429B9E9392E0 /* SwiftBotApp/ModeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142D6839427040358B4FBA90 /* SwiftBotApp/ModeSelectionView.swift */; }; - 4310D51BEC4040248D9F8E66 /* Models/KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DEA97F20B664678BC69DB03 /* Models/KeychainHelper.swift */; }; - 45BDC95CDE8D4005A5A70F85 /* SwiftBotApp/StandaloneSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE30A3D04296486A87AFEC73 /* SwiftBotApp/StandaloneSetupView.swift */; }; - 49AEED7B963B4B6F842F7A10 /* SwiftBotApp/RemoteSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199651172D924276B1B7FA3B /* SwiftBotApp/RemoteSetupView.swift */; }; - 4B164E69FD8746C58CA0E842 /* Models/ClusterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0870D97E081745428605B82E /* Models/ClusterModels.swift */; }; - 5125A5586D3B4765960059A3 /* Models/AIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFCFFB1B1A45FABEAE7686 /* Models/AIModels.swift */; }; - 538A5B6B3E4166EE17B3A077 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35480F12EB2C7DFB546BD550 /* RootView.swift */; }; - 5AEC748EA501439CBE3442FC /* SwiftBotApp/OnboardingRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA77BCA23D894E4E98F5B874 /* SwiftBotApp/OnboardingRootView.swift */; }; - 5B1E9801A1B2C3D4E5F60718 /* VoiceRuleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */; }; - 5B2FA002B2C3D4E5F6071901 /* EmptyRuleOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2FA001B2C3D4E5F6071900 /* EmptyRuleOnboardingView.swift */; }; - 6F1B40D1A2B3C4D5E6F70819 /* AppModel+Gateway.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D0A2B3C4D5E6F70819 /* AppModel+Gateway.swift */; }; - 6F1B40D3A2B3C4D5E6F70819 /* AppModel+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D2A2B3C4D5E6F70819 /* AppModel+Commands.swift */; }; - 6F1B40D5A2B3C4D5E6F70819 /* AppModel+AI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D4A2B3C4D5E6F70819 /* AppModel+AI.swift */; }; - 6F1B40D7A2B3C4D5E6F70819 /* AppModel+CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D6A2B3C4D5E6F70819 /* AppModel+CommandProcessor.swift */; }; - 6F1B40D9A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D8A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift */; }; - AB32B2AEE754434283311E56 /* AppModel+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2C94CD671B4FF1B53E0E01 /* AppModel+Media.swift */; }; - 6E00FB54496F46AEA8E4E3CE /* AppModel+BotLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0360C1B274834BFFA134D842 /* AppModel+BotLifecycle.swift */; }; - A71F38FA5B6D492D96110EE5 /* AppModel+AdminWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85615EB8C640459B8480BE2E /* AppModel+AdminWeb.swift */; }; - 73BAC11337B101CC5C7AFCD2 /* DiscordService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3205CAA1A44F7E79578277 /* DiscordService.swift */; }; - 75F7879D2B8A080849E4D4A2 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010969C7B6435248430DD012 /* Models.swift */; }; - 77516EDB305B452A9063B036 /* Models/BotStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D44391E12144C8A9215A079 /* Models/BotStateModels.swift */; }; - 829DC28FA2E7429B93795C74 /* Models/DiscordCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFA0F326F22A4CF2817AA2AB /* Models/DiscordCache.swift */; }; - 8D8E9F001122334455667788 /* AppUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8E9F101122334455667788 /* AppUpdater.swift */; }; - 8E8D7C6B5A4F3E2D1C0B9A88 /* HelpEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E8D7C6B5A4F3E2D1C0B9A89 /* HelpEngine.swift */; }; - 9A8B7C601122334455667788 /* SwiftBot.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5015DDB02F554EF200618C6D /* SwiftBot.icon */; }; - 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 */; }; - A1B2C3D40111223344556601 /* Security/CertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40111223344556701 /* Security/CertificateManager.swift */; }; - A1B2C3D40111223344556602 /* Security/ACMEClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40111223344556702 /* Security/ACMEClient.swift */; }; - A1B2C3D40111223344556603 /* Security/CloudflareDNSProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40111223344556703 /* Security/CloudflareDNSProvider.swift */; }; - A1B2C3D40111223344556604 /* Security/CloudflareTunnelClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40111223344556704 /* Security/CloudflareTunnelClient.swift */; }; - A1B2C3D40111223344556605 /* Security/TunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40111223344556705 /* Security/TunnelManager.swift */; }; - A1B2C3D40111223344556A01 /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D40111223344556901 /* X509 */; }; - A1B2C3D40111223344556A02 /* Crypto in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D40111223344556902 /* Crypto */; }; - A1B2C3D40111223344556A03 /* SwiftASN1 in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D40111223344556903 /* SwiftASN1 */; }; - 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 */; }; - A2B3C4D5E6F7080911223345 /* MediaExportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D5E6F7080911223344 /* MediaExportCoordinator.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 */; }; - AA1000011122334455667702 /* GeneralPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667702 /* GeneralPreferencesView.swift */; }; - AA1000011122334455667703 /* DiscordPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667703 /* DiscordPreferencesView.swift */; }; - AA1000011122334455667704 /* MeshPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667704 /* MeshPreferencesView.swift */; }; - AA1000011122334455667705 /* WebUIPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667705 /* WebUIPreferencesView.swift */; }; - AA1000011122334455667706 /* UpdatesPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667706 /* UpdatesPreferencesView.swift */; }; - AA1000011122334455667707 /* AdvancedPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011122334455667707 /* AdvancedPreferencesView.swift */; }; - 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 */; }; - 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 */; }; - D4E5F607080011223344556B /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F6070800112233445C /* LogsView.swift */; }; - D6E7F8091A2B3C4D5E6F7082 /* IntelligenceGlowBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E7F8091A2B3C4D5E6F7081 /* IntelligenceGlowBorder.swift */; }; - E4C1A9000102030405060708 /* WikiBridgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C1A9010102030405060708 /* WikiBridgeView.swift */; }; - EBEDA37ACA12477FB8096E6D /* SwiftBotApp/SwiftMeshSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2338A77A835B45B3963A71D7 /* SwiftBotApp/SwiftMeshSetupView.swift */; }; - F2BE0FA6AB43AF1AB21CD5D7 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9564766EDA4C18D06A84BCB /* Persistence.swift */; }; - F4A5B6C7D8E9F001122334A6 /* VoiceActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A5B6C7D8E9F001122334A5 /* VoiceActionsView.swift */; }; - F607080011223344556677AD /* AIBotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F607080011223344556677 /* AIBotsView.swift */; }; - F6B4A085F1EF4FC8B45EA1A5 /* Models/GatewayModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50331CC0992C4A59BFC07663 /* Models/GatewayModels.swift */; }; + 0005740627E44E41BFB0B69B /* UpdatesPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F9E79347D751050F3DE039 /* UpdatesPreferencesView.swift */; }; + 00F2D700D7DA38BA9049A986 /* GeneralPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3595DECC8AF4632DC5E33E83 /* GeneralPreferencesView.swift */; }; + 085D20923E60B1599D77A36D /* PatchyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A08A1E26A9A98169FEE2F3B /* PatchyViewModel.swift */; }; + 0DE92D9DBC280ACE537C2209 /* DiscordService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BBDCEDB0E2FC18B5BD01F0 /* DiscordService.swift */; }; + 0F8391EF95A66EB768F81BA8 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A523236CFC7EE60B9E5F3AC /* PreferencesView.swift */; }; + 10688115A610483DA3FBB1A5 /* BotDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB947E7E83CAEC7FAECF7D09 /* BotDataProvider.swift */; }; + 126B88FAE40D80657EB4FE78 /* WikiLookupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40BC9ACDFB65AED3E2743A4D /* WikiLookupService.swift */; }; + 12E4267A46ED7C52DDBF535B /* AppModel+BotLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72EB30017C9118909755079 /* AppModel+BotLifecycle.swift */; }; + 13EC6A8C3526F1B617A9C146 /* TunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C516D7E2A82E384871ADA77 /* TunnelManager.swift */; }; + 15272B7A21973431AB9E8263 /* AppModel+Cluster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D7E1662299F69697912A63E /* AppModel+Cluster.swift */; }; + 16BAB36AC9A670AE9B8375EE /* AppModel+AdminWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EC3AEA8E17C6B46C8E9A679 /* AppModel+AdminWeb.swift */; }; + 1D30EB61BE0A3E76319F3871 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5788F384AF1C5CEC1A7137 /* main.swift */; }; + 1E0F9DE242BF278D48AB932D /* OnboardingRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D35BA34860F043C661478F /* OnboardingRootView.swift */; }; + 21F0F51F5739E8BA2E0E6978 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A84E95FDE31E5E752C2E2F /* Persistence.swift */; }; + 22701BDB9191BD43BFA13BD7 /* SwiftMeshView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD24A98DC57DF430081B255 /* SwiftMeshView.swift */; }; + 26001C9AFAE18FF56EA4BCDD /* SwiftASN1 in Frameworks */ = {isa = PBXBuildFile; productRef = 6BEFF4EF2D66A493DAF66DDA /* SwiftASN1 */; }; + 27EBD03F4CDFA5B4F8A76F38 /* DiscordMessageRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE46A141E350EC70B74B7430 /* DiscordMessageRESTClient.swift */; }; + 2C30A3ECEFEBB1D412442557 /* MediaLibraryIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AD9D0BFA57E9EFB2E11322 /* MediaLibraryIndexer.swift */; }; + 2D75FD3C65BC54A6F1EEA2AA /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F3D691AFC6D78460AE015A8 /* DiagnosticsView.swift */; }; + 2F717839189B088E0B4DA26C /* PatchyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44CA1B8D8A85A90468C0E9C3 /* PatchyView.swift */; }; + 32EF85D74938EFC99D68448B /* HelpEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902D75D3824989E0F053C6B /* HelpEngine.swift */; }; + 3427FDD6B8F160DBA04F79E1 /* RemoteSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A03317C59A5CE2BC3F067F /* RemoteSetupView.swift */; }; + 35714FE14D2FFB4A4A8B34B6 /* AppModel+CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C510CF282F7E3B9D9A55415A /* AppModel+CommandProcessor.swift */; }; + 37247A9D640138A5CBF8F161 /* OnboardingStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E72DBCA95A64DF3580BEB869 /* OnboardingStyles.swift */; }; + 382736874A4ABFC372B65199 /* GatewayModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DC12FFA9EC621A265D2A0C /* GatewayModels.swift */; }; + 3A84CA8872264B04238C0992 /* AppModelTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55E2CDD13174C72BDB79094 /* AppModelTypes.swift */; }; + 3C569F8CE14C8C41CA1C6246 /* AppModel+DiscordEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6165A66E22A9D4F34E71A45 /* AppModel+DiscordEvents.swift */; }; + 3E3F9681C479C7D5E52D9348 /* IntelligenceGlowBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD2301142C3495D5EE63034 /* IntelligenceGlowBorder.swift */; }; + 3F177F30A59216E08AA7FC23 /* MediaExportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5794D658F3B4D68836792B7 /* MediaExportCoordinator.swift */; }; + 403C97BE65696647F37FFD89 /* IntelService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316BE5EE23ADB08FD4042070 /* IntelService.swift */; }; + 40EEEB0FC47E27A70C5D25AF /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 7C341BA99920552C7CDA9D24 /* Sparkle */; }; + 440F6A5F62FD1B7714FD1A2E /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E1C9365566CEC38D0E39EB /* KeychainHelper.swift */; }; + 4440417E51C60D48DDE07270 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A94E05BFB8B12D551FBD96 /* Models.swift */; }; + 4816D705CD69FA14D1641610 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EABA6DDB19D3312394B5D72 /* SettingsView.swift */; }; + 4EB0A5E6CC96AD52F65A20C0 /* CacheKeyBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D70C83084571D4A0927A8AA /* CacheKeyBuilder.swift */; }; + 52D688DC1DDA87CD00D7A749 /* DiscordGuildRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2912532FD0E1F2D6462C3B /* DiscordGuildRESTClient.swift */; }; + 5343E350CBC68B28BDD894C4 /* GatewayEventDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855F750BDCFF1773E57DF565 /* GatewayEventDispatcher.swift */; }; + 566EE7D8A59E9C9890359374 /* AppModel+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E3EF8C962344F48B9C0BF73 /* AppModel+Commands.swift */; }; + 5799D01A37C0C70F209DEE50 /* AIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40B1CD3E3726DBE1AA70765 /* AIModels.swift */; }; + 58E683E4B09BA7F0510C4D2B /* ClusterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDF94EFD14E6193471D909 /* ClusterCoordinator.swift */; }; + 5C11B6D31A6225BC9D878539 /* MeshPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53B8C017D812B376BF24D61 /* MeshPreferencesView.swift */; }; + 5CA7B51148541E5E468552C3 /* DiscordAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5F49E09077B173A9D9C851 /* DiscordAIService.swift */; }; + 5D25AD1C212F4CC9B5C90DD5 /* DiscordPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DA7729D9CB3A88CF3619B8 /* DiscordPreferencesView.swift */; }; + 5F948EAA0D02EA068B33BC39 /* AppModel+WikiBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B696934C12E6410E1E192294 /* AppModel+WikiBridge.swift */; }; + 61B7BFCD4B707D6306631F7A /* AppModel+Patchy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD42789A025FD409C081463 /* AppModel+Patchy.swift */; }; + 61FE77A534CAE8A1DB129DD6 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985814A00309DEBC6C7FD35F /* OverviewView.swift */; }; + 63424338C247B5ECB87A8504 /* AppUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F49DE5A766F83A139F9F20 /* AppUpdater.swift */; }; + 647A7728561387E5DFC85FD3 /* UpdateSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FAF77E90DE081DDE95A80BF /* UpdateSource.swift */; }; + 65F8E1C9E4E603B4E420DCE9 /* CloudflareTunnelClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07385233B714CF5897D32E5 /* CloudflareTunnelClient.swift */; }; + 6685B7E80DABB0ACEE4BE071 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AEB32046AFF5E8525A5B458 /* NIOCore */; }; + 6923C1DFC02F72C9F6A307AC /* RemoteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F5F3F20E9067B456E84EB6 /* RemoteAPI.swift */; }; + 6B8F2B6980D8C3FA13D9CAC3 /* CloudflareDNSProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5365D3471C752240ADA7D5 /* CloudflareDNSProvider.swift */; }; + 6CF94F13C717C0AB955A7215 /* DiscordInteractionRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750574541041BF7D66545F68 /* DiscordInteractionRESTClient.swift */; }; + 6D555B0AE68DA044A57A4093 /* VoiceRuleStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22C5009D34D5F98076D3B7 /* VoiceRuleStateStore.swift */; }; + 6E8FB330BD0BE5ADFCA0FC6B /* MediaThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDACC9B9F1633073834BE07 /* MediaThumbnailCache.swift */; }; + 7071FF33C237742C35AB7082 /* AppModel+DiscordParsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABB16BA645CAAD2D3A17C6F /* AppModel+DiscordParsers.swift */; }; + 7132DFC658401903B85DC61D /* CertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D3D1ABFC5B176C108B7B6E /* CertificateManager.swift */; }; + 7894B179A8B556313CC7492A /* AppModel+Gateway.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5716A5F63D0211A0DA067364 /* AppModel+Gateway.swift */; }; + 795E1B910968188CC57BA263 /* RemoteControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7059780457BD592C5364C934 /* RemoteControlService.swift */; }; + 7A065730CD545F87AF004893 /* AdminWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB32D13A6D673323296304C /* AdminWebServer.swift */; }; + 7A8F1F65E34BD4BFFE7BCAA9 /* AdvancedPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82CE247254BB7396D69F514B /* AdvancedPreferencesView.swift */; }; + 7B1416030084F25AC03D0B84 /* AppModel+SlashCommandHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA2D2805C055B0DE50F1453 /* AppModel+SlashCommandHelpers.swift */; }; + 831BDE04FBF99D4CD415BD88 /* WikiBridgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8B9FF78E7B196891AF7A7F /* WikiBridgeView.swift */; }; + 8754C1BE68873E719DB6E909 /* ModeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC1974FBBE7F7282C86BCB3 /* ModeSelectionView.swift */; }; + 8764933B1A2D5429FEC2FAA5 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB38D25572DE04143F91B8B2 /* RootView.swift */; }; + 8830FFC6D53A953574FE99AF /* AppModel+VoicePresence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E828B8B55D1AEB575FE26E5 /* AppModel+VoicePresence.swift */; }; + 889466FAF399CB465ED1C45B /* WebUIPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC29827C8F3269A4F23E561 /* WebUIPreferencesView.swift */; }; + 8B5F8E571AC8FB3BCD79EC99 /* CommonUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065E8C11350B1294C20F9B3A /* CommonUI.swift */; }; + 8B909CD9FA439A5575ED2EAF /* DiscordCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C706274351683F9F2B98EFC6 /* DiscordCache.swift */; }; + 8FDF1380E0C0369F9570C5D4 /* StandaloneSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E8A9E44693B2BF8DA450D85 /* StandaloneSetupView.swift */; }; + 91FF3FB5CA902BC308D4B6F2 /* CommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D35375EF6E1EA424CC6BE3D /* CommandsView.swift */; }; + 93A851A6FD066BDF9D74AFAD /* BuiltInUpdateSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1FE5FBB68C0886CB6760F7 /* BuiltInUpdateSources.swift */; }; + 9527171E8D1C224595FF1BF6 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 1C81108D7CF200AA2750ED17 /* NIOSSL */; }; + 9A07952399C3531240BD87FB /* DiscordIdentityRESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 694267133E5E659F12946B10 /* DiscordIdentityRESTClient.swift */; }; + 9AC1DDF0CBE7C89586CB079D /* AppModel+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7962859CFEF723E67BD800C /* AppModel+Media.swift */; }; + 9C5DCB5C183F3BCFF236095E /* AMDService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87A43A58BBA036A9646BCAC /* AMDService.swift */; }; + 9C70C57B9C4A44DC95FB0C56 /* SteamService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBC0B8ABE120213D32A8F9F /* SteamService.swift */; }; + 9D8268E43E33B6ADEDDCFD7E /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC9C07D51639D96FE0038EA /* CommandProcessor.swift */; }; + A0FD425E7CB10D15C1B2F630 /* BotSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2402E98DBD749BAE72E6B33 /* BotSettings.swift */; }; + A5CBEE32EC1247802A9F689E /* VoiceRuleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C5EE8341F1833DB1BF48D6 /* VoiceRuleListView.swift */; }; + AB311B611EAF20CED07A34FD /* BotStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C883C3445CFB25BE95819B5 /* BotStateModels.swift */; }; + AC0B6725D3ABBB12498538EF /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02B30EDD00CF95D428E4065 /* UpdateChecker.swift */; }; + ACE122B57907AC0B3FBD616F /* VoicePresenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F319382F183BB1612580B30 /* VoicePresenceStore.swift */; }; + B0787CF87C490A51456F573B /* UpdateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 788B16C5C9EE07B0145A0E82 /* UpdateItem.swift */; }; + B0ECA01493988DD4BBE0840D /* DiscordGatewayConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460C93A6D305EACF834AA7E4 /* DiscordGatewayConnection.swift */; }; + B6B3280ACF4438511C36A188 /* SwiftBotApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5083D5348FCB914E6C796E2D /* SwiftBotApp.swift */; }; + BD731E0515669EE345148385 /* VersionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346C818F5054314C6BF97DC5 /* VersionStore.swift */; }; + BF9824A4094CDC175299AA63 /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 7DF22120B86D527EE31A4E83 /* NIOPosix */; }; + C0A3E25F664C72E666E3D3F2 /* EventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CA49D19240EC6B6DB7C08C /* EventBus.swift */; }; + C1AFBA55AC265D22F80521D2 /* VoiceActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2627CB6D5226BCFBDB65D61 /* VoiceActionsView.swift */; }; + C2531026993F1B1C6B25F226 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E857AB706540A5A69F2DEC0 /* OnboardingView.swift */; }; + C36B35143B0B2A8DBD9AD6ED /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 81D226FCC037CE475B1850B7 /* X509 */; }; + C73139C0B46A9ACEEF9C5177 /* ClusterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 322D9081CEE7191A61086D90 /* ClusterModels.swift */; }; + C73223597C2FE00574A04193 /* ReleaseNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9386EDF3092943C62265C4E /* ReleaseNotes.swift */; }; + CB1B41CE58ED74524B73FAA5 /* RuleExecutionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A5EEE408384C33E6A68310 /* RuleExecutionService.swift */; }; + CB267C81149E545799E3E0E4 /* LocalBotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91C41589B7BDFB426C5552BB /* LocalBotProvider.swift */; }; + D78CB841C27860D437A41E36 /* RemoteBotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031F4D5F167A067542123F0C /* RemoteBotProvider.swift */; }; + DB9E21A6521C07EDC9461565 /* NVIDIAService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C2C4E81E105509E3B80F76B /* NVIDIAService.swift */; }; + DC03C1EA8B4FEBC1E2BE90A7 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA9BA187B549800BA8A345C /* AppModel.swift */; }; + DC360305E4A3D50E81B2F576 /* Crypto in Frameworks */ = {isa = PBXBuildFile; productRef = 1840331BC8F2A1886D5014FF /* Crypto */; }; + DEEBB36AAE69806B0DE44641 /* RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F8451B9C919529C86CD289 /* RemoteModels.swift */; }; + DEED6A6AE95EB1E4BD68B09F /* SchemaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C156C1770937C834073B637 /* SchemaSettings.swift */; }; + EAEDC19B03FED30AC99ECBFB /* SwiftBot.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8964B98159AC25452C4423DF /* SwiftBot.icon */; }; + EBF4ED30124502DB72B90F79 /* EmbedFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726607C6AA014293B0C7794D /* EmbedFormatter.swift */; }; + F18225F9C4A1405093180E0D /* AppModel+AI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A275B86FF1816EF5115B7E84 /* AppModel+AI.swift */; }; + F266903914FB55C141F347D5 /* SwiftMeshSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818A90855EE5E88B16695E30 /* SwiftMeshSetupView.swift */; }; + F2CD5289AC48F48DAD2E96CD /* EmptyRuleOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33D0CC3569144419991BD125 /* EmptyRuleOnboardingView.swift */; }; + F3AC4F30295DAC412A25E885 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC99CA7ACBDFF1217B6525B /* LogsView.swift */; }; + F88E9E996F631AB24753F151 /* AIBotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553AAF747AF590A5486827AA /* AIBotsView.swift */; }; + F8B5C3B9C16141D761050DD6 /* ACMEClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8828FF064E45A0519FB1640E /* ACMEClient.swift */; }; + FB161956DA013471E5BF80FF /* Resources in Resources */ = {isa = PBXBuildFile; fileRef = 8568A65A0821C78B3A7007CA /* Resources */; }; + FD624ED4D70CFAF9D943F0B5 /* RemoteModeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33D037B65C9DBFE3FECD644 /* RemoteModeRootView.swift */; }; + FDA57E1FBBE210E213A9292B /* RuleEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CEA798F4C6EE225D9FD1FE /* RuleEngineModels.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 0011223344556677AABBCCDD /* CommonUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUI.swift; sourceTree = ""; }; - 010969C7B6435248430DD012 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; - 07080011223344556677AABB /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 0870D97E081745428605B82E /* Models/ClusterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/ClusterModels.swift; sourceTree = ""; }; - 0A6B7D201122334455667788 /* Resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Resources; sourceTree = ""; }; - 0B3205CAA1A44F7E79578277 /* DiscordService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordService.swift; sourceTree = ""; }; - 11C22D441122334455667788 /* ClusterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClusterCoordinator.swift; sourceTree = ""; }; - 11C22D661122334455667788 /* AdminWebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminWebServer.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182931 /* Services/CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/CommandProcessor.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182933 /* Services/DiscordAIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/DiscordAIService.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182935 /* Services/DiscordGatewayConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/DiscordGatewayConnection.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182937 /* Services/DiscordGuildRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/DiscordGuildRESTClient.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182939 /* Services/DiscordIdentityRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/DiscordIdentityRESTClient.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F60718293B /* Services/DiscordInteractionRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/DiscordInteractionRESTClient.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F60718293D /* Services/DiscordMessageRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/DiscordMessageRESTClient.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F60718293F /* Services/GatewayEventDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/GatewayEventDispatcher.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182941 /* Services/RuleExecutionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RuleExecutionService.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182943 /* Services/VoicePresenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/VoicePresenceStore.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182945 /* Services/VoiceRuleStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/VoiceRuleStateStore.swift; sourceTree = ""; }; - 11D1A1B2C3D4E5F607182947 /* Services/WikiLookupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/WikiLookupService.swift; sourceTree = ""; }; - 142D6839427040358B4FBA90 /* SwiftBotApp/ModeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/ModeSelectionView.swift; sourceTree = ""; }; - 199651172D924276B1B7FA3B /* SwiftBotApp/RemoteSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/RemoteSetupView.swift; sourceTree = ""; }; - 2338A77A835B45B3963A71D7 /* SwiftBotApp/SwiftMeshSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/SwiftMeshSetupView.swift; sourceTree = ""; }; - 27EFCFFB1B1A45FABEAE7686 /* Models/AIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/AIModels.swift; sourceTree = ""; }; - 2D93A1C3B5E6F708192A3B4C /* AppModel+SlashCommandHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+SlashCommandHelpers.swift"; sourceTree = ""; }; - 2E1AB44BA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+DiscordParsers.swift"; sourceTree = ""; }; - 2FB770B7A11D22E33F44C550 /* AppModelTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelTypes.swift; sourceTree = ""; }; - 35480F12EB2C7DFB546BD550 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; - 44A31413B04848EB75D50EFA /* SwiftBot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftBot.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5015DDB02F554EF200618C6D /* SwiftBot.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = SwiftBot.icon; sourceTree = SOURCE_ROOT; }; - 5015DDB42F55851D00618C6D /* SwiftBot-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "SwiftBot-Info.plist"; sourceTree = ""; }; - 50331CC0992C4A59BFC07663 /* Models/GatewayModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/GatewayModels.swift; sourceTree = ""; }; - 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRuleListView.swift; sourceTree = ""; }; - 5B2FA001B2C3D4E5F6071900 /* EmptyRuleOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyRuleOnboardingView.swift; sourceTree = ""; }; - 6337960E98AEBC0A19A67531 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; - 6F1B40D0A2B3C4D5E6F70819 /* AppModel+Gateway.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Gateway.swift"; sourceTree = ""; }; - 6F1B40D2A2B3C4D5E6F70819 /* AppModel+Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Commands.swift"; sourceTree = ""; }; - 6F1B40D4A2B3C4D5E6F70819 /* AppModel+AI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+AI.swift"; sourceTree = ""; }; - 6F1B40D6A2B3C4D5E6F70819 /* AppModel+CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+CommandProcessor.swift"; sourceTree = ""; }; - 6F1B40D8A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+VoicePresence.swift"; sourceTree = ""; }; - 7E2C94CD671B4FF1B53E0E01 /* AppModel+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Media.swift"; sourceTree = ""; }; - 0360C1B274834BFFA134D842 /* AppModel+BotLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+BotLifecycle.swift"; sourceTree = ""; }; - 85615EB8C640459B8480BE2E /* AppModel+AdminWeb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+AdminWeb.swift"; sourceTree = ""; }; - 7D44391E12144C8A9215A079 /* Models/BotStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/BotStateModels.swift; sourceTree = ""; }; - 7DEA97F20B664678BC69DB03 /* Models/KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/KeychainHelper.swift; sourceTree = ""; }; - 8D8E9F101122334455667788 /* AppUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdater.swift; sourceTree = ""; }; - 8E8D7C6B5A4F3E2D1C0B9A89 /* HelpEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpEngine.swift; sourceTree = ""; }; - 8F2038AEE60943658748C2A8 /* Models/EventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/EventBus.swift; sourceTree = ""; }; - A1B2C3D40111223344556701 /* Security/CertificateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Security/CertificateManager.swift; sourceTree = ""; }; - A1B2C3D40111223344556702 /* Security/ACMEClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Security/ACMEClient.swift; sourceTree = ""; }; - 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 = ""; }; - A1B2C3D4E5F60708001122A3 /* CommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsView.swift; sourceTree = ""; }; - A2B3C4D5E6F7080911223344 /* MediaExportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaExportCoordinator.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 = ""; }; - AA2000011122334455667701 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; - AA2000011122334455667702 /* GeneralPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPreferencesView.swift; sourceTree = ""; }; - AA2000011122334455667703 /* DiscordPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordPreferencesView.swift; sourceTree = ""; }; - AA2000011122334455667704 /* MeshPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPreferencesView.swift; sourceTree = ""; }; - AA2000011122334455667705 /* WebUIPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUIPreferencesView.swift; sourceTree = ""; }; - AA2000011122334455667706 /* UpdatesPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatesPreferencesView.swift; sourceTree = ""; }; - AA2000011122334455667707 /* AdvancedPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPreferencesView.swift; sourceTree = ""; }; - ACD8A97AEC9B4F4AAAEF2C7B /* Models/RuleEngineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/RuleEngineModels.swift; sourceTree = ""; }; - AFA0F326F22A4CF2817AA2AB /* Models/DiscordCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/DiscordCache.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 = ""; }; - B4F6C2021122334455667788 /* SchemaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaSettings.swift; sourceTree = ""; }; - B7436E2097EB456B85B3138E /* Models/BotSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/BotSettings.swift; sourceTree = ""; }; - C1D2E3F4A5B6C7D8E9F00111 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.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 = ""; }; - D2E3F4A5B6C7D8E9F0011223 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; - D6E7F8091A2B3C4D5E6F7081 /* IntelligenceGlowBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelligenceGlowBorder.swift; sourceTree = ""; }; - DE30A3D04296486A87AFEC73 /* SwiftBotApp/StandaloneSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/StandaloneSetupView.swift; sourceTree = ""; }; - E4C1A9010102030405060708 /* WikiBridgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiBridgeView.swift; sourceTree = ""; }; - E5F607080011223344556677 /* AIBotsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIBotsView.swift; sourceTree = ""; }; - EA30CC27A218AF533E2E4C0E /* SwiftBotApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp.swift; sourceTree = ""; }; - EA77BCA23D894E4E98F5B874 /* SwiftBotApp/OnboardingRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/OnboardingRootView.swift; sourceTree = ""; }; - F2206311680E46EFA928E726 /* SwiftBotApp/OnboardingStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp/OnboardingStyles.swift; sourceTree = ""; }; - F4A5B6C7D8E9F001122334A5 /* VoiceActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceActionsView.swift; sourceTree = ""; }; - F9564766EDA4C18D06A84BCB /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; + 031F4D5F167A067542123F0C /* RemoteBotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBotProvider.swift; sourceTree = ""; }; + 03DA7729D9CB3A88CF3619B8 /* DiscordPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordPreferencesView.swift; sourceTree = ""; }; + 065E8C11350B1294C20F9B3A /* CommonUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUI.swift; sourceTree = ""; }; + 0D7E1662299F69697912A63E /* AppModel+Cluster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Cluster.swift"; sourceTree = ""; }; + 1C2C4E81E105509E3B80F76B /* NVIDIAService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NVIDIAService.swift; sourceTree = ""; }; + 1C5365D3471C752240ADA7D5 /* CloudflareDNSProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudflareDNSProvider.swift; sourceTree = ""; }; + 1F3D691AFC6D78460AE015A8 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = ""; }; + 24F8451B9C919529C86CD289 /* RemoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteModels.swift; sourceTree = ""; }; + 253ED8DD86B265E94F6ABDE2 /* SparklePublisher */ = {isa = PBXFileReference; includeInIndex = 0; path = SparklePublisher; sourceTree = BUILT_PRODUCTS_DIR; }; + 2C156C1770937C834073B637 /* SchemaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaSettings.swift; sourceTree = ""; }; + 2F319382F183BB1612580B30 /* VoicePresenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoicePresenceStore.swift; sourceTree = ""; }; + 316BE5EE23ADB08FD4042070 /* IntelService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelService.swift; sourceTree = ""; }; + 322D9081CEE7191A61086D90 /* ClusterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClusterModels.swift; sourceTree = ""; }; + 33D0CC3569144419991BD125 /* EmptyRuleOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyRuleOnboardingView.swift; sourceTree = ""; }; + 346C818F5054314C6BF97DC5 /* VersionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionStore.swift; sourceTree = ""; }; + 3595DECC8AF4632DC5E33E83 /* GeneralPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPreferencesView.swift; sourceTree = ""; }; + 38BBDCEDB0E2FC18B5BD01F0 /* DiscordService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordService.swift; sourceTree = ""; }; + 3A523236CFC7EE60B9E5F3AC /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + 3ABB16BA645CAAD2D3A17C6F /* AppModel+DiscordParsers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+DiscordParsers.swift"; sourceTree = ""; }; + 3E3EF8C962344F48B9C0BF73 /* AppModel+Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Commands.swift"; sourceTree = ""; }; + 40BC9ACDFB65AED3E2743A4D /* WikiLookupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiLookupService.swift; sourceTree = ""; }; + 42D35BA34860F043C661478F /* OnboardingRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRootView.swift; sourceTree = ""; }; + 44CA1B8D8A85A90468C0E9C3 /* PatchyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchyView.swift; sourceTree = ""; }; + 44F5F3F20E9067B456E84EB6 /* RemoteAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAPI.swift; sourceTree = ""; }; + 44F9E79347D751050F3DE039 /* UpdatesPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatesPreferencesView.swift; sourceTree = ""; }; + 460C93A6D305EACF834AA7E4 /* DiscordGatewayConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordGatewayConnection.swift; sourceTree = ""; }; + 46E1C9365566CEC38D0E39EB /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + 4AB32D13A6D673323296304C /* AdminWebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminWebServer.swift; sourceTree = ""; }; + 4BD24A98DC57DF430081B255 /* SwiftMeshView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftMeshView.swift; sourceTree = ""; }; + 4C5788F384AF1C5CEC1A7137 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 4ECDF94EFD14E6193471D909 /* ClusterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClusterCoordinator.swift; sourceTree = ""; }; + 5083D5348FCB914E6C796E2D /* SwiftBotApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBotApp.swift; sourceTree = ""; }; + 553AAF747AF590A5486827AA /* AIBotsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIBotsView.swift; sourceTree = ""; }; + 56C5EE8341F1833DB1BF48D6 /* VoiceRuleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRuleListView.swift; sourceTree = ""; }; + 5716A5F63D0211A0DA067364 /* AppModel+Gateway.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Gateway.swift"; sourceTree = ""; }; + 5C516D7E2A82E384871ADA77 /* TunnelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManager.swift; sourceTree = ""; }; + 5C883C3445CFB25BE95819B5 /* BotStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotStateModels.swift; sourceTree = ""; }; + 5E8A9E44693B2BF8DA450D85 /* StandaloneSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandaloneSetupView.swift; sourceTree = ""; }; + 694267133E5E659F12946B10 /* DiscordIdentityRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordIdentityRESTClient.swift; sourceTree = ""; }; + 6CD2301142C3495D5EE63034 /* IntelligenceGlowBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelligenceGlowBorder.swift; sourceTree = ""; }; + 6E828B8B55D1AEB575FE26E5 /* AppModel+VoicePresence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+VoicePresence.swift"; sourceTree = ""; }; + 6E857AB706540A5A69F2DEC0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 6EC99CA7ACBDFF1217B6525B /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; + 7059780457BD592C5364C934 /* RemoteControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteControlService.swift; sourceTree = ""; }; + 726607C6AA014293B0C7794D /* EmbedFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbedFormatter.swift; sourceTree = ""; }; + 74A94E05BFB8B12D551FBD96 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 750574541041BF7D66545F68 /* DiscordInteractionRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordInteractionRESTClient.swift; sourceTree = ""; }; + 788B16C5C9EE07B0145A0E82 /* UpdateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateItem.swift; sourceTree = ""; }; + 7A08A1E26A9A98169FEE2F3B /* PatchyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchyViewModel.swift; sourceTree = ""; }; + 7D70C83084571D4A0927A8AA /* CacheKeyBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheKeyBuilder.swift; sourceTree = ""; }; + 7FAF77E90DE081DDE95A80BF /* UpdateSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSource.swift; sourceTree = ""; }; + 7FD42789A025FD409C081463 /* AppModel+Patchy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Patchy.swift"; sourceTree = ""; }; + 818A90855EE5E88B16695E30 /* SwiftMeshSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftMeshSetupView.swift; sourceTree = ""; }; + 82CE247254BB7396D69F514B /* AdvancedPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPreferencesView.swift; sourceTree = ""; }; + 855F750BDCFF1773E57DF565 /* GatewayEventDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayEventDispatcher.swift; sourceTree = ""; }; + 8568A65A0821C78B3A7007CA /* Resources */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Resources; path = SwiftBotApp/Resources; sourceTree = SOURCE_ROOT; }; + 8828FF064E45A0519FB1640E /* ACMEClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACMEClient.swift; sourceTree = ""; }; + 8964B98159AC25452C4423DF /* SwiftBot.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = SwiftBot.icon; sourceTree = ""; }; + 8AC1974FBBE7F7282C86BCB3 /* ModeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeSelectionView.swift; sourceTree = ""; }; + 8D35375EF6E1EA424CC6BE3D /* CommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsView.swift; sourceTree = ""; }; + 8EC3AEA8E17C6B46C8E9A679 /* AppModel+AdminWeb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+AdminWeb.swift"; sourceTree = ""; }; + 8FC29827C8F3269A4F23E561 /* WebUIPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUIPreferencesView.swift; sourceTree = ""; }; + 91C41589B7BDFB426C5552BB /* LocalBotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalBotProvider.swift; sourceTree = ""; }; + 95A03317C59A5CE2BC3F067F /* RemoteSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSetupView.swift; sourceTree = ""; }; + 985814A00309DEBC6C7FD35F /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; + 99AD9D0BFA57E9EFB2E11322 /* MediaLibraryIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryIndexer.swift; sourceTree = ""; }; + 9A22C5009D34D5F98076D3B7 /* VoiceRuleStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRuleStateStore.swift; sourceTree = ""; }; + 9AA2D2805C055B0DE50F1453 /* AppModel+SlashCommandHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+SlashCommandHelpers.swift"; sourceTree = ""; }; + 9BBC0B8ABE120213D32A8F9F /* SteamService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamService.swift; sourceTree = ""; }; + 9C8B9FF78E7B196891AF7A7F /* WikiBridgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiBridgeView.swift; sourceTree = ""; }; + 9DA9BA187B549800BA8A345C /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 9EABA6DDB19D3312394B5D72 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A02B30EDD00CF95D428E4065 /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = ""; }; + A1F49DE5A766F83A139F9F20 /* AppUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdater.swift; sourceTree = ""; }; + A2402E98DBD749BAE72E6B33 /* BotSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotSettings.swift; sourceTree = ""; }; + A275B86FF1816EF5115B7E84 /* AppModel+AI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+AI.swift"; sourceTree = ""; }; + A40B1CD3E3726DBE1AA70765 /* AIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModels.swift; sourceTree = ""; }; + A53B8C017D812B376BF24D61 /* MeshPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPreferencesView.swift; sourceTree = ""; }; + A55E2CDD13174C72BDB79094 /* AppModelTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelTypes.swift; sourceTree = ""; }; + A902D75D3824989E0F053C6B /* HelpEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpEngine.swift; sourceTree = ""; }; + B0D3D1ABFC5B176C108B7B6E /* CertificateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateManager.swift; sourceTree = ""; }; + B696934C12E6410E1E192294 /* AppModel+WikiBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+WikiBridge.swift"; sourceTree = ""; }; + BE46A141E350EC70B74B7430 /* DiscordMessageRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordMessageRESTClient.swift; sourceTree = ""; }; + C07385233B714CF5897D32E5 /* CloudflareTunnelClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudflareTunnelClient.swift; sourceTree = ""; }; + C33D037B65C9DBFE3FECD644 /* RemoteModeRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteModeRootView.swift; sourceTree = ""; }; + C510CF282F7E3B9D9A55415A /* AppModel+CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+CommandProcessor.swift"; sourceTree = ""; }; + C5794D658F3B4D68836792B7 /* MediaExportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaExportCoordinator.swift; sourceTree = ""; }; + C706274351683F9F2B98EFC6 /* DiscordCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordCache.swift; sourceTree = ""; }; + C7962859CFEF723E67BD800C /* AppModel+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Media.swift"; sourceTree = ""; }; + C87A43A58BBA036A9646BCAC /* AMDService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AMDService.swift; sourceTree = ""; }; + CA5F49E09077B173A9D9C851 /* DiscordAIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordAIService.swift; sourceTree = ""; }; + D2627CB6D5226BCFBDB65D61 /* VoiceActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceActionsView.swift; sourceTree = ""; }; + D72EB30017C9118909755079 /* AppModel+BotLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+BotLifecycle.swift"; sourceTree = ""; }; + D7DC12FFA9EC621A265D2A0C /* GatewayModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayModels.swift; sourceTree = ""; }; + D9386EDF3092943C62265C4E /* ReleaseNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotes.swift; sourceTree = ""; }; + DA1FE5FBB68C0886CB6760F7 /* BuiltInUpdateSources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuiltInUpdateSources.swift; sourceTree = ""; }; + DB2912532FD0E1F2D6462C3B /* DiscordGuildRESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordGuildRESTClient.swift; sourceTree = ""; }; + DEDACC9B9F1633073834BE07 /* MediaThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaThumbnailCache.swift; sourceTree = ""; }; + E5A84E95FDE31E5E752C2E2F /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; + E5CEA798F4C6EE225D9FD1FE /* RuleEngineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleEngineModels.swift; sourceTree = ""; }; + E72DBCA95A64DF3580BEB869 /* OnboardingStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStyles.swift; sourceTree = ""; }; + F3A5EEE408384C33E6A68310 /* RuleExecutionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleExecutionService.swift; sourceTree = ""; }; + F6165A66E22A9D4F34E71A45 /* AppModel+DiscordEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+DiscordEvents.swift"; sourceTree = ""; }; + F6CA49D19240EC6B6DB7C08C /* EventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBus.swift; sourceTree = ""; }; + FB38D25572DE04143F91B8B2 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + FB947E7E83CAEC7FAECF7D09 /* BotDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotDataProvider.swift; sourceTree = ""; }; + FCC9C07D51639D96FE0038EA /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = ""; }; + FD22E0CFEADC14DEFF30FD53 /* SwiftBot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftBot.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 1F7A11C22D33445566778897 /* Frameworks */ = { + F293869C698841B60583C607 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1F7A11C22D33445566778899 /* Sparkle in Frameworks */, - 2A8C10001122334455667788 /* UpdateEngine in Frameworks */, - A1B2C3D40111223344556A01 /* X509 in Frameworks */, - A1B2C3D40111223344556A02 /* Crypto in Frameworks */, - A1B2C3D40111223344556A03 /* SwiftASN1 in Frameworks */, - A1B2C3D40111223344556A04 /* NIOCore in Frameworks */, - A1B2C3D40111223344556A05 /* NIOPosix in Frameworks */, - A1B2C3D40111223344556A06 /* NIOSSL in Frameworks */, + 40EEEB0FC47E27A70C5D25AF /* Sparkle in Frameworks */, + C36B35143B0B2A8DBD9AD6ED /* X509 in Frameworks */, + DC360305E4A3D50E81B2F576 /* Crypto in Frameworks */, + 26001C9AFAE18FF56EA4BCDD /* SwiftASN1 in Frameworks */, + 6685B7E80DABB0ACEE4BE071 /* NIOCore in Frameworks */, + BF9824A4094CDC175299AA63 /* NIOPosix in Frameworks */, + 9527171E8D1C224595FF1BF6 /* NIOSSL in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 23AB887A5919EB91B14F0046 = { + 00B77F31D8E3A1FCEE8DCAB0 /* SwiftBotApp */ = { isa = PBXGroup; children = ( - 5015DDB42F55851D00618C6D /* SwiftBot-Info.plist */, - 5015DDB02F554EF200618C6D /* SwiftBot.icon */, - B1576087F628D0A5598994F5 /* SwiftBotApp */, - 94552BCD00E80365B7E02046 /* Products */, - 50F7585F2F6294510052287C /* Recovered References */, + 2FBE2483DB8EF8B9175CCA8C /* Models */, + 89E4B71C538DFBEB2E0DF64F /* Security */, + E6833AE29324F7856596A38C /* Services */, + 307C4D83D7FFE2C98F8E99FE /* UpdateEngine */, + 4AB32D13A6D673323296304C /* AdminWebServer.swift */, + 82CE247254BB7396D69F514B /* AdvancedPreferencesView.swift */, + 553AAF747AF590A5486827AA /* AIBotsView.swift */, + 9DA9BA187B549800BA8A345C /* AppModel.swift */, + 8EC3AEA8E17C6B46C8E9A679 /* AppModel+AdminWeb.swift */, + A275B86FF1816EF5115B7E84 /* AppModel+AI.swift */, + D72EB30017C9118909755079 /* AppModel+BotLifecycle.swift */, + 0D7E1662299F69697912A63E /* AppModel+Cluster.swift */, + C510CF282F7E3B9D9A55415A /* AppModel+CommandProcessor.swift */, + 3E3EF8C962344F48B9C0BF73 /* AppModel+Commands.swift */, + F6165A66E22A9D4F34E71A45 /* AppModel+DiscordEvents.swift */, + 3ABB16BA645CAAD2D3A17C6F /* AppModel+DiscordParsers.swift */, + 5716A5F63D0211A0DA067364 /* AppModel+Gateway.swift */, + C7962859CFEF723E67BD800C /* AppModel+Media.swift */, + 7FD42789A025FD409C081463 /* AppModel+Patchy.swift */, + 9AA2D2805C055B0DE50F1453 /* AppModel+SlashCommandHelpers.swift */, + 6E828B8B55D1AEB575FE26E5 /* AppModel+VoicePresence.swift */, + B696934C12E6410E1E192294 /* AppModel+WikiBridge.swift */, + A55E2CDD13174C72BDB79094 /* AppModelTypes.swift */, + A1F49DE5A766F83A139F9F20 /* AppUpdater.swift */, + 4ECDF94EFD14E6193471D909 /* ClusterCoordinator.swift */, + 8D35375EF6E1EA424CC6BE3D /* CommandsView.swift */, + 065E8C11350B1294C20F9B3A /* CommonUI.swift */, + 1F3D691AFC6D78460AE015A8 /* DiagnosticsView.swift */, + 03DA7729D9CB3A88CF3619B8 /* DiscordPreferencesView.swift */, + 38BBDCEDB0E2FC18B5BD01F0 /* DiscordService.swift */, + 33D0CC3569144419991BD125 /* EmptyRuleOnboardingView.swift */, + 3595DECC8AF4632DC5E33E83 /* GeneralPreferencesView.swift */, + A902D75D3824989E0F053C6B /* HelpEngine.swift */, + 6CD2301142C3495D5EE63034 /* IntelligenceGlowBorder.swift */, + 6EC99CA7ACBDFF1217B6525B /* LogsView.swift */, + C5794D658F3B4D68836792B7 /* MediaExportCoordinator.swift */, + A53B8C017D812B376BF24D61 /* MeshPreferencesView.swift */, + 74A94E05BFB8B12D551FBD96 /* Models.swift */, + 8AC1974FBBE7F7282C86BCB3 /* ModeSelectionView.swift */, + 42D35BA34860F043C661478F /* OnboardingRootView.swift */, + E72DBCA95A64DF3580BEB869 /* OnboardingStyles.swift */, + 6E857AB706540A5A69F2DEC0 /* OnboardingView.swift */, + 985814A00309DEBC6C7FD35F /* OverviewView.swift */, + 44CA1B8D8A85A90468C0E9C3 /* PatchyView.swift */, + 7A08A1E26A9A98169FEE2F3B /* PatchyViewModel.swift */, + E5A84E95FDE31E5E752C2E2F /* Persistence.swift */, + 3A523236CFC7EE60B9E5F3AC /* PreferencesView.swift */, + C33D037B65C9DBFE3FECD644 /* RemoteModeRootView.swift */, + 95A03317C59A5CE2BC3F067F /* RemoteSetupView.swift */, + FB38D25572DE04143F91B8B2 /* RootView.swift */, + 2C156C1770937C834073B637 /* SchemaSettings.swift */, + 9EABA6DDB19D3312394B5D72 /* SettingsView.swift */, + 5E8A9E44693B2BF8DA450D85 /* StandaloneSetupView.swift */, + 5083D5348FCB914E6C796E2D /* SwiftBotApp.swift */, + 818A90855EE5E88B16695E30 /* SwiftMeshSetupView.swift */, + 4BD24A98DC57DF430081B255 /* SwiftMeshView.swift */, + 44F9E79347D751050F3DE039 /* UpdatesPreferencesView.swift */, + D2627CB6D5226BCFBDB65D61 /* VoiceActionsView.swift */, + 56C5EE8341F1833DB1BF48D6 /* VoiceRuleListView.swift */, + 8FC29827C8F3269A4F23E561 /* WebUIPreferencesView.swift */, + 9C8B9FF78E7B196891AF7A7F /* WikiBridgeView.swift */, + ); + path = SwiftBotApp; + sourceTree = ""; + }; + 0B81279A83B1440C3CEC4A11 /* SparklePublisher */ = { + isa = PBXGroup; + children = ( + 4C5788F384AF1C5CEC1A7137 /* main.swift */, + ); + name = SparklePublisher; + path = Tools/SparklePublisher; + sourceTree = ""; + }; + 2FBE2483DB8EF8B9175CCA8C /* Models */ = { + isa = PBXGroup; + children = ( + A40B1CD3E3726DBE1AA70765 /* AIModels.swift */, + A2402E98DBD749BAE72E6B33 /* BotSettings.swift */, + 5C883C3445CFB25BE95819B5 /* BotStateModels.swift */, + 322D9081CEE7191A61086D90 /* ClusterModels.swift */, + C706274351683F9F2B98EFC6 /* DiscordCache.swift */, + F6CA49D19240EC6B6DB7C08C /* EventBus.swift */, + D7DC12FFA9EC621A265D2A0C /* GatewayModels.swift */, + 46E1C9365566CEC38D0E39EB /* KeychainHelper.swift */, + 99AD9D0BFA57E9EFB2E11322 /* MediaLibraryIndexer.swift */, + DEDACC9B9F1633073834BE07 /* MediaThumbnailCache.swift */, + E5CEA798F4C6EE225D9FD1FE /* RuleEngineModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + 307C4D83D7FFE2C98F8E99FE /* UpdateEngine */ = { + isa = PBXGroup; + children = ( + C87A43A58BBA036A9646BCAC /* AMDService.swift */, + DA1FE5FBB68C0886CB6760F7 /* BuiltInUpdateSources.swift */, + 7D70C83084571D4A0927A8AA /* CacheKeyBuilder.swift */, + 726607C6AA014293B0C7794D /* EmbedFormatter.swift */, + 316BE5EE23ADB08FD4042070 /* IntelService.swift */, + 1C2C4E81E105509E3B80F76B /* NVIDIAService.swift */, + D9386EDF3092943C62265C4E /* ReleaseNotes.swift */, + 9BBC0B8ABE120213D32A8F9F /* SteamService.swift */, + A02B30EDD00CF95D428E4065 /* UpdateChecker.swift */, + 788B16C5C9EE07B0145A0E82 /* UpdateItem.swift */, + 7FAF77E90DE081DDE95A80BF /* UpdateSource.swift */, + 346C818F5054314C6BF97DC5 /* VersionStore.swift */, + ); + path = UpdateEngine; + sourceTree = ""; + }; + 5862010FC4698115F0427E7E = { + isa = PBXGroup; + children = ( + 0B81279A83B1440C3CEC4A11 /* SparklePublisher */, + 00B77F31D8E3A1FCEE8DCAB0 /* SwiftBotApp */, + 8568A65A0821C78B3A7007CA /* Resources */, + 8964B98159AC25452C4423DF /* SwiftBot.icon */, + A63029345405694029040AF9 /* Products */, ); sourceTree = ""; }; - 50F7585F2F6294510052287C /* Recovered References */ = { + 89E4B71C538DFBEB2E0DF64F /* Security */ = { isa = PBXGroup; children = ( - EA77BCA23D894E4E98F5B874 /* SwiftBotApp/OnboardingRootView.swift */, - 142D6839427040358B4FBA90 /* SwiftBotApp/ModeSelectionView.swift */, - DE30A3D04296486A87AFEC73 /* SwiftBotApp/StandaloneSetupView.swift */, - 2338A77A835B45B3963A71D7 /* SwiftBotApp/SwiftMeshSetupView.swift */, - 199651172D924276B1B7FA3B /* SwiftBotApp/RemoteSetupView.swift */, - F2206311680E46EFA928E726 /* SwiftBotApp/OnboardingStyles.swift */, + 8828FF064E45A0519FB1640E /* ACMEClient.swift */, + B0D3D1ABFC5B176C108B7B6E /* CertificateManager.swift */, + 1C5365D3471C752240ADA7D5 /* CloudflareDNSProvider.swift */, + C07385233B714CF5897D32E5 /* CloudflareTunnelClient.swift */, + 5C516D7E2A82E384871ADA77 /* TunnelManager.swift */, ); - name = "Recovered References"; + path = Security; sourceTree = ""; }; - 94552BCD00E80365B7E02046 /* Products */ = { + A63029345405694029040AF9 /* Products */ = { isa = PBXGroup; children = ( - 44A31413B04848EB75D50EFA /* SwiftBot.app */, + 253ED8DD86B265E94F6ABDE2 /* SparklePublisher */, + FD22E0CFEADC14DEFF30FD53 /* SwiftBot.app */, ); name = Products; sourceTree = ""; }; - B1576087F628D0A5598994F5 /* SwiftBotApp */ = { + E6833AE29324F7856596A38C /* Services */ = { isa = PBXGroup; children = ( - 6337960E98AEBC0A19A67531 /* AppModel.swift */, - 11C22D661122334455667788 /* AdminWebServer.swift */, - A2B3C4D5E6F7080911223344 /* MediaExportCoordinator.swift */, - 11D1A1B2C3D4E5F607182931 /* Services/CommandProcessor.swift */, - 11D1A1B2C3D4E5F607182933 /* Services/DiscordAIService.swift */, - 11D1A1B2C3D4E5F607182935 /* Services/DiscordGatewayConnection.swift */, - 11D1A1B2C3D4E5F607182937 /* Services/DiscordGuildRESTClient.swift */, - 11D1A1B2C3D4E5F607182939 /* Services/DiscordIdentityRESTClient.swift */, - 11D1A1B2C3D4E5F60718293B /* Services/DiscordInteractionRESTClient.swift */, - 11D1A1B2C3D4E5F60718293D /* Services/DiscordMessageRESTClient.swift */, - 11D1A1B2C3D4E5F60718293F /* Services/GatewayEventDispatcher.swift */, - 11D1A1B2C3D4E5F607182941 /* Services/RuleExecutionService.swift */, - 11D1A1B2C3D4E5F607182943 /* Services/VoicePresenceStore.swift */, - 11D1A1B2C3D4E5F607182945 /* Services/VoiceRuleStateStore.swift */, - 11D1A1B2C3D4E5F607182947 /* Services/WikiLookupService.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 */, - A1B2C3D40111223344556704 /* Security/CloudflareTunnelClient.swift */, - A1B2C3D40111223344556705 /* Security/TunnelManager.swift */, - 2D93A1C3B5E6F708192A3B4C /* AppModel+SlashCommandHelpers.swift */, - 2E1AB44BA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift */, - 6F1B40D0A2B3C4D5E6F70819 /* AppModel+Gateway.swift */, - 6F1B40D2A2B3C4D5E6F70819 /* AppModel+Commands.swift */, - 6F1B40D4A2B3C4D5E6F70819 /* AppModel+AI.swift */, - 6F1B40D6A2B3C4D5E6F70819 /* AppModel+CommandProcessor.swift */, - 6F1B40D8A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift */, - 7E2C94CD671B4FF1B53E0E01 /* AppModel+Media.swift */, - 0360C1B274834BFFA134D842 /* AppModel+BotLifecycle.swift */, - 85615EB8C640459B8480BE2E /* AppModel+AdminWeb.swift */, - 2FB770B7A11D22E33F44C550 /* AppModelTypes.swift */, - 8D8E9F101122334455667788 /* AppUpdater.swift */, - 8E8D7C6B5A4F3E2D1C0B9A89 /* HelpEngine.swift */, - 11C22D441122334455667788 /* ClusterCoordinator.swift */, - A7B811001122334455667788 /* PatchyView.swift */, - A7B813001122334455667788 /* PatchyViewModel.swift */, - EA30CC27A218AF533E2E4C0E /* SwiftBotApp.swift */, - 0B3205CAA1A44F7E79578277 /* DiscordService.swift */, - 010969C7B6435248430DD012 /* Models.swift */, - 27EFCFFB1B1A45FABEAE7686 /* Models/AIModels.swift */, - B7436E2097EB456B85B3138E /* Models/BotSettings.swift */, - 7D44391E12144C8A9215A079 /* Models/BotStateModels.swift */, - 0870D97E081745428605B82E /* Models/ClusterModels.swift */, - AFA0F326F22A4CF2817AA2AB /* Models/DiscordCache.swift */, - 8F2038AEE60943658748C2A8 /* Models/EventBus.swift */, - 50331CC0992C4A59BFC07663 /* Models/GatewayModels.swift */, - 7DEA97F20B664678BC69DB03 /* Models/KeychainHelper.swift */, - ACD8A97AEC9B4F4AAAEF2C7B /* Models/RuleEngineModels.swift */, - F9564766EDA4C18D06A84BCB /* Persistence.swift */, - B4F6C2021122334455667788 /* SchemaSettings.swift */, - C3D4E5F70112233445566778 /* SwiftMeshView.swift */, - D1A2B3C50102030405060708 /* DiagnosticsView.swift */, - D6E7F8091A2B3C4D5E6F7081 /* IntelligenceGlowBorder.swift */, - E4C1A9010102030405060708 /* WikiBridgeView.swift */, - 5B2FA001B2C3D4E5F6071900 /* EmptyRuleOnboardingView.swift */, - 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */, - 0A6B7D201122334455667788 /* Resources */, - 35480F12EB2C7DFB546BD550 /* RootView.swift */, - B01010101010101010101001 /* RemoteModeRootView.swift */, - C1D2E3F4A5B6C7D8E9F00111 /* OnboardingView.swift */, - D2E3F4A5B6C7D8E9F0011223 /* OverviewView.swift */, - F4A5B6C7D8E9F001122334A5 /* VoiceActionsView.swift */, - A1B2C3D4E5F60708001122A3 /* CommandsView.swift */, - C3D4E5F6070800112233445C /* LogsView.swift */, - E5F607080011223344556677 /* AIBotsView.swift */, - 07080011223344556677AABB /* SettingsView.swift */, - 0011223344556677AABBCCDD /* CommonUI.swift */, - AA2000011122334455667701 /* PreferencesView.swift */, - AA2000011122334455667702 /* GeneralPreferencesView.swift */, - AA2000011122334455667703 /* DiscordPreferencesView.swift */, - AA2000011122334455667704 /* MeshPreferencesView.swift */, - AA2000011122334455667705 /* WebUIPreferencesView.swift */, - AA2000011122334455667706 /* UpdatesPreferencesView.swift */, - AA2000011122334455667707 /* AdvancedPreferencesView.swift */, + FB947E7E83CAEC7FAECF7D09 /* BotDataProvider.swift */, + FCC9C07D51639D96FE0038EA /* CommandProcessor.swift */, + CA5F49E09077B173A9D9C851 /* DiscordAIService.swift */, + 460C93A6D305EACF834AA7E4 /* DiscordGatewayConnection.swift */, + DB2912532FD0E1F2D6462C3B /* DiscordGuildRESTClient.swift */, + 694267133E5E659F12946B10 /* DiscordIdentityRESTClient.swift */, + 750574541041BF7D66545F68 /* DiscordInteractionRESTClient.swift */, + BE46A141E350EC70B74B7430 /* DiscordMessageRESTClient.swift */, + 855F750BDCFF1773E57DF565 /* GatewayEventDispatcher.swift */, + 91C41589B7BDFB426C5552BB /* LocalBotProvider.swift */, + 44F5F3F20E9067B456E84EB6 /* RemoteAPI.swift */, + 031F4D5F167A067542123F0C /* RemoteBotProvider.swift */, + 7059780457BD592C5364C934 /* RemoteControlService.swift */, + 24F8451B9C919529C86CD289 /* RemoteModels.swift */, + F3A5EEE408384C33E6A68310 /* RuleExecutionService.swift */, + 2F319382F183BB1612580B30 /* VoicePresenceStore.swift */, + 9A22C5009D34D5F98076D3B7 /* VoiceRuleStateStore.swift */, + 40BC9ACDFB65AED3E2743A4D /* WikiLookupService.swift */, ); - path = SwiftBotApp; + path = Services; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 38CB9D1B9F4C791831BD4560 /* SwiftBot */ = { + 0D7DDDF6C0A646EE75388F7A /* SparklePublisher */ = { isa = PBXNativeTarget; - buildConfigurationList = 54E3BB8F01147895EA783B3E /* Build configuration list for PBXNativeTarget "SwiftBot" */; + buildConfigurationList = C7B407BF6C5684434BD1EC2E /* Build configuration list for PBXNativeTarget "SparklePublisher" */; buildPhases = ( - 1F7A11C22D33445566778897 /* Frameworks */, - 0A6B7D301122334455667788 /* Resources */, - E7C6ACB1EED7DC017665578C /* Sources */, + 636A1D79BE93FB259BDDCC12 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SparklePublisher; + packageProductDependencies = ( + ); + productName = SparklePublisher; + productReference = 253ED8DD86B265E94F6ABDE2 /* SparklePublisher */; + productType = "com.apple.product-type.tool"; + }; + 4643F3EE1229139DEE939C6D /* SwiftBot */ = { + isa = PBXNativeTarget; + buildConfigurationList = 600BF58B2C21E331EF3C6827 /* Build configuration list for PBXNativeTarget "SwiftBot" */; + buildPhases = ( + EDC393728F949C02F79D0365 /* SwiftLint */, + 5368ECB2922C2BD7F185C8C3 /* Sources */, + 868F2384A7DC075A8C4D7176 /* Resources */, + F293869C698841B60583C607 /* Frameworks */, ); buildRules = ( ); @@ -351,236 +457,304 @@ ); name = SwiftBot; packageProductDependencies = ( - 1F7A11C22D33445566778898 /* Sparkle */, - 2A8C10101122334455667788 /* UpdateEngine */, - A1B2C3D40111223344556901 /* X509 */, - A1B2C3D40111223344556902 /* Crypto */, - A1B2C3D40111223344556903 /* SwiftASN1 */, - A1B2C3D40111223344556904 /* NIOCore */, - A1B2C3D40111223344556905 /* NIOPosix */, - A1B2C3D40111223344556906 /* NIOSSL */, + 7C341BA99920552C7CDA9D24 /* Sparkle */, + 81D226FCC037CE475B1850B7 /* X509 */, + 1840331BC8F2A1886D5014FF /* Crypto */, + 6BEFF4EF2D66A493DAF66DDA /* SwiftASN1 */, + 5AEB32046AFF5E8525A5B458 /* NIOCore */, + 7DF22120B86D527EE31A4E83 /* NIOPosix */, + 1C81108D7CF200AA2750ED17 /* NIOSSL */, ); productName = SwiftBot; - productReference = 44A31413B04848EB75D50EFA /* SwiftBot.app */; + productReference = FD22E0CFEADC14DEFF30FD53 /* SwiftBot.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 3BD77BC016D6E32A8E693A59 /* Project object */ = { + C0066AD18713AFCF7B5EAC62 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2630; + LastUpgradeCheck = 1630; + TargetAttributes = { + 4643F3EE1229139DEE939C6D = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + }; }; - buildConfigurationList = 81CA7B1A81AF2F66208AC01D /* Build configuration list for PBXProject "SwiftBot" */; - compatibilityVersion = "Xcode 14.0"; + buildConfigurationList = 1FC61DD7AA6DAC1A0330856E /* Build configuration list for PBXProject "SwiftBot" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( Base, en, ); - mainGroup = 23AB887A5919EB91B14F0046; + mainGroup = 5862010FC4698115F0427E7E; minimizedProjectReferenceProxies = 1; packageReferences = ( - 1F7A11C22D33445566778896 /* XCRemoteSwiftPackageReference "Sparkle" */, - 2A8C10201122334455667788 /* XCLocalSwiftPackageReference "Sources/UpdateEngine" */, - A1B2C3D40111223344556801 /* XCRemoteSwiftPackageReference "swift-certificates" */, - A1B2C3D40111223344556802 /* XCRemoteSwiftPackageReference "swift-crypto" */, - A1B2C3D40111223344556803 /* XCRemoteSwiftPackageReference "swift-asn1" */, - A1B2C3D40111223344556804 /* XCRemoteSwiftPackageReference "swift-nio" */, - A1B2C3D40111223344556805 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */, + D8C92210CC9DF49CDCA360CC /* XCRemoteSwiftPackageReference "Sparkle" */, + 7F2B5C746E1B0FDC54E15204 /* XCRemoteSwiftPackageReference "swift-asn1" */, + 3DBCADE37280FF5C19CB8C41 /* XCRemoteSwiftPackageReference "swift-certificates" */, + 8CD70ED74937A5A681408390 /* XCRemoteSwiftPackageReference "swift-crypto" */, + 53F525F39820A67BBF1FBBAB /* XCRemoteSwiftPackageReference "swift-nio" */, + 084E20246CEB4E4FE5B8CFC0 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */, ); + preferredProjectObjectVersion = 77; + productRefGroup = A63029345405694029040AF9 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 38CB9D1B9F4C791831BD4560 /* SwiftBot */, + 0D7DDDF6C0A646EE75388F7A /* SparklePublisher */, + 4643F3EE1229139DEE939C6D /* SwiftBot */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 0A6B7D301122334455667788 /* Resources */ = { + 868F2384A7DC075A8C4D7176 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9A8B7C601122334455667788 /* SwiftBot.icon in Resources */, - 0A6B7D101122334455667788 /* Resources in Resources */, + FB161956DA013471E5BF80FF /* Resources in Resources */, + EAEDC19B03FED30AC99ECBFB /* SwiftBot.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + EDC393728F949C02F79D0365 /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint > /dev/null; then swiftlint; fi"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ - E7C6ACB1EED7DC017665578C /* Sources */ = { + 5368ECB2922C2BD7F185C8C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F8B5C3B9C16141D761050DD6 /* ACMEClient.swift in Sources */, + F88E9E996F631AB24753F151 /* AIBotsView.swift in Sources */, + 5799D01A37C0C70F209DEE50 /* AIModels.swift in Sources */, + 9C5DCB5C183F3BCFF236095E /* AMDService.swift in Sources */, + 7A065730CD545F87AF004893 /* AdminWebServer.swift in Sources */, + 7A8F1F65E34BD4BFFE7BCAA9 /* AdvancedPreferencesView.swift in Sources */, + F18225F9C4A1405093180E0D /* AppModel+AI.swift in Sources */, + 16BAB36AC9A670AE9B8375EE /* AppModel+AdminWeb.swift in Sources */, + 12E4267A46ED7C52DDBF535B /* AppModel+BotLifecycle.swift in Sources */, + 15272B7A21973431AB9E8263 /* AppModel+Cluster.swift in Sources */, + 35714FE14D2FFB4A4A8B34B6 /* AppModel+CommandProcessor.swift in Sources */, + 566EE7D8A59E9C9890359374 /* AppModel+Commands.swift in Sources */, + 3C569F8CE14C8C41CA1C6246 /* AppModel+DiscordEvents.swift in Sources */, + 7071FF33C237742C35AB7082 /* AppModel+DiscordParsers.swift in Sources */, + 7894B179A8B556313CC7492A /* AppModel+Gateway.swift in Sources */, + 9AC1DDF0CBE7C89586CB079D /* AppModel+Media.swift in Sources */, + 61B7BFCD4B707D6306631F7A /* AppModel+Patchy.swift in Sources */, + 7B1416030084F25AC03D0B84 /* AppModel+SlashCommandHelpers.swift in Sources */, + 8830FFC6D53A953574FE99AF /* AppModel+VoicePresence.swift in Sources */, + 5F948EAA0D02EA068B33BC39 /* AppModel+WikiBridge.swift in Sources */, + DC03C1EA8B4FEBC1E2BE90A7 /* AppModel.swift in Sources */, + 3A84CA8872264B04238C0992 /* AppModelTypes.swift in Sources */, + 63424338C247B5ECB87A8504 /* AppUpdater.swift in Sources */, + 10688115A610483DA3FBB1A5 /* BotDataProvider.swift in Sources */, + A0FD425E7CB10D15C1B2F630 /* BotSettings.swift in Sources */, + AB311B611EAF20CED07A34FD /* BotStateModels.swift in Sources */, + 93A851A6FD066BDF9D74AFAD /* BuiltInUpdateSources.swift in Sources */, + 4EB0A5E6CC96AD52F65A20C0 /* CacheKeyBuilder.swift in Sources */, + 7132DFC658401903B85DC61D /* CertificateManager.swift in Sources */, + 6B8F2B6980D8C3FA13D9CAC3 /* CloudflareDNSProvider.swift in Sources */, + 65F8E1C9E4E603B4E420DCE9 /* CloudflareTunnelClient.swift in Sources */, + 58E683E4B09BA7F0510C4D2B /* ClusterCoordinator.swift in Sources */, + C73139C0B46A9ACEEF9C5177 /* ClusterModels.swift in Sources */, + 9D8268E43E33B6ADEDDCFD7E /* CommandProcessor.swift in Sources */, + 91FF3FB5CA902BC308D4B6F2 /* CommandsView.swift in Sources */, + 8B5F8E571AC8FB3BCD79EC99 /* CommonUI.swift in Sources */, + 2D75FD3C65BC54A6F1EEA2AA /* DiagnosticsView.swift in Sources */, + 5CA7B51148541E5E468552C3 /* DiscordAIService.swift in Sources */, + 8B909CD9FA439A5575ED2EAF /* DiscordCache.swift in Sources */, + B0ECA01493988DD4BBE0840D /* DiscordGatewayConnection.swift in Sources */, + 52D688DC1DDA87CD00D7A749 /* DiscordGuildRESTClient.swift in Sources */, + 9A07952399C3531240BD87FB /* DiscordIdentityRESTClient.swift in Sources */, + 6CF94F13C717C0AB955A7215 /* DiscordInteractionRESTClient.swift in Sources */, + 27EBD03F4CDFA5B4F8A76F38 /* DiscordMessageRESTClient.swift in Sources */, + 5D25AD1C212F4CC9B5C90DD5 /* DiscordPreferencesView.swift in Sources */, + 0DE92D9DBC280ACE537C2209 /* DiscordService.swift in Sources */, + EBF4ED30124502DB72B90F79 /* EmbedFormatter.swift in Sources */, + F2CD5289AC48F48DAD2E96CD /* EmptyRuleOnboardingView.swift in Sources */, + C0A3E25F664C72E666E3D3F2 /* EventBus.swift in Sources */, + 5343E350CBC68B28BDD894C4 /* GatewayEventDispatcher.swift in Sources */, + 382736874A4ABFC372B65199 /* GatewayModels.swift in Sources */, + 00F2D700D7DA38BA9049A986 /* GeneralPreferencesView.swift in Sources */, + 32EF85D74938EFC99D68448B /* HelpEngine.swift in Sources */, + 403C97BE65696647F37FFD89 /* IntelService.swift in Sources */, + 3E3F9681C479C7D5E52D9348 /* IntelligenceGlowBorder.swift in Sources */, + 440F6A5F62FD1B7714FD1A2E /* KeychainHelper.swift in Sources */, + CB267C81149E545799E3E0E4 /* LocalBotProvider.swift in Sources */, + F3AC4F30295DAC412A25E885 /* LogsView.swift in Sources */, + 3F177F30A59216E08AA7FC23 /* MediaExportCoordinator.swift in Sources */, + 2C30A3ECEFEBB1D412442557 /* MediaLibraryIndexer.swift in Sources */, + 6E8FB330BD0BE5ADFCA0FC6B /* MediaThumbnailCache.swift in Sources */, + 5C11B6D31A6225BC9D878539 /* MeshPreferencesView.swift in Sources */, + 8754C1BE68873E719DB6E909 /* ModeSelectionView.swift in Sources */, + 4440417E51C60D48DDE07270 /* Models.swift in Sources */, + DB9E21A6521C07EDC9461565 /* NVIDIAService.swift in Sources */, + 1E0F9DE242BF278D48AB932D /* OnboardingRootView.swift in Sources */, + 37247A9D640138A5CBF8F161 /* OnboardingStyles.swift in Sources */, + C2531026993F1B1C6B25F226 /* OnboardingView.swift in Sources */, + 61FE77A534CAE8A1DB129DD6 /* OverviewView.swift in Sources */, + 2F717839189B088E0B4DA26C /* PatchyView.swift in Sources */, + 085D20923E60B1599D77A36D /* PatchyViewModel.swift in Sources */, + 21F0F51F5739E8BA2E0E6978 /* Persistence.swift in Sources */, + 0F8391EF95A66EB768F81BA8 /* PreferencesView.swift in Sources */, + C73223597C2FE00574A04193 /* ReleaseNotes.swift in Sources */, + 6923C1DFC02F72C9F6A307AC /* RemoteAPI.swift in Sources */, + D78CB841C27860D437A41E36 /* RemoteBotProvider.swift in Sources */, + 795E1B910968188CC57BA263 /* RemoteControlService.swift in Sources */, + FD624ED4D70CFAF9D943F0B5 /* RemoteModeRootView.swift in Sources */, + DEEBB36AAE69806B0DE44641 /* RemoteModels.swift in Sources */, + 3427FDD6B8F160DBA04F79E1 /* RemoteSetupView.swift in Sources */, + 8764933B1A2D5429FEC2FAA5 /* RootView.swift in Sources */, + FDA57E1FBBE210E213A9292B /* RuleEngineModels.swift in Sources */, + CB1B41CE58ED74524B73FAA5 /* RuleExecutionService.swift in Sources */, + DEED6A6AE95EB1E4BD68B09F /* SchemaSettings.swift in Sources */, + 4816D705CD69FA14D1641610 /* SettingsView.swift in Sources */, + 8FDF1380E0C0369F9570C5D4 /* StandaloneSetupView.swift in Sources */, + 9C70C57B9C4A44DC95FB0C56 /* SteamService.swift in Sources */, + B6B3280ACF4438511C36A188 /* SwiftBotApp.swift in Sources */, + F266903914FB55C141F347D5 /* SwiftMeshSetupView.swift in Sources */, + 22701BDB9191BD43BFA13BD7 /* SwiftMeshView.swift in Sources */, + 13EC6A8C3526F1B617A9C146 /* TunnelManager.swift in Sources */, + AC0B6725D3ABBB12498538EF /* UpdateChecker.swift in Sources */, + B0787CF87C490A51456F573B /* UpdateItem.swift in Sources */, + 647A7728561387E5DFC85FD3 /* UpdateSource.swift in Sources */, + 0005740627E44E41BFB0B69B /* UpdatesPreferencesView.swift in Sources */, + BD731E0515669EE345148385 /* VersionStore.swift in Sources */, + C1AFBA55AC265D22F80521D2 /* VoiceActionsView.swift in Sources */, + ACE122B57907AC0B3FBD616F /* VoicePresenceStore.swift in Sources */, + A5CBEE32EC1247802A9F689E /* VoiceRuleListView.swift in Sources */, + 6D555B0AE68DA044A57A4093 /* VoiceRuleStateStore.swift in Sources */, + 889466FAF399CB465ED1C45B /* WebUIPreferencesView.swift in Sources */, + 831BDE04FBF99D4CD415BD88 /* WikiBridgeView.swift in Sources */, + 126B88FAE40D80657EB4FE78 /* WikiLookupService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 636A1D79BE93FB259BDDCC12 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5AEC748EA501439CBE3442FC /* SwiftBotApp/OnboardingRootView.swift in Sources */, - 3B969F2C6A7C429B9E9392E0 /* SwiftBotApp/ModeSelectionView.swift in Sources */, - 45BDC95CDE8D4005A5A70F85 /* SwiftBotApp/StandaloneSetupView.swift in Sources */, - EBEDA37ACA12477FB8096E6D /* SwiftBotApp/SwiftMeshSetupView.swift in Sources */, - 49AEED7B963B4B6F842F7A10 /* SwiftBotApp/RemoteSetupView.swift in Sources */, - 222494946C4E49E09016F964 /* SwiftBotApp/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 */, - A1B2C3D40111223344556603 /* Security/CloudflareDNSProvider.swift in Sources */, - A1B2C3D40111223344556604 /* Security/CloudflareTunnelClient.swift in Sources */, - A1B2C3D40111223344556605 /* Security/TunnelManager.swift in Sources */, - 2D93A1C4B5E6F708192A3B4C /* AppModel+SlashCommandHelpers.swift in Sources */, - 2E1AB44CA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift in Sources */, - 6F1B40D1A2B3C4D5E6F70819 /* AppModel+Gateway.swift in Sources */, - 6F1B40D3A2B3C4D5E6F70819 /* AppModel+Commands.swift in Sources */, - 6F1B40D5A2B3C4D5E6F70819 /* AppModel+AI.swift in Sources */, - 6F1B40D7A2B3C4D5E6F70819 /* AppModel+CommandProcessor.swift in Sources */, - 6F1B40D9A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift in Sources */, - AB32B2AEE754434283311E56 /* AppModel+Media.swift in Sources */, - 6E00FB54496F46AEA8E4E3CE /* AppModel+BotLifecycle.swift in Sources */, - A71F38FA5B6D492D96110EE5 /* AppModel+AdminWeb.swift in Sources */, - 2FB770B8A11D22E33F44C550 /* AppModelTypes.swift in Sources */, - A2B3C4D5E6F7080911223345 /* MediaExportCoordinator.swift in Sources */, - 11D1A1B2C3D4E5F607182930 /* Services/CommandProcessor.swift in Sources */, - 11D1A1B2C3D4E5F607182932 /* Services/DiscordAIService.swift in Sources */, - 11D1A1B2C3D4E5F607182934 /* Services/DiscordGatewayConnection.swift in Sources */, - 11D1A1B2C3D4E5F607182936 /* Services/DiscordGuildRESTClient.swift in Sources */, - 11D1A1B2C3D4E5F607182938 /* Services/DiscordIdentityRESTClient.swift in Sources */, - 11D1A1B2C3D4E5F60718293A /* Services/DiscordInteractionRESTClient.swift in Sources */, - 11D1A1B2C3D4E5F60718293C /* Services/DiscordMessageRESTClient.swift in Sources */, - 11D1A1B2C3D4E5F60718293E /* Services/GatewayEventDispatcher.swift in Sources */, - 11D1A1B2C3D4E5F607182940 /* Services/RuleExecutionService.swift in Sources */, - 11D1A1B2C3D4E5F607182942 /* Services/VoicePresenceStore.swift in Sources */, - 11D1A1B2C3D4E5F607182944 /* Services/VoiceRuleStateStore.swift in Sources */, - 11D1A1B2C3D4E5F607182946 /* Services/WikiLookupService.swift in Sources */, - 8D8E9F001122334455667788 /* AppUpdater.swift in Sources */, - 8E8D7C6B5A4F3E2D1C0B9A88 /* HelpEngine.swift in Sources */, - A7B810001122334455667788 /* PatchyView.swift in Sources */, - A7B812001122334455667788 /* PatchyViewModel.swift in Sources */, - 11C22D331122334455667788 /* ClusterCoordinator.swift in Sources */, - 20382A9EF51DD3FD3E6D9FA2 /* SwiftBotApp.swift in Sources */, - 73BAC11337B101CC5C7AFCD2 /* DiscordService.swift in Sources */, - 75F7879D2B8A080849E4D4A2 /* Models.swift in Sources */, - 5125A5586D3B4765960059A3 /* Models/AIModels.swift in Sources */, - 346EE31F9E1D4CB28BCE37A8 /* Models/BotSettings.swift in Sources */, - 77516EDB305B452A9063B036 /* Models/BotStateModels.swift in Sources */, - 4B164E69FD8746C58CA0E842 /* Models/ClusterModels.swift in Sources */, - 829DC28FA2E7429B93795C74 /* Models/DiscordCache.swift in Sources */, - 212CE68E56C348E2B16F8E20 /* Models/EventBus.swift in Sources */, - F6B4A085F1EF4FC8B45EA1A5 /* Models/GatewayModels.swift in Sources */, - 4310D51BEC4040248D9F8E66 /* Models/KeychainHelper.swift in Sources */, - 08DCC0CBE7324CA3B5253825 /* Models/RuleEngineModels.swift in Sources */, - F2BE0FA6AB43AF1AB21CD5D7 /* Persistence.swift in Sources */, - 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 */, - F607080011223344556677AD /* AIBotsView.swift in Sources */, - 080011223344556677AABBCE /* SettingsView.swift in Sources */, - 11223344556677AABBCCDDFF /* CommonUI.swift in Sources */, - AA1000011122334455667701 /* PreferencesView.swift in Sources */, - AA1000011122334455667702 /* GeneralPreferencesView.swift in Sources */, - AA1000011122334455667703 /* DiscordPreferencesView.swift in Sources */, - AA1000011122334455667704 /* MeshPreferencesView.swift in Sources */, - AA1000011122334455667705 /* WebUIPreferencesView.swift in Sources */, - AA1000011122334455667706 /* UpdatesPreferencesView.swift in Sources */, - AA1000011122334455667707 /* AdvancedPreferencesView.swift in Sources */, - 5B2FA002B2C3D4E5F6071901 /* EmptyRuleOnboardingView.swift in Sources */, - 5B1E9801A1B2C3D4E5F60718 /* VoiceRuleListView.swift in Sources */, - D1A2B3C40102030405060708 /* DiagnosticsView.swift in Sources */, - E4C1A9000102030405060708 /* WikiBridgeView.swift in Sources */, + 1D30EB61BE0A3E76319F3871 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ - 19DDEE98357561AC9EA8526F /* Debug */ = { + 357DF8D0FCB875B119AC18D3 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 196000; DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SwiftBot-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = SwiftBot; + INFOPLIST_KEY_CFBundleName = SwiftBot; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_LSMinimumSystemVersion = 26.0; + INFOPLIST_KEY_NSHighResolutionCapable = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SwiftBot needs local network access to reach other leader/worker SwiftBots"; + INFOPLIST_KEY_SUEnableAutomaticChecks = YES; + INFOPLIST_KEY_SUFeedURL = "https://johnwatso.github.io/SwiftBot/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = "rxaJsfCpTKtpqRubSfkJwKnztT5S8RHsdAueuT+jKck="; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "DEBUG=1", + "@executable_path/../Frameworks", ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.john.swiftbot; - PRODUCT_NAME = "$(TARGET_NAME)"; + MARKETING_VERSION = 1.9.6; + PRODUCT_BUNDLE_IDENTIFIER = com.example.swiftbot; + PRODUCT_NAME = SwiftBot; + PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.9; }; name = Debug; }; - 293E5BAB59887771C0591595 /* Release */ = { + 4249029B0E9B3339C4E84513 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.swiftbot.sparklepublisher; + PRODUCT_NAME = SparklePublisher; + SDKROOT = macosx; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + A2A3F35BA4A6C9C688D09CC6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.swiftbot.sparklepublisher; + PRODUCT_NAME = SparklePublisher; + SDKROOT = macosx; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + C453F006AB578F6E38216F4A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -611,11 +785,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -627,23 +799,20 @@ MACOSX_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.john.swiftbot; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.9; + SWIFT_VERSION = 5.0; }; name = Release; }; - 814B5B590EA2D69F135BB12A /* Debug */ = { + F7397A1EB1237FED3772A19C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = SwiftBot; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; AUTOMATION_APPLE_EVENTS = NO; - CODE_SIGNING_ALLOWED = NO; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 196000; @@ -656,6 +825,7 @@ ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SwiftBot-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = SwiftBot; @@ -683,181 +853,194 @@ RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_VERSION = 5.9; }; - name = Debug; + name = Release; }; - C6AE148F24F0271FED0E29FE /* Release */ = { + FE95460722C79A6DFD94B6D7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = SwiftBot; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - AUTOMATION_APPLE_EVENTS = NO; - CODE_SIGNING_ALLOWED = YES; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 196000; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "SwiftBot-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = SwiftBot; - INFOPLIST_KEY_CFBundleName = SwiftBot; - INFOPLIST_KEY_LSApplicationCategoryType = ""; - INFOPLIST_KEY_LSMinimumSystemVersion = 26.0; - INFOPLIST_KEY_NSHighResolutionCapable = YES; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SwiftBot needs local network access to reach other leader/worker SwiftBots"; - INFOPLIST_KEY_SUEnableAutomaticChecks = YES; - INFOPLIST_KEY_SUFeedURL = "https://johnwatso.github.io/SwiftBot/appcast.xml"; - INFOPLIST_KEY_SUPublicEDKey = "rxaJsfCpTKtpqRubSfkJwKnztT5S8RHsdAueuT+jKck="; - LD_RUNPATH_SEARCH_PATHS = ( + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "@executable_path/../Frameworks", + "DEBUG=1", ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.9.6; - PRODUCT_BUNDLE_IDENTIFIER = com.example.swiftbot; - PRODUCT_NAME = SwiftBot; - PROVISIONING_PROFILE_SPECIFIER = ""; - RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = NO; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; - RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; - RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; - RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; }; - name = Release; + name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 54E3BB8F01147895EA783B3E /* Build configuration list for PBXNativeTarget "SwiftBot" */ = { + 1FC61DD7AA6DAC1A0330856E /* Build configuration list for PBXProject "SwiftBot" */ = { isa = XCConfigurationList; buildConfigurations = ( - 814B5B590EA2D69F135BB12A /* Debug */, - C6AE148F24F0271FED0E29FE /* Release */, + FE95460722C79A6DFD94B6D7 /* Debug */, + C453F006AB578F6E38216F4A /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 81CA7B1A81AF2F66208AC01D /* Build configuration list for PBXProject "SwiftBot" */ = { + 600BF58B2C21E331EF3C6827 /* Build configuration list for PBXNativeTarget "SwiftBot" */ = { isa = XCConfigurationList; buildConfigurations = ( - 19DDEE98357561AC9EA8526F /* Debug */, - 293E5BAB59887771C0591595 /* Release */, + 357DF8D0FCB875B119AC18D3 /* Debug */, + F7397A1EB1237FED3772A19C /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 2A8C10201122334455667788 /* XCLocalSwiftPackageReference "Sources/UpdateEngine" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Sources/UpdateEngine; + C7B407BF6C5684434BD1EC2E /* Build configuration list for PBXNativeTarget "SparklePublisher" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4249029B0E9B3339C4E84513 /* Debug */, + A2A3F35BA4A6C9C688D09CC6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; }; -/* End XCLocalSwiftPackageReference section */ +/* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 1F7A11C22D33445566778896 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + 084E20246CEB4E4FE5B8CFC0 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sparkle-project/Sparkle"; + repositoryURL = "https://github.com/apple/swift-nio-ssl.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.8.0; + kind = upToNextMinorVersion; + minimumVersion = 2.30.0; }; }; - A1B2C3D40111223344556801 /* XCRemoteSwiftPackageReference "swift-certificates" */ = { + 3DBCADE37280FF5C19CB8C41 /* XCRemoteSwiftPackageReference "swift-certificates" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-certificates.git"; requirement = { - kind = upToNextMajorVersion; + kind = upToNextMinorVersion; minimumVersion = 1.9.0; }; }; - A1B2C3D40111223344556802 /* XCRemoteSwiftPackageReference "swift-crypto" */ = { + 53F525F39820A67BBF1FBBAB /* XCRemoteSwiftPackageReference "swift-nio" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-crypto.git"; + repositoryURL = "https://github.com/apple/swift-nio.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.12.3; + kind = upToNextMinorVersion; + minimumVersion = 2.80.0; }; }; - A1B2C3D40111223344556803 /* XCRemoteSwiftPackageReference "swift-asn1" */ = { + 7F2B5C746E1B0FDC54E15204 /* XCRemoteSwiftPackageReference "swift-asn1" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-asn1.git"; requirement = { - kind = upToNextMajorVersion; + kind = upToNextMinorVersion; minimumVersion = 1.1.0; }; }; - A1B2C3D40111223344556804 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + 8CD70ED74937A5A681408390 /* XCRemoteSwiftPackageReference "swift-crypto" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-nio.git"; + repositoryURL = "https://github.com/apple/swift-crypto.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.80.0; + kind = upToNextMinorVersion; + minimumVersion = 3.12.3; }; }; - A1B2C3D40111223344556805 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */ = { + D8C92210CC9DF49CDCA360CC /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-nio-ssl.git"; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.30.0; + kind = upToNextMinorVersion; + minimumVersion = 2.8.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1F7A11C22D33445566778898 /* Sparkle */ = { - isa = XCSwiftPackageProductDependency; - package = 1F7A11C22D33445566778896 /* XCRemoteSwiftPackageReference "Sparkle" */; - productName = Sparkle; - }; - 2A8C10101122334455667788 /* UpdateEngine */ = { + 1840331BC8F2A1886D5014FF /* Crypto */ = { isa = XCSwiftPackageProductDependency; - package = 2A8C10201122334455667788 /* XCLocalSwiftPackageReference "Sources/UpdateEngine" */; - productName = UpdateEngine; + package = 8CD70ED74937A5A681408390 /* XCRemoteSwiftPackageReference "swift-crypto" */; + productName = Crypto; }; - A1B2C3D40111223344556901 /* X509 */ = { + 1C81108D7CF200AA2750ED17 /* NIOSSL */ = { isa = XCSwiftPackageProductDependency; - package = A1B2C3D40111223344556801 /* XCRemoteSwiftPackageReference "swift-certificates" */; - productName = X509; + package = 084E20246CEB4E4FE5B8CFC0 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */; + productName = NIOSSL; }; - A1B2C3D40111223344556902 /* Crypto */ = { + 5AEB32046AFF5E8525A5B458 /* NIOCore */ = { isa = XCSwiftPackageProductDependency; - package = A1B2C3D40111223344556802 /* XCRemoteSwiftPackageReference "swift-crypto" */; - productName = Crypto; + package = 53F525F39820A67BBF1FBBAB /* XCRemoteSwiftPackageReference "swift-nio" */; + productName = NIOCore; }; - A1B2C3D40111223344556903 /* SwiftASN1 */ = { + 6BEFF4EF2D66A493DAF66DDA /* SwiftASN1 */ = { isa = XCSwiftPackageProductDependency; - package = A1B2C3D40111223344556803 /* XCRemoteSwiftPackageReference "swift-asn1" */; + package = 7F2B5C746E1B0FDC54E15204 /* XCRemoteSwiftPackageReference "swift-asn1" */; productName = SwiftASN1; }; - A1B2C3D40111223344556904 /* NIOCore */ = { + 7C341BA99920552C7CDA9D24 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; - package = A1B2C3D40111223344556804 /* XCRemoteSwiftPackageReference "swift-nio" */; - productName = NIOCore; + package = D8C92210CC9DF49CDCA360CC /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; }; - A1B2C3D40111223344556905 /* NIOPosix */ = { + 7DF22120B86D527EE31A4E83 /* NIOPosix */ = { isa = XCSwiftPackageProductDependency; - package = A1B2C3D40111223344556804 /* XCRemoteSwiftPackageReference "swift-nio" */; + package = 53F525F39820A67BBF1FBBAB /* XCRemoteSwiftPackageReference "swift-nio" */; productName = NIOPosix; }; - A1B2C3D40111223344556906 /* NIOSSL */ = { + 81D226FCC037CE475B1850B7 /* X509 */ = { isa = XCSwiftPackageProductDependency; - package = A1B2C3D40111223344556805 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */; - productName = NIOSSL; + package = 3DBCADE37280FF5C19CB8C41 /* XCRemoteSwiftPackageReference "swift-certificates" */; + productName = X509; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = 3BD77BC016D6E32A8E693A59 /* Project object */; + rootObject = C0066AD18713AFCF7B5EAC62 /* Project object */; } diff --git a/SwiftBot.xcodeproj/xcshareddata/xcschemes/SparklePublisher.xcscheme b/SwiftBot.xcodeproj/xcshareddata/xcschemes/SparklePublisher.xcscheme new file mode 100644 index 0000000..5bbaa77 --- /dev/null +++ b/SwiftBot.xcodeproj/xcshareddata/xcschemes/SparklePublisher.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SwiftBot.xcodeproj/xcshareddata/xcschemes/SwiftBot.xcscheme b/SwiftBot.xcodeproj/xcshareddata/xcschemes/SwiftBot.xcscheme index 3318b64..4570923 100644 --- a/SwiftBot.xcodeproj/xcshareddata/xcschemes/SwiftBot.xcscheme +++ b/SwiftBot.xcodeproj/xcshareddata/xcschemes/SwiftBot.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1630" + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> @@ -26,11 +27,12 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> @@ -38,6 +40,8 @@ + + + + + + diff --git a/SwiftBotApp/AIBotsView.swift b/SwiftBotApp/AIBotsView.swift index 81f7861..4ce3328 100644 --- a/SwiftBotApp/AIBotsView.swift +++ b/SwiftBotApp/AIBotsView.swift @@ -548,7 +548,6 @@ private struct EngineStatusStackView: View { } } - private enum AIEngineStatus { case online case offline @@ -580,7 +579,7 @@ private enum AIEngineStatus { struct MemoryOverviewView: View { @ObservedObject var viewModel: MemoryViewModel @State private var showClearAllConfirm = false - @State private var scopeToClear: MemoryScope? = nil + @State private var scopeToClear: MemoryScope? var body: some View { VStack(alignment: .leading, spacing: 14) { diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index ab0d8d9..f328441 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -196,11 +196,11 @@ struct AdminWebActionsPayload: Codable { let servers: [AdminWebSimpleOption] let textChannelsByServer: [String: [AdminWebSimpleOption]] let voiceChannelsByServer: [String: [AdminWebSimpleOption]] - + /// Server-driven metadata for generic WEBUI rendering /// This replaces hard-coded assumptions with dynamic configuration let builderMetadata: AdminWebBuilderMetadata - + /// Deprecated: Kept for backwards compatibility with older WEBUI versions /// New WEBUI should use builderMetadata instead let conditionTypes: [String] @@ -571,7 +571,7 @@ actor AdminWebServer { loadPersistedSessions() let previous = self.config self.config = config - + // Refresh the active public base URL so OAuth redirect URIs pick up config changes immediately. self.activePublicBaseURL = resolvedPublicBaseURL(usingTLS: activeTransportUsesTLS) @@ -1433,7 +1433,7 @@ 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 @@ -1691,7 +1691,7 @@ actor AdminWebServer { guard let session = authenticatedSession(for: request) else { return unauthorizedResponse() } - + return jsonResponse([ "user": session.username, "discordUserID": session.userID, @@ -1725,17 +1725,17 @@ actor AdminWebServer { 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", @@ -1752,7 +1752,7 @@ actor AdminWebServer { session.expiresAt > Date() { return session } - + // Then try Bearer token (Remote client) if let authorization = request.headers["authorization"], authorization.hasPrefix("Bearer ") { @@ -1762,7 +1762,7 @@ actor AdminWebServer { return session } } - + return nil } @@ -1965,14 +1965,14 @@ actor AdminWebServer { var resolvedBase = activePublicBaseURL.isEmpty ? resolvedPublicBaseURL(usingTLS: config.https != nil) : activePublicBaseURL - + // Ensure scheme exists if !resolvedBase.isEmpty && !resolvedBase.contains("://") { resolvedBase = "https://" + resolvedBase } - + let path = config.redirectPath.hasPrefix("/") ? config.redirectPath : "/" + config.redirectPath - + Task { await logger?("[OAuth] Constructing redirectURI from base='\(resolvedBase)' and path='\(path)'") } @@ -1984,22 +1984,22 @@ actor AdminWebServer { } return fallback } - + // Handle existing path in base URL (e.g. proxy subpath) if !components.path.isEmpty && components.path != "/" { - let base_path = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path - let sub_path = path.hasPrefix("/") ? path : "/" + path - components.path = base_path + sub_path + let basePath = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path + let subPath = path.hasPrefix("/") ? path : "/" + path + components.path = basePath + subPath } else { components.path = path } - + let result = components.url?.absoluteString ?? (resolvedBase + (resolvedBase.hasSuffix("/") ? String(path.dropFirst()) : path)) - + Task { await logger?("[OAuth] Resulting redirectURI: \(result)") } - + return result } @@ -2205,30 +2205,22 @@ private final class AdminWebNIOHTTPHandler: ChannelInboundHandler { return 0 } } -import Foundation - -// MARK: - Server-Driven Metadata for WEBUI Action Builder -// This file defines the canonical contract exposed by the backend -// to enable generic, resilient WEBUI rendering without hard-coded assumptions. - -/// Complete metadata payload for the WEBUI action builder -/// This replaces hard-coded assumptions in the WEBUI with server-driven configuration struct AdminWebBuilderMetadata: Codable { /// Available trigger types with full metadata let triggers: [AdminWebTriggerMetadata] - + /// Available condition/filter types with full metadata let conditions: [AdminWebBlockMetadata] - + /// Available block types organized by category let categories: [AdminWebCategoryMetadata] - + /// All available action/modifier/AI blocks with full metadata let blocks: [AdminWebBlockMetadata] - + /// All available context variables for templating let variables: [AdminWebVariableMetadata] - + /// Version of the metadata schema for forward compatibility let schemaVersion: Int } @@ -2249,22 +2241,22 @@ struct AdminWebBlockMetadata: Codable { let symbol: String // SF Symbol name let category: String // Category ID (e.g., "actions", "ai") let description: String? // Optional help text - + /// Variables required for this block to function let requiredVariables: [String] - + /// Variables this block populates in context let outputVariables: [String] - + /// Field definitions for configuring this block let fields: [AdminWebFieldMetadata] - + /// Whether this block produces Discord output let producesOutput: Bool - + /// Whether this block is an AI processing block let isAIBlock: Bool - + /// Whether this block is a message modifier let isModifier: Bool } @@ -2296,7 +2288,7 @@ struct AdminWebFieldMetadata: Codable { let defaultValue: String? // Optional default value let description: String? // Optional help text let placeholder: String? // Optional placeholder text - + /// For picker/dropdown fields, the source of options let optionsSource: AdminWebOptionsSource? } @@ -2368,7 +2360,7 @@ extension ConditionType { isModifier: false ) } - + private var conditionDescription: String? { switch self { case .server: return "Only trigger in a specific server" @@ -2388,7 +2380,7 @@ extension ConditionType { case .channelType: return "Only trigger for specific channel types" } } - + private var conditionFieldMetadata: [AdminWebFieldMetadata] { switch self { case .server: @@ -2546,22 +2538,22 @@ extension ActionType { isModifier: self.isModifier ) } - + /// Whether this action type produces Discord output private var producesOutput: Bool { switch self { case .sendMessage, .sendDM, .addReaction, .deleteMessage, - .addRole, .removeRole, .timeoutMember, .kickMember, + .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .setStatus, .addLogEntry: return true - case .generateAIResponse, .summariseMessage, .classifyMessage, - .extractEntities, .rewriteMessage, .delay, .setVariable, + case .generateAIResponse, .summariseMessage, .classifyMessage, + .extractEntities, .rewriteMessage, .delay, .setVariable, .randomChoice, .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: return false } } - + /// Whether this is an AI processing block private var isAIBlock: Bool { switch self { @@ -2572,18 +2564,18 @@ extension ActionType { return false } } - + /// Whether this is a message modifier private var isModifier: Bool { switch self { - case .replyToTrigger, .mentionUser, .mentionRole, + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: return true default: return false } } - + /// Field metadata for each action type private var fieldMetadata: [AdminWebFieldMetadata] { switch self { @@ -2640,11 +2632,11 @@ extension ActionType { optionsSource: nil ) ] - + case .sendDM: // Send DM is now a routing modifier only - content comes from Send Message action return [] - + case .generateAIResponse: return [ AdminWebFieldMetadata( @@ -2658,7 +2650,7 @@ extension ActionType { optionsSource: nil ) ] - + case .summariseMessage: return [ AdminWebFieldMetadata( @@ -2672,7 +2664,7 @@ extension ActionType { optionsSource: nil ) ] - + case .classifyMessage: return [ AdminWebFieldMetadata( @@ -2686,7 +2678,7 @@ extension ActionType { optionsSource: nil ) ] - + case .extractEntities: return [ AdminWebFieldMetadata( @@ -2700,7 +2692,7 @@ extension ActionType { optionsSource: nil ) ] - + case .rewriteMessage: return [ AdminWebFieldMetadata( @@ -2714,7 +2706,7 @@ extension ActionType { optionsSource: nil ) ] - + case .addReaction: return [ AdminWebFieldMetadata( @@ -2728,7 +2720,7 @@ extension ActionType { optionsSource: nil ) ] - + case .addRole, .removeRole: return [ AdminWebFieldMetadata( @@ -2742,7 +2734,7 @@ extension ActionType { optionsSource: .roles ) ] - + case .timeoutMember: return [ AdminWebFieldMetadata( @@ -2756,7 +2748,7 @@ extension ActionType { optionsSource: nil ) ] - + case .kickMember: return [ AdminWebFieldMetadata( @@ -2770,7 +2762,7 @@ extension ActionType { optionsSource: nil ) ] - + case .moveMember: return [ AdminWebFieldMetadata( @@ -2784,7 +2776,7 @@ extension ActionType { optionsSource: .voiceChannels ) ] - + case .createChannel: return [ AdminWebFieldMetadata( @@ -2798,7 +2790,7 @@ extension ActionType { optionsSource: nil ) ] - + case .webhook: return [ AdminWebFieldMetadata( @@ -2822,7 +2814,7 @@ extension ActionType { optionsSource: nil ) ] - + case .delay: return [ AdminWebFieldMetadata( @@ -2836,7 +2828,7 @@ extension ActionType { optionsSource: nil ) ] - + case .setVariable: return [ AdminWebFieldMetadata( @@ -2860,7 +2852,7 @@ extension ActionType { optionsSource: nil ) ] - + case .setStatus: return [ AdminWebFieldMetadata( @@ -2874,7 +2866,7 @@ extension ActionType { optionsSource: nil ) ] - + case .addLogEntry: return [ AdminWebFieldMetadata( @@ -2888,7 +2880,7 @@ extension ActionType { optionsSource: nil ) ] - + case .deleteMessage: return [ AdminWebFieldMetadata( @@ -2902,17 +2894,17 @@ extension ActionType { optionsSource: nil ) ] - + // Modifiers - no additional fields beyond the toggle case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: return [] - + case .randomChoice: // Random choice would need array of options - simplified for now return [] } } - + private var description: String? { switch self { case .sendMessage: @@ -2941,7 +2933,7 @@ extension BlockCategory { let blockIds = ActionType.allCases .filter { $0.category == self } .map { $0.rawValue } - + return AdminWebCategoryMetadata( id: self.rawValue, name: self.rawValue, @@ -2951,7 +2943,7 @@ extension BlockCategory { order: self.displayOrder ) } - + private var displayOrder: Int { switch self { case .triggers: return 0 @@ -2962,7 +2954,7 @@ extension BlockCategory { case .moderation: return 5 } } - + private var description: String? { switch self { case .triggers: diff --git a/SwiftBotApp/AppModel+AdminWeb.swift b/SwiftBotApp/AppModel+AdminWeb.swift index 43f62d6..cc6d2d1 100644 --- a/SwiftBotApp/AppModel+AdminWeb.swift +++ b/SwiftBotApp/AppModel+AdminWeb.swift @@ -1256,7 +1256,7 @@ extension AppModel { forceReplaceDNS: Bool = false ) async throws -> String { logs.append("=== Public Access Setup Started ===") - + let hostname = effectiveAdminWebHostname() logs.append("Hostname: \(hostname)") guard !hostname.isEmpty else { @@ -1461,15 +1461,15 @@ extension AppModel { guard !trimmedToken.isEmpty else { throw CertificateManager.Error.missingCloudflareToken } - + let dnsProvider = CloudflareDNSProvider(apiToken: trimmedToken) - + // First verify the token is valid by checking user info let isValid = await dnsProvider.verifyAPIToken() guard isValid else { throw CertificateManager.Error.inactiveCloudflareToken } - + // Then list all available zones return try await dnsProvider.listZones() } @@ -1479,7 +1479,7 @@ extension AppModel { forceReplaceDNS: Bool = false ) async throws -> String { logs.append("=== Internet Access Setup Started ===") - + let hostname = effectiveAdminWebHostname() logs.append("Hostname: \(hostname)") guard !hostname.isEmpty else { @@ -1625,7 +1625,7 @@ extension AppModel { return error.errorDescription ?? genericAdminWebPublicAccessFailureMessage case let error as CloudflareTunnelClient.Error: let message = error.errorDescription ?? genericAdminWebPublicAccessFailureMessage - if message.localizedCaseInsensitiveContains("authentication") || + if message.localizedCaseInsensitiveContains("authentication") || message.localizedCaseInsensitiveContains("access denied") || message.localizedCaseInsensitiveContains("permission") { return "Cloudflare authentication failed. Ensure your API token has 'Cloudflare Tunnel: Edit' permissions." diff --git a/SwiftBotApp/AppModel+BotLifecycle.swift b/SwiftBotApp/AppModel+BotLifecycle.swift index 1a677f1..24921d1 100644 --- a/SwiftBotApp/AppModel+BotLifecycle.swift +++ b/SwiftBotApp/AppModel+BotLifecycle.swift @@ -194,13 +194,13 @@ extension AppModel { 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) } diff --git a/SwiftBotApp/AppModel+Cluster.swift b/SwiftBotApp/AppModel+Cluster.swift new file mode 100644 index 0000000..3869462 --- /dev/null +++ b/SwiftBotApp/AppModel+Cluster.swift @@ -0,0 +1,454 @@ +import Foundation +import SwiftUI +import AppKit + +extension AppModel { + + // MARK: - Cluster / SwiftMesh + + func refreshClusterStatus() { + print("[DEBUG] AppModel.refreshClusterStatus() called") + Task { + print("[DEBUG] AppModel.refreshClusterStatus() Task started") + await pollClusterStatus() + let snapshot = await cluster.currentSnapshot() + await MainActor.run { + print("[DEBUG] AppModel.refreshClusterStatus() UI update") + self.clusterSnapshot = snapshot + self.lastClusterStatusRefreshAt = Date() + self.logSwiftMeshStatus(snapshot, context: "Refresh") + } + } + } + + func testWorkerLeaderConnection(leaderAddress: String? = nil, leaderPort: Int? = nil) { + let address = leaderAddress ?? settings.clusterLeaderAddress + let port = leaderPort ?? settings.clusterLeaderPort + + print("[DEBUG] AppModel.testWorkerLeaderConnection() called with address=\(address), port=\(port)") + Task { + print("[DEBUG] AppModel.testWorkerLeaderConnection() Task started") + await MainActor.run { + self.workerConnectionTestInProgress = true + self.workerConnectionTestIsSuccess = false + self.workerConnectionTestStatus = "Testing connection..." + self.workerConnectionTestOutcome = nil + } + + let outcome = await performWorkerConnectionTest( + leaderAddress: address, + leaderPort: port + ) + print("[DEBUG] AppModel.testWorkerLeaderConnection() outcome: \(outcome.isSuccess)") + + await MainActor.run { + self.workerConnectionTestInProgress = false + self.workerConnectionTestIsSuccess = outcome.isSuccess + self.workerConnectionTestStatus = outcome.message + self.workerConnectionTestOutcome = outcome + self.lastClusterStatusRefreshAt = Date() + self.logs.append("SwiftMesh worker connection test: \(outcome.message)") + } + } + } + + func refreshClusterStatusNow() async -> ClusterSnapshot { + await pollClusterStatus() + let snapshot = await cluster.currentSnapshot() + self.clusterSnapshot = snapshot + logSwiftMeshStatus(snapshot, context: "Refresh") + return snapshot + } + + func scheduleClusterNodesRefresh() { + clusterNodesRefreshTask?.cancel() + clusterNodesRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self else { return } + await self.pollClusterStatus() + } + } + + func configureMeshSync() { + meshSyncTask?.cancel() + meshSyncTask = nil + + guard settings.clusterMode == .leader || settings.clusterMode == .standby else { return } + + meshSyncTask = Task { [weak self] in + while !Task.isCancelled { + // Leader pushes, Standby pulls + // Sync every 10 seconds so failover config changes propagate quickly. + try? await Task.sleep(nanoseconds: 10_000_000_000) + if Task.isCancelled { break } + + guard let self else { break } + + if self.settings.clusterMode == .leader { + // 1. Push worker registry to all nodes + await self.cluster.pushWorkerRegistryToStandbys() + // 2. Push incremental conversation batches per node + await self.pushIncrementalConversationsToAllNodes() + } else if self.settings.clusterMode == .standby { + // 3. Standby: Pull config files and wiki cache from Primary + await self.pullConfigFilesFromLeader() + await self.pullWikiCacheFromLeader() + } + } + } + } + + func setupBackgroundRefreshScheduler() { + let scheduler = NSBackgroundActivityScheduler(identifier: "com.swiftbot.meshBackgroundRefresh") + scheduler.repeats = true + scheduler.interval = 15 * 60 // 15 minutes + scheduler.tolerance = 5 * 60 // 5-minute tolerance window + scheduler.qualityOfService = .background + scheduler.schedule { [weak self] completion in + guard let self else { completion(.finished); return } + Task { + await self.runBackgroundMeshRefresh() + completion(.finished) + } + } + backgroundRefreshScheduler = scheduler + } + + func runBackgroundMeshRefresh() async { + guard settings.clusterMode == .standby || settings.clusterMode == .worker else { return } + await pullConfigFilesFromLeader() + await requestResyncFromLeader(fromRecordID: localLastMergedRecordID) + } + + func pushIncrementalConversationsToAllNodes() async { + let nodes = await cluster.registeredNodeInfo() + guard !nodes.isEmpty else { return } + let currentTerm = await cluster.currentLeaderTerm() + for (nodeName, baseURL) in nodes { + let cursor = await cluster.currentReplicationCursor(for: nodeName) + let fromID = cursor?.lastSentRecordID ?? "" + let (records, hasMore) = await conversationStore.recordsSince(fromRecordID: fromID, limit: 500) + let lastID = records.last?.id + let payload = MeshSyncPayload( + conversations: records, + commandLog: Array(commandLog.prefix(200)), + voiceLog: Array(voiceLog.prefix(200)), + activeVoice: activeVoice, + leaderTerm: currentTerm, + cursorRecordID: lastID, + hasMore: hasMore, + fromCursorRecordID: fromID + ) + let ok = await cluster.pushConversationsToSingleNode(baseURL, payload) + if ok, lastID != nil { + await cluster.updateReplicationCursor(for: nodeName, lastSentRecordID: lastID, term: currentTerm) + } + } + } + + func pullWikiCacheFromLeader() async { + guard let data = await cluster.fetchWikiCache() else { return } + if let entries = try? JSONDecoder().decode([WikiContextEntry].self, from: data) { + for entry in entries { + await wikiContextCache.upsertEntry(entry) + } + logs.append("SwiftMesh: pulled \(entries.count) wiki entry(s) from Primary") + } + } + + func pullConfigFilesFromLeader() async { + guard settings.clusterMode == .standby || settings.clusterMode == .worker else { return } + guard let data = await cluster.fetchConfigFiles() else { return } + let imported = await store.importMeshSyncedFiles( + data, + excludingFileNames: Set([ + SwiftBotStorage.swiftMeshConfigFileName, + SwiftBotStorage.clusterStateFileName + ]) + ) + guard imported > 0 else { return } + + logs.append("SwiftMesh: pulled \(imported) config file(s) from Primary") + await reloadSyncedConfigFromDisk() + } + + func reloadSyncedConfigFromDisk() async { + // Keep local mesh identity authoritative on this node. + let currentLocalMesh = settings.swiftMeshSettings + let currentLocalMedia = mediaLibrarySettings + let currentLocalAdminWebUI = settings.adminWebUI + let currentLocalRemoteAccessToken = settings.remoteAccessToken + var reloaded = await store.load() + let meshFromFile = await swiftMeshConfigStore.load() + let effectiveLocalMesh = meshFromFile ?? currentLocalMesh + reloaded.swiftMeshSettings = effectiveLocalMesh + if meshFromFile == nil { + // Self-heal missing mesh file so future reloads remain stable. + try? await swiftMeshConfigStore.save(effectiveLocalMesh) + } + if effectiveLocalMesh.mode == .standby || effectiveLocalMesh.mode == .worker { + reloaded.adminWebUI = currentLocalAdminWebUI + reloaded.remoteAccessToken = currentLocalRemoteAccessToken + } + reloaded.wikiBot.normalizeSources() + settings = reloaded + mediaLibrarySettings = currentLocalMedia + await mediaLibraryIndexer.invalidate() + await ruleStore.reloadFromDisk() + await aiService.configureLocalAIDMReplies( + enabled: settings.localAIDMReplyEnabled, + provider: settings.localAIProvider, + preferredProvider: settings.preferredAIProvider, + endpoint: localAIEndpointForService(), + model: settings.localAIModel, + openAIAPIKey: effectiveOpenAIAPIKey(), + openAIModel: settings.openAIModel, + systemPrompt: settings.localAISystemPrompt + ) + configurePatchyMonitoring() + await configureAdminWebServer() + await refreshAIStatus() + } + + func applyClusterSettingsRuntime(mode: ClusterMode, nodeName: String, leaderAddress: String, leaderPort: Int, listenPort: Int, sharedSecret: String) async { + // Phase 5 Safety Guard: Prevent invalid mesh ports from being used. + guard listenPort > 0 && listenPort <= 65535 else { + logs.append("❌ [SwiftMesh] Invalid port '\(listenPort)' — aborting mesh connection.") + return + } + + await cluster.applySettings( + mode: mode, + nodeName: nodeName, + leaderAddress: leaderAddress, + leaderPort: leaderPort, + listenPort: listenPort, + sharedSecret: sharedSecret, + leaderTerm: settings.clusterLeaderTerm + ) + + // Phase 4: Configuration Consistency - log final mesh endpoint + if mode != .standalone { + let host = ProcessInfo.processInfo.hostName + logs.append("SwiftMesh listening on \(host):\(listenPort)") + } + + await cluster.setOffloadPolicy( + aiReplies: settings.clusterOffloadAIReplies, + wikiLookups: settings.clusterOffloadWikiLookups + ) + // Sync secondary safety guard: only Primary nodes may send Discord output. + let isPrimary = mode == .standalone || mode == .leader + await service.setOutputAllowed(isPrimary) + configureMeshSync() + if mode == .standby { + await pullConfigFilesFromLeader() + } + await pollClusterStatus() + } + + func pollClusterStatus() async { + guard settings.clusterMode != .standalone else { + clusterNodes = [] + await refreshRegisteredWorkersDebugInfo() + return + } + + let emptyBody = Data() + let localStatusHeaders = await meshStatusAuthHeaders(path: "/cluster/status", method: "GET", body: emptyBody) + let localURL = URL(string: "http://127.0.0.1:\(settings.clusterListenPort)/cluster/status") + + if settings.clusterMode == .standby, + let remoteNodes = await fetchRemoteLeaderNodesIfAvailable() { + await applyClusterNodes(remoteNodes) + return + } + + if let localURL, + let response = await clusterStatusService.fetchStatus(from: localURL, headers: localStatusHeaders) { + let resolvedNodes = response.nodes.isEmpty ? fallbackClusterNodes() : response.nodes + await applyClusterNodes(resolvedNodes) + return + } + + if settings.clusterMode == .worker || settings.clusterMode == .standby, + let remoteNodes = await fetchRemoteLeaderNodesIfAvailable() { + await applyClusterNodes(remoteNodes) + return + } + + let graceWindow: TimeInterval = 12 + if let lastSuccess = lastClusterStatusSuccessAt, + Date().timeIntervalSince(lastSuccess) <= graceWindow, + !lastGoodClusterNodes.isEmpty { + clusterNodes = lastGoodClusterNodes + } else { + clusterNodes = fallbackClusterNodes() + } + await refreshRegisteredWorkersDebugInfo() + } + + private func applyClusterNodes(_ nodes: [ClusterNodeStatus]) async { + clusterNodes = nodes + lastGoodClusterNodes = nodes + lastClusterStatusSuccessAt = Date() + await refreshRegisteredWorkersDebugInfo() + } + + private func refreshRegisteredWorkersDebugInfo() async { + let info = await cluster.registeredWorkersDebugInfo() + registeredWorkersDebugCount = info.count + registeredWorkersDebugSummary = info.summary + } + + private func fetchRemoteLeaderNodesIfAvailable() async -> [ClusterNodeStatus]? { + guard let baseURL = normalizedSwiftMeshBaseURL(from: settings.clusterLeaderAddress, defaultPort: settings.clusterLeaderPort), + let statusURL = URL(string: baseURL.absoluteString + "/cluster/status"), + let host = baseURL.host else { + return nil + } + + let emptyBody = Data() + let statusHeaders = await meshStatusAuthHeaders(path: "/cluster/status", method: "GET", body: emptyBody) + if let response = await clusterStatusService.fetchStatus(from: statusURL, headers: statusHeaders) { + let nodes = response.nodes.isEmpty ? fallbackClusterNodes() : response.nodes + if settings.clusterMode == .standby { + return ensureLocalStandbyNodePresent(in: nodes) + } + return nodes + } + + guard let pingURL = URL(string: baseURL.absoluteString + "/cluster/ping") else { + return nil + } + let pingHeaders = await meshStatusAuthHeaders(path: "/cluster/ping", method: "GET", body: emptyBody) + guard let ping = await clusterStatusService.fetchPing(from: pingURL, headers: pingHeaders), + ping.response.status.caseInsensitiveCompare("ok") == .orderedSame, + ping.response.role.caseInsensitiveCompare("leader") == .orderedSame else { + return nil + } + + var nodes = fallbackClusterNodes() + if let leaderIndex = nodes.firstIndex(where: { $0.role == .leader }) { + nodes[leaderIndex].status = .healthy + nodes[leaderIndex].latencyMs = ping.latencyMs + nodes[leaderIndex].displayName = ping.response.node + nodes[leaderIndex].hostname = host + return nodes + } + + nodes.append( + ClusterNodeStatus( + id: "leader-\(host.lowercased())", + hostname: host, + displayName: ping.response.node, + role: .leader, + hardwareModel: "Unknown", + cpu: 0, + mem: 0, + cpuName: "Unknown CPU", + physicalMemoryBytes: 0, + uptime: 0, + latencyMs: ping.latencyMs, + status: .healthy, + jobsActive: 0 + ) + ) + if settings.clusterMode == .standby { + return ensureLocalStandbyNodePresent(in: nodes) + } + return nodes + } + + private func ensureLocalStandbyNodePresent(in nodes: [ClusterNodeStatus]) -> [ClusterNodeStatus] { + guard settings.clusterMode == .standby else { return nodes } + guard let localWorker = fallbackClusterNodes().first(where: { $0.role == .worker }) else { + return nodes + } + + let hasLocal = nodes.contains { node in + guard node.role == .worker else { return false } + if node.displayName.caseInsensitiveCompare(localWorker.displayName) == .orderedSame { + return true + } + return node.hostname.caseInsensitiveCompare(localWorker.hostname) == .orderedSame + } + guard !hasLocal else { return nodes } + + var merged = nodes + merged.append(localWorker) + return merged + } + + private func meshStatusAuthHeaders(path: String, method: String, body: Data) async -> [String: String] { + let secret = settings.clusterSharedSecret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !secret.isEmpty else { return [:] } + + let nonce = UUID().uuidString + let timestamp = Int(Date().timeIntervalSince1970) + let signature = await cluster.meshSignature( + method: method, + nonce: nonce, + timestamp: timestamp, + path: path, + body: body + ) + return [ + "X-Mesh-Nonce": nonce, + "X-Mesh-Timestamp": String(timestamp), + "X-Mesh-Signature": signature + ] + } + + func fallbackClusterNodes() -> [ClusterNodeStatus] { + let localNodeName = settings.clusterNodeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? (Host.current().localizedName ?? "SwiftBot Node") + : settings.clusterNodeName.trimmingCharacters(in: .whitespacesAndNewlines) + let hostname = ProcessInfo.processInfo.hostName + let role: ClusterNodeRole = settings.clusterMode == .leader ? .leader : .worker + let uptime = max(0, Date().timeIntervalSince(launchedAt)) + let hardwareInfo = HardwareInfo.current() + var nodes: [ClusterNodeStatus] = [ + ClusterNodeStatus( + id: "\(role.rawValue)-\(hostname.lowercased())-\(settings.clusterListenPort)", + hostname: hostname, + displayName: localNodeName, + role: role, + hardwareModel: hardwareInfo.modelIdentifier, + cpu: 0, + mem: 0, + cpuName: hardwareInfo.cpuName, + physicalMemoryBytes: hardwareInfo.physicalMemoryBytes, + uptime: uptime, + latencyMs: nil, + status: clusterSnapshot.serverState.nodeHealthStatus, + jobsActive: 0 + ) + ] + + if settings.clusterMode == .worker || settings.clusterMode == .standby, + !settings.clusterLeaderAddress.isEmpty { + let host = URL(string: settings.clusterLeaderAddress)?.host ?? "Primary" + nodes.append( + ClusterNodeStatus( + id: "leader-\(host.lowercased())", + hostname: host, + displayName: host, + role: .leader, + hardwareModel: "Unknown", + cpu: 0, + mem: 0, + cpuName: "Unknown CPU", + physicalMemoryBytes: 0, + uptime: 0, + latencyMs: nil, + status: .disconnected, + jobsActive: 0 + ) + ) + } + + return nodes + } + +} diff --git a/SwiftBotApp/AppModel+Commands.swift b/SwiftBotApp/AppModel+Commands.swift index 1ea1ec0..71cb6be 100644 --- a/SwiftBotApp/AppModel+Commands.swift +++ b/SwiftBotApp/AppModel+Commands.swift @@ -101,7 +101,7 @@ extension AppModel { let hardCap = max(limit, settings.openAIImageMonthlyHardCap) let usageKey = imageUsageKey(userID: userId) let used = settings.openAIImageUsageByUserMonth[usageKey] ?? 0 - + if limit > 0, used >= limit { return await send( channelId, @@ -137,7 +137,7 @@ extension AppModel { pruneOldImageUsageMonths() settings.openAIImageUsageByUserMonth[usageKey] = used + 1 _ = await persistSettings() - + // SwiftMesh: broadcast updated usage to other nodes if settings.clusterMode == .leader { await pushImageUsageToAllNodes() @@ -2116,7 +2116,7 @@ extension AppModel { ) async -> (messages: [Message], wikiContext: String) { let maxHistory = 8 var recent = await conversationStore.recentMessages(in: scope, limit: maxHistory) - + if !currentContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { recent.append( MemoryRecord( @@ -2153,7 +2153,7 @@ extension AppModel { let combinedContext = [wikiContext, aiMemoryContext] .filter { !$0.isEmpty } .joined(separator: "\n\n") - + return (conversationalMessages, combinedContext) } diff --git a/SwiftBotApp/AppModel+DiscordEvents.swift b/SwiftBotApp/AppModel+DiscordEvents.swift new file mode 100644 index 0000000..01eb890 --- /dev/null +++ b/SwiftBotApp/AppModel+DiscordEvents.swift @@ -0,0 +1,295 @@ +import Foundation +import SwiftUI + +extension AppModel { + + // MARK: - Discord Event Handlers + + func handleMemberJoin(_ event: GatewayMemberJoinEvent) async { + // Legacy settings path still active for backward compatibility. + // New config: use a "Member Joined" trigger rule in Actions instead. + let legacyEnabled = settings.behavior.memberJoinWelcomeEnabled && + !settings.behavior.memberJoinWelcomeChannelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasRules = ruleStore.rules.contains { $0.isEnabled && $0.trigger == .memberJoined } + guard legacyEnabled || hasRules else { return } + + let now = Date() + let guildId = event.guildID + let userId = event.userID + + // Increment member count for this guild (best-effort; sourced from GUILD_CREATE). + let memberCount = (guildMemberCounts[guildId] ?? 0) + 1 + guildMemberCounts[guildId] = memberCount + + // Burst-guard: track join timestamps per guild; cap array to 50 entries. + var timestamps = guildJoinTimestamps[guildId] ?? [] + timestamps = timestamps.filter { now.timeIntervalSince($0) < 5 } + timestamps.append(now) + if timestamps.count > 50 { timestamps = Array(timestamps.suffix(50)) } + guildJoinTimestamps[guildId] = timestamps + + let burstThreshold = 10 + if timestamps.count > burstThreshold { + // Raid-safe: summarize instead of individual welcome. + if timestamps.count == burstThreshold + 1 { + // Post once at the threshold crossing, not on every subsequent join. + let channelId = settings.behavior.memberJoinWelcomeChannelId + .trimmingCharacters(in: .whitespacesAndNewlines) + let serverName = connectedServers[guildId] ?? "the server" + _ = await send(channelId, "👥 Multiple members joined \(serverName) — welcome everyone!") + logs.append("Member join burst detected in \(guildId); switched to summary mode.") + } + return + } + + // Dedupe: skip if same user joined this guild within 10 seconds. + let dedupeKey = "\(guildId):\(userId)" + if let last = recentMemberJoins[dedupeKey], now.timeIntervalSince(last) < 10 { return } + recentMemberJoins[dedupeKey] = now + // Bounded cleanup: cap at 500 entries, remove entries older than 60s. + if recentMemberJoins.count > 500 { + let pruned = recentMemberJoins.filter { now.timeIntervalSince($0.value) < 60 } + recentMemberJoins = Dictionary(uniqueKeysWithValues: Array(pruned.prefix(500))) + } + + // Template sanitization: neutralize @everyone and @here to prevent mass-ping abuse. + let safeUsername = event.rawUsername + .replacingOccurrences(of: "@everyone", with: "@​everyone") + .replacingOccurrences(of: "@here", with: "@​here") + + let serverName = connectedServers[guildId] ?? "the server" + let message = settings.behavior.memberJoinWelcomeTemplate + .replacingOccurrences(of: "{username}", with: safeUsername) + .replacingOccurrences(of: "{server}", with: serverName) + .replacingOccurrences(of: "{memberCount}", with: "\(memberCount)") + + if legacyEnabled { + let channelId = settings.behavior.memberJoinWelcomeChannelId + .trimmingCharacters(in: .whitespacesAndNewlines) + _ = await send(channelId, message) + } + + // Rule-based execution: evaluate any enabled "Member Joined" trigger rules. + let ruleEvent = VoiceRuleEvent( + kind: .memberJoin, + guildId: guildId, + userId: userId, + username: safeUsername, + channelId: "", + fromChannelId: nil, + toChannelId: nil, + durationSeconds: nil, + messageContent: nil, + messageId: nil, + mediaFileName: nil, + mediaRelativePath: nil, + mediaSourceName: nil, + mediaNodeName: nil, + triggerMessageId: nil, + triggerChannelId: nil, + triggerGuildId: guildId, + triggerUserId: userId, + isDirectMessage: false, + authorIsBot: nil, + joinedAt: event.joinedAt + ) + let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) + for rule in matchedRules { + _ = PipelineContext() + for action in rule.processedActions where action.type == .sendMessage { + let ruleMessage = action.message + .replacingOccurrences(of: "{username}", with: safeUsername) + .replacingOccurrences(of: "{server}", with: serverName) + .replacingOccurrences(of: "{memberCount}", with: "\(memberCount)") + .replacingOccurrences(of: "{userId}", with: userId) + let targetChannel = action.channelId.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !targetChannel.isEmpty else { continue } + _ = await send(targetChannel, ruleMessage) + } + } + + // Log username only — no internal IDs or metadata. + addEvent(ActivityEvent(timestamp: now, kind: .info, message: "👋 \(safeUsername) joined \(serverName)")) + logs.append("Member join welcome sent for \(safeUsername) in \(serverName)") + } + + func handleMemberLeave(_ event: GatewayMemberLeaveEvent) async { + let now = Date() + let guildId = event.guildID + let userId = event.userID + + // Best-effort member count decrement + if let count = guildMemberCounts[guildId] { + guildMemberCounts[guildId] = max(0, count - 1) + } + + let username = event.username + + let ruleEvent = VoiceRuleEvent( + kind: .memberLeave, + guildId: guildId, + userId: userId, + username: username, + channelId: "", + fromChannelId: nil, + toChannelId: nil, + durationSeconds: nil, + messageContent: nil, + messageId: nil, + mediaFileName: nil, + mediaRelativePath: nil, + mediaSourceName: nil, + mediaNodeName: nil, + triggerMessageId: nil, + triggerChannelId: nil, + triggerGuildId: guildId, + triggerUserId: userId, + isDirectMessage: false, + authorIsBot: nil, + joinedAt: nil + ) + + let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) + for rule in matchedRules { + _ = await service.executeRulePipeline(actions: rule.processedActions, for: ruleEvent, isDirectMessage: ruleEvent.isDirectMessage) + } + + addEvent(ActivityEvent(timestamp: now, kind: .info, message: "🚪 \(username) left the server")) + logs.append("Member leave handled for \(username)") + } + + func handleGuildCreate(_ event: GatewayGuildCreateEvent) async { + guildCreateEventCount += 1 + if let memberCount = event.memberCount { + guildMemberCounts[event.guildID] = memberCount + } + + await discordCache.upsertGuild(id: event.guildID, name: event.guildName) + await discordCache.setGuildVoiceChannels(guildID: event.guildID, channels: parseVoiceChannels(from: event.rawMap)) + await discordCache.setGuildTextChannels(guildID: event.guildID, channels: parseTextChannels(from: event.rawMap)) + await discordCache.setGuildRoles(guildID: event.guildID, roles: parseRoles(from: event.rawMap)) + await discordCache.mergeChannelTypes(parseChannelTypes(from: event.rawMap)) + await cacheGuildMembers(from: event.rawMap) + await syncPublishedDiscordCacheFromService() + await syncVoicePresenceFromGuildSnapshot(guildId: event.guildID, guildMap: event.rawMap) + scheduleDiscordCacheSave() + await registerSlashCommandsIfNeeded() + } + + func handleChannelCreate(_ event: GatewayChannelCreateEvent) async { + await discordCache.setChannelType(channelID: event.channelID, type: event.type) + await discordCache.upsertChannel( + guildID: event.guildID, + channelID: event.channelID, + name: event.name, + type: event.type + ) + await syncPublishedDiscordCacheFromService() + scheduleDiscordCacheSave() + } + + func handleGuildDelete(_ event: GatewayGuildDeleteEvent) async { + await discordCache.removeGuild(id: event.guildID) + await syncPublishedDiscordCacheFromService() + await clearVoicePresence(guildID: event.guildID) + scheduleDiscordCacheSave() + } + + func syncVoicePresenceFromGuildSnapshot(guildId: String, guildMap: [String: DiscordJSON]) async { + guard case let .array(voiceStates)? = guildMap["voice_states"] else { return } + + let now = Date() + var snapshot: [VoiceMemberPresence] = [] + for state in voiceStates { + guard case let .object(stateMap) = state, + case let .string(userId)? = stateMap["user_id"], + case let .string(channelId)? = stateMap["channel_id"] + else { continue } + + if case let .object(member)? = stateMap["member"], + case let .object(user)? = member["user"], + case let .string(avatarHash)? = user["avatar"], + !avatarHash.isEmpty { + cacheUserAvatar(avatarHash, for: userId) + if case let .string(guildAvatarHash)? = member["avatar"], !guildAvatarHash.isEmpty { + cacheGuildAvatar(guildAvatarHash, for: "\(guildId)-\(userId)") + } + } else if case let .object(user)? = stateMap["user"], + case let .string(avatarHash)? = user["avatar"], + !avatarHash.isEmpty { + cacheUserAvatar(avatarHash, for: userId) + } + + let username = await voiceDisplayName(from: stateMap, userId: userId) + let key = "\(guildId)-\(userId)" + let joinedAt = now + + snapshot.append( + VoiceMemberPresence( + id: key, + userId: userId, + username: username, + guildId: guildId, + channelId: channelId, + channelName: channelDisplayName(guildId: guildId, channelId: channelId), + joinedAt: joinedAt + ) + ) + } + + activeVoice = await voicePresenceStore.syncGuildSnapshot(guildId, members: snapshot) + } + + func cacheGuildMembers(from guildMap: [String: DiscordJSON]) async { + guard case let .array(members)? = guildMap["members"] else { return } + + for member in members { + guard case let .object(memberMap) = member else { continue } + if case let .string(nick)? = memberMap["nick"], !nick.isEmpty, + case let .object(user)? = memberMap["user"], + case let .string(userId)? = user["id"] { + await discordCache.upsertUser(id: userId, preferredName: nick) + continue + } + + guard case let .object(user)? = memberMap["user"], + case let .string(userId)? = user["id"] else { continue } + + if case let .string(avatarHash)? = user["avatar"], !avatarHash.isEmpty { + cacheUserAvatar(avatarHash, for: userId) + } + + if case let .string(globalName)? = user["global_name"], !globalName.isEmpty { + await discordCache.upsertUser(id: userId, preferredName: globalName) + } else if case let .string(username)? = user["username"], !username.isEmpty { + await discordCache.upsertUser(id: userId, preferredName: username) + } + } + } + + func syncPublishedDiscordCacheFromService() async { + let snapshot = await discordCache.currentSnapshot() + connectedServers = snapshot.connectedServers + availableVoiceChannelsByServer = snapshot.availableVoiceChannelsByServer + availableTextChannelsByServer = snapshot.availableTextChannelsByServer + availableRolesByServer = snapshot.availableRolesByServer + knownUsersById = snapshot.usernamesById + } + + func scheduleDiscordCacheSave() { + discordCacheSaveTask?.cancel() + discordCacheSaveTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 800_000_000) + guard !Task.isCancelled, let self = self else { return } + do { + let snapshot = await self.discordCache.currentSnapshot() + try await discordCacheStore.save(snapshot) + } catch { + await MainActor.run { + self.logs.append("❌ Failed saving Discord cache: \(error.localizedDescription)") + } + } + } + } + +} diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index eca34e7..440a2c1 100644 --- a/SwiftBotApp/AppModel+Gateway.swift +++ b/SwiftBotApp/AppModel+Gateway.swift @@ -148,13 +148,13 @@ extension AppModel { let nodes = await cluster.registeredNodeInfo() guard !nodes.isEmpty else { return } let currentTerm = await cluster.currentLeaderTerm() - + let payload = MeshSyncPayload( conversations: [], imageUsage: settings.openAIImageUsageByUserMonth, leaderTerm: currentTerm ) - + for (_, baseURL) in nodes { _ = await cluster.pushConversationsToSingleNode(baseURL, payload) } @@ -288,8 +288,8 @@ extension AppModel { currentUserID: userId, currentContent: content ) - - var serverName: String? = nil + + var serverName: String? if let gid = guildID { serverName = await discordCache.guildName(for: gid) } @@ -372,7 +372,7 @@ extension AppModel { currentUserID: userId, currentContent: prompt ) - var serverName: String? = nil + var serverName: String? if let gid = guildID { serverName = await discordCache.guildName(for: gid) } @@ -527,7 +527,7 @@ extension AppModel { let token = settings.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { return } guard ActionDispatcher.canSend(clusterMode: settings.clusterMode, action: "registerSlashCommands", log: { logs.append($0) }) else { return } - + let slashEnabled = settings.commandsEnabled && settings.slashCommandsEnabled if lastSlashCommandsEnabledState != slashEnabled { lastSlashRegistrationAt = nil @@ -579,7 +579,7 @@ extension AppModel { logs.append("✅ Slash commands disabled and cleared for \(guildRegisteredCount) guild(s)") } } else if guildIds.isEmpty, - (lastSlashRegistrationAt == nil || now.timeIntervalSince(lastSlashRegistrationAt!) >= 300) { + lastSlashRegistrationAt == nil || now.timeIntervalSince(lastSlashRegistrationAt!) >= 300 { do { try await service.registerGlobalApplicationCommands( applicationID: appID, @@ -705,7 +705,7 @@ extension AppModel { voiceLog.insert(VoiceEventLogEntry(time: now, description: "MOVE \(displayName) \(previous.channelName) -> \(next.channelName)"), at: 0) if allowPrimarySideEffects, - (shouldNotifyVoiceEvent(guildId: guildId, channelId: previous.channelId) || shouldNotifyVoiceEvent(guildId: guildId, channelId: next.channelId)) { + shouldNotifyVoiceEvent(guildId: guildId, channelId: previous.channelId) || shouldNotifyVoiceEvent(guildId: guildId, channelId: next.channelId) { let message = renderNotificationTemplate( settings.guildSettings[guildId]?.moveNotificationTemplate ?? GuildSettings().moveNotificationTemplate, username: displayName, @@ -917,5 +917,4 @@ extension AppModel { // GUILD_MEMBER_ADD is now handled via handleMemberJoin (P0.5). } - } diff --git a/SwiftBotApp/AppModel+Patchy.swift b/SwiftBotApp/AppModel+Patchy.swift new file mode 100644 index 0000000..df06234 --- /dev/null +++ b/SwiftBotApp/AppModel+Patchy.swift @@ -0,0 +1,513 @@ +import Foundation +import SwiftUI + +extension AppModel { + + // MARK: - Patchy Update Monitoring + + func addPatchyTarget(_ target: PatchySourceTarget) { + settings.patchy.sourceTargets.append(target) + saveSettings() + resolveSteamNameIfNeeded(for: target) + } + + func updatePatchyTarget(_ target: PatchySourceTarget) { + guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == target.id }) else { return } + settings.patchy.sourceTargets[idx] = target + saveSettings() + resolveSteamNameIfNeeded(for: target) + } + + func deletePatchyTarget(_ targetID: UUID) { + settings.patchy.sourceTargets.removeAll { $0.id == targetID } + saveSettings() + } + + func togglePatchyTargetEnabled(_ targetID: UUID) { + guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == targetID }) else { return } + settings.patchy.sourceTargets[idx].isEnabled.toggle() + saveSettings() + } + + func setPatchyTargetEnabled(_ targetID: UUID, enabled: Bool) { + guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == targetID }) else { return } + settings.patchy.sourceTargets[idx].isEnabled = enabled + saveSettings() + } + + func runPatchyManualCheck() { + Task { + await runPatchyMonitoringCycle(trigger: "Manual") + } + } + + private func validatePatchyTarget(_ target: PatchySourceTarget, forceRefresh: Bool = false) async -> (isValid: Bool, detail: String) { + let channelId = target.channelId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !channelId.isEmpty else { + return (false, "Target channel ID is empty.") + } + + let now = Date() + if !forceRefresh, let cached = patchyTargetValidationCache[channelId], now.timeIntervalSince(cached.validatedAt) < 3600 { + return (cached.isValid, cached.detail) + } + + do { + _ = try await service.fetchChannel(channelId: channelId, token: settings.token) + let result = (true, "Ready") + patchyTargetValidationCache[channelId] = (result.0, result.1, now) + return result + } catch { + let detail = patchyErrorDiagnostic(from: error) + let result = (false, detail) + patchyTargetValidationCache[channelId] = (result.0, result.1, now) + return result + } + } + + func sendPatchyTest(targetID: UUID) { + Task { + guard let target = settings.patchy.sourceTargets.first(where: { $0.id == targetID }) else { return } + guard !target.channelId.isEmpty else { + appendPatchyLog("Test send skipped: target channel is empty.") + return + } + + let validation = await validatePatchyTarget(target, forceRefresh: true) + guard validation.isValid else { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = validation.detail + } + persistSettingsQuietly() + appendPatchyLog("Patchy test skipped: \(validation.detail)") + return + } + + do { + resolveSteamNameIfNeeded(for: target) + let source = try PatchyRuntime.makeSource(from: target) + let item = try await source.fetchLatest() + let mapped = PatchyRuntime.map(item: item, change: .unchanged(identifier: item.identifier)) + let fallback = PatchyRuntime.fallbackMessage(for: mapped) + let delivery = await sendPatchyNotificationDetailed( + channelId: target.channelId, + message: fallback, + embedJSON: mapped.embedJSON, + roleIDs: target.roleIDs + ) + + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastRunAt = Date() + entry.lastStatus = delivery.detail + } + persistSettingsQuietly() + appendPatchyLog("Test send [\(target.source.rawValue)] -> \(delivery.detail)") + } catch { + let diagnostic = patchyErrorDiagnostic(from: error) + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = "Patchy test failed: \(diagnostic)" + } + persistSettingsQuietly() + appendPatchyLog("Patchy test failed: \(diagnostic)") + } + } + } + + func pullPatchyUpdate(targetID: UUID) { + Task { + guard let target = settings.patchy.sourceTargets.first(where: { $0.id == targetID }) else { return } + + do { + resolveSteamNameIfNeeded(for: target) + let source = try PatchyRuntime.makeSource(from: target) + let item = try await source.fetchLatest() + let mapped = PatchyRuntime.map(item: item, change: .unchanged(identifier: item.identifier)) + + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = mapped.statusSummary + } + persistSettingsQuietly() + appendPatchyLog("Pull [\(target.source.rawValue)] -> \(mapped.statusSummary)") + } catch { + let diagnostic = patchyErrorDiagnostic(from: error) + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = "Pull failed: \(diagnostic)" + } + persistSettingsQuietly() + appendPatchyLog("Pull [\(target.source.rawValue)] failed: \(diagnostic)") + } + } + } + + func configurePatchyMonitoring() { + patchyMonitorTask?.cancel() + patchyMonitorTask = nil + + guard settings.patchy.monitoringEnabled else { + appendPatchyLog("Patchy monitoring paused.") + return + } + + patchyMonitorTask = Task { [weak self] in + guard let self else { return } + await self.runPatchyMonitoringCycle(trigger: "Startup") + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 3_600_000_000_000) + if Task.isCancelled { break } + await self.runPatchyMonitoringCycle(trigger: "Scheduled") + } + } + appendPatchyLog("Patchy monitoring started (hourly).") + } + + struct PatchySourceGroupKey: Hashable { + let source: PatchySourceKind + let steamAppID: String + } + + func runPatchyMonitoringCycle(trigger: String) async { + guard !patchyIsCycleRunning else { return } + guard let patchyChecker else { + appendPatchyLog("Patchy checker unavailable. Cycle skipped.") + return + } + + let enabledTargets = settings.patchy.sourceTargets.filter { $0.isEnabled && !$0.channelId.isEmpty } + guard !enabledTargets.isEmpty else { + appendPatchyLog("Patchy cycle (\(trigger)) skipped: no enabled targets.") + patchyLastCycleAt = Date() + return + } + + patchyIsCycleRunning = true + defer { + patchyIsCycleRunning = false + patchyLastCycleAt = Date() + } + + let grouped = Dictionary(grouping: enabledTargets) { target in + PatchySourceGroupKey( + source: target.source, + steamAppID: target.steamAppID.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + for (_, targets) in grouped { + guard let referenceTarget = targets.first else { continue } + + do { + resolveSteamNameIfNeeded(for: referenceTarget) + let source = try PatchyRuntime.makeSource(from: referenceTarget) + let item = try await source.fetchLatest() + let mapped: PatchyFetchResult + if let driverItem = item as? DriverUpdateItem { + let newestVersion = driverItem.version.trimmingCharacters(in: .whitespacesAndNewlines) + let versionKey = PatchyRuntime.lastPostedDriverVersionKey(for: item.sourceKey) + let versionCheck = try await patchyChecker.check(identifier: newestVersion, for: versionKey) + mapped = PatchyRuntime.map(item: item, change: versionCheck) + for target in targets { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = mapped.statusSummary + } + } + + switch versionCheck { + case .firstSeen: + try await patchyChecker.save(identifier: newestVersion, for: versionKey) + appendPatchyLog("Patchy driver baseline initialized [\(referenceTarget.source.rawValue)] version=\(newestVersion)") + case .unchanged: + break + case .changed(let oldVersion, _): + guard let comparison = PatchyRuntime.compareDriverVersions(newestVersion, oldVersion) else { + try await patchyChecker.save(identifier: newestVersion, for: versionKey) + appendPatchyLog("Patchy migrated legacy driver baseline [\(referenceTarget.source.rawValue)] old=\(oldVersion) new=\(newestVersion)") + break + } + + guard comparison > 0 else { + appendPatchyLog("Patchy ignored non-newer driver [\(referenceTarget.source.rawValue)] latest=\(newestVersion) lastPosted=\(oldVersion)") + break + } + + let fallback = PatchyRuntime.fallbackMessage(for: mapped) + for target in targets { + let validation = await validatePatchyTarget(target) + guard validation.isValid else { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = validation.detail + } + appendPatchyLog("Patchy cycle [\(target.source.rawValue)] skipped target \(target.channelId): \(validation.detail)") + continue + } + + let delivery = await sendPatchyNotificationDetailed( + channelId: target.channelId, + message: fallback, + embedJSON: mapped.embedJSON, + roleIDs: target.roleIDs + ) + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastRunAt = Date() + entry.lastStatus = delivery.detail + } + if delivery.ok { + try await patchyChecker.save(identifier: newestVersion, for: versionKey) + } + } + } + } else if let steamItem = item as? SteamUpdateItem { + let newestStamp = PatchyRuntime.makeSteamOrderingStamp(item: steamItem) + let steamKey = PatchyRuntime.lastPostedSteamIdentifierKey(for: item.sourceKey) + let steamCheck = try await patchyChecker.check(identifier: newestStamp, for: steamKey) + mapped = PatchyRuntime.map(item: item, change: steamCheck) + + for target in targets { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = mapped.statusSummary + } + } + + switch steamCheck { + case .firstSeen: + try await patchyChecker.save(identifier: newestStamp, for: steamKey) + appendPatchyLog("Patchy Steam baseline initialized [\(referenceTarget.steamAppID)] stamp=\(newestStamp)") + case .unchanged: + break + case .changed(let oldStamp, _): + guard let comparison = PatchyRuntime.compareSteamOrderingStamp(newestStamp, oldStamp) else { + try await patchyChecker.save(identifier: newestStamp, for: steamKey) + appendPatchyLog("Patchy migrated legacy Steam baseline [\(referenceTarget.steamAppID)] old=\(oldStamp) new=\(newestStamp)") + break + } + + guard comparison > 0 else { + appendPatchyLog("Patchy ignored non-newer Steam item [\(referenceTarget.steamAppID)] latest=\(newestStamp) lastPosted=\(oldStamp)") + break + } + + let fallback = PatchyRuntime.fallbackMessage(for: mapped) + for target in targets { + let validation = await validatePatchyTarget(target) + guard validation.isValid else { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = validation.detail + } + appendPatchyLog("Patchy cycle [\(target.source.rawValue)] skipped target \(target.channelId): \(validation.detail)") + continue + } + + let delivery = await sendPatchyNotificationDetailed( + channelId: target.channelId, + message: fallback, + embedJSON: mapped.embedJSON, + roleIDs: target.roleIDs + ) + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastRunAt = Date() + entry.lastStatus = delivery.detail + } + if delivery.ok { + try await patchyChecker.save(identifier: newestStamp, for: steamKey) + } + } + } + } else { + let change = try await patchyChecker.check(item: item) + try await patchyChecker.save(item: item) + mapped = PatchyRuntime.map(item: item, change: change) + + for target in targets { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = mapped.statusSummary + } + } + + if change.isNewItem { + let fallback = PatchyRuntime.fallbackMessage(for: mapped) + for target in targets { + let validation = await validatePatchyTarget(target) + guard validation.isValid else { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = validation.detail + } + appendPatchyLog("Patchy cycle [\(target.source.rawValue)] skipped target \(target.channelId): \(validation.detail)") + continue + } + + let delivery = await sendPatchyNotificationDetailed( + channelId: target.channelId, + message: fallback, + embedJSON: mapped.embedJSON, + roleIDs: target.roleIDs + ) + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastRunAt = Date() + entry.lastStatus = delivery.detail + } + } + } + } + } catch { + for target in targets { + updatePatchyTargetRuntimeState(id: target.id) { entry in + entry.lastCheckedAt = Date() + entry.lastStatus = "Patchy check failed: \(error.localizedDescription)" + } + } + appendPatchyLog("Patchy cycle \(referenceTarget.source.rawValue) failed: \(error.localizedDescription)") + } + } + + persistSettingsQuietly() + } + + func updatePatchyTargetRuntimeState(id: UUID, apply: (inout PatchySourceTarget) -> Void) { + guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == id }) else { return } + var target = settings.patchy.sourceTargets[idx] + apply(&target) + settings.patchy.sourceTargets[idx] = target + } + + func appendPatchyLog(_ line: String) { + let stamp = ISO8601DateFormatter().string(from: Date()) + let final = "[\(stamp)] \(line)" + patchyDebugLogs.insert(final, at: 0) + if patchyDebugLogs.count > 200 { + patchyDebugLogs.removeLast(patchyDebugLogs.count - 200) + } + logs.append("Patchy: \(line)") + } + + func persistSettingsQuietly() { + let snapshot = settings + Task { + do { + try await store.save(snapshot) + try await swiftMeshConfigStore.save(snapshot.swiftMeshSettings) + } catch { + await MainActor.run { + self.logs.append("❌ Failed saving settings: \(error.localizedDescription)") + } + } + } + } + + func migrateLegacyPatchySettingsIfNeeded(_ loaded: inout BotSettings) -> Bool { + guard loaded.patchy.sourceTargets.isEmpty, !loaded.patchy.targets.isEmpty else { + return false + } + + let migratedTargets = loaded.patchy.targets.map { legacy in + PatchySourceTarget( + isEnabled: legacy.isEnabled, + source: loaded.patchy.source, + steamAppID: loaded.patchy.steamAppID, + serverId: legacy.serverId, + channelId: legacy.channelId, + roleIDs: legacy.roleIDs + ) + } + + loaded.patchy.sourceTargets = migratedTargets + return true + } + + func migrateLegacyWikiBridgeSettingsIfNeeded(_ loaded: inout BotSettings) -> Bool { + let previousTargets = loaded.wikiBot.sources.count + let previousPrimary = loaded.wikiBot.sources.first(where: { $0.isPrimary })?.id + loaded.wikiBot.normalizeSources() + let currentPrimary = loaded.wikiBot.sources.first(where: { $0.isPrimary })?.id + return previousTargets != loaded.wikiBot.sources.count || previousPrimary != currentPrimary + } + + func patchyErrorDiagnostic(from error: Error) -> String { + let ns = error as NSError + let statusCode = ns.userInfo["statusCode"] as? Int ?? ns.code + let body = (ns.userInfo["responseBody"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // Try to parse Discord's specific error code from the JSON body + var discordCode: Int? + if let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let code = json["code"] as? Int { + discordCode = code + } + + // Map to HIG-aligned, actionable messages + switch (statusCode, discordCode) { + case (403, 50001?): + return "SwiftBot cannot view this channel. Check permissions in the Discord server." + case (403, 50013?): + return "SwiftBot lacks 'Embed Links' or 'Mention' permissions in this channel." + case (404, 10003?): + return "Channel not found. It may have been deleted — please remove or update this target." + case (401, _): + return "Invalid Bot Token. Please check your token in General Settings." + case (429, _): + return "Sending too fast. Discord is temporarily limiting requests." + default: + if !body.isEmpty && body != "-" { + let trimmedBody = body.count > 120 ? String(body.prefix(117)) + "..." : body + return "Failed to send (HTTP \(statusCode)). Details: \(trimmedBody)" + } + return "Failed to send (HTTP \(statusCode)). Check Patchy logs for details." + } + } + + func resolveSteamNameIfNeeded(for target: PatchySourceTarget) { + guard target.source == .steam else { return } + let appID = target.steamAppID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !appID.isEmpty else { return } + if let existing = settings.patchy.steamAppNames[appID], !existing.isEmpty { + return + } + + Task { + if let name = await fetchSteamAppName(appID: appID) { + await MainActor.run { + self.settings.patchy.steamAppNames[appID] = name + self.persistSettingsQuietly() + } + } + } + } + + func fetchSteamAppName(appID: String) async -> String? { + guard let url = URL(string: "https://store.steampowered.com/api/appdetails?appids=\(appID)&l=english") else { + return nil + } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + return nil + } + guard + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let appNode = root[appID] as? [String: Any], + let success = appNode["success"] as? Bool, success, + let dataNode = appNode["data"] as? [String: Any], + let name = dataNode["name"] as? String + else { + return nil + } + + let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } catch { + return nil + } + } + +} diff --git a/SwiftBotApp/AppModel+SlashCommandHelpers.swift b/SwiftBotApp/AppModel+SlashCommandHelpers.swift index e223892..3287fa3 100644 --- a/SwiftBotApp/AppModel+SlashCommandHelpers.swift +++ b/SwiftBotApp/AppModel+SlashCommandHelpers.swift @@ -9,6 +9,7 @@ extension AppModel { ["name": "8ball", "description": "Ask the magic 8-ball", "type": 1, "options": [["type": 3, "name": "question", "description": "Your yes/no question", "required": true]]], ["name": "poll", "description": "Create a simple poll prompt", "type": 1, "options": [["type": 3, "name": "question", "description": "Poll question", "required": true]]], ["name": "userinfo", "description": "Show user info", "type": 1], + // swiftlint:disable:next line_length ["name": "cluster", "description": "Cluster status/probe/test", "type": 1, "options": [["type": 3, "name": "action", "description": "status | test | probe", "required": false, "choices": [["name": "status", "value": "status"], ["name": "test", "value": "test"], ["name": "probe", "value": "probe"]]]]], ["name": "debug", "description": "Admin diagnostics", "type": 1], ["name": "notifystatus", "description": "Show notification config status", "type": 1], diff --git a/SwiftBotApp/AppModel+WikiBridge.swift b/SwiftBotApp/AppModel+WikiBridge.swift new file mode 100644 index 0000000..105dd87 --- /dev/null +++ b/SwiftBotApp/AppModel+WikiBridge.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftUI + +extension AppModel { + + // MARK: - Wiki Bridge + + func addWikiBridgeSourceTarget(_ target: WikiSource) { + settings.wikiBot.sources.append(target) + settings.wikiBot.normalizeSources() + saveSettings() + } + + func updateWikiBridgeSourceTarget(_ target: WikiSource) { + guard let idx = settings.wikiBot.sources.firstIndex(where: { $0.id == target.id }) else { return } + settings.wikiBot.sources[idx] = target + settings.wikiBot.normalizeSources() + saveSettings() + } + + func deleteWikiBridgeSourceTarget(_ targetID: UUID) { + settings.wikiBot.sources.removeAll { $0.id == targetID } + settings.wikiBot.normalizeSources() + saveSettings() + } + + func toggleWikiBridgeSourceTargetEnabled(_ targetID: UUID) { + guard let idx = settings.wikiBot.sources.firstIndex(where: { $0.id == targetID }) else { return } + settings.wikiBot.sources[idx].enabled.toggle() + settings.wikiBot.normalizeSources() + saveSettings() + } + + func setWikiBridgePrimarySource(_ targetID: UUID) { + settings.wikiBot.setPrimarySource(targetID) + settings.wikiBot.normalizeSources() + saveSettings() + } + + func testWikiBridgeSource(targetID: UUID) { + Task { + guard let target = settings.wikiBot.sources.first(where: { $0.id == targetID }) else { return } + let usesWeaponCommand = target.commands.contains { normalizedWikiCommandTrigger($0.trigger) == "weapon" } + let testQuery = usesWeaponCommand ? "AKM" : "Main Page" + let result = await wikiLookupService.lookupWiki(query: testQuery, source: target) + updateWikiBridgeSourceRuntimeState(id: targetID) { entry in + entry.lastLookupAt = Date() + if let result { + entry.lastStatus = "Resolved: \(result.title)" + } else { + entry.lastStatus = "No result for \"\(testQuery)\"" + } + } + persistSettingsQuietly() + } + } + + func runWikiBridgeSourceTestQuery(source: WikiSource, query: String) async -> FinalsWikiLookupResult? { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return await wikiLookupService.lookupWiki(query: trimmed, source: source) + } + + func updateWikiBridgeSourceRuntimeState(id: UUID, apply: (inout WikiSource) -> Void) { + guard let idx = settings.wikiBot.sources.firstIndex(where: { $0.id == id }) else { return } + var target = settings.wikiBot.sources[idx] + apply(&target) + settings.wikiBot.sources[idx] = target + } + +} diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index a3afb5c..107d54f 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -3,319 +3,8 @@ import AVFoundation import CryptoKit import Foundation 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 AdminWebCertificateRenewalConfiguration: Equatable { - let enabled: Bool - let domain: String - let cloudflareToken: String -} - -actor MediaLibraryIndexer { - private struct CacheEntry { - let signature: String - let payload: MediaLibraryPayload - let createdAt: Date - } - - private var cachedEntry: CacheEntry? - private let cacheTTL: TimeInterval = 30 - - func cachedItem(for id: String) -> MediaLibraryItem? { - cachedEntry?.payload.items.first(where: { $0.id == id }) - } - - func invalidate() { - cachedEntry = nil - } - - func snapshot( - sources: [MediaLibrarySource], - ownerNodeName: String, - ownerBaseURL: String?, - configFilePath: String - ) -> MediaLibraryPayload { - let signature = makeSignature(sources: sources, ownerNodeName: ownerNodeName, ownerBaseURL: ownerBaseURL, configFilePath: configFilePath) - if let cachedEntry, cachedEntry.signature == signature, Date().timeIntervalSince(cachedEntry.createdAt) < cacheTTL { - return cachedEntry.payload - } - - let payload = MediaLibraryPayload( - nodeName: ownerNodeName, - configFilePath: configFilePath, - sources: sources, - items: scanItems(sources: sources, ownerNodeName: ownerNodeName, ownerBaseURL: ownerBaseURL), - generatedAt: Date() - ) - cachedEntry = CacheEntry(signature: signature, payload: payload, createdAt: Date()) - return payload - } - - private func makeSignature( - sources: [MediaLibrarySource], - ownerNodeName: String, - ownerBaseURL: String?, - configFilePath: String - ) -> String { - let sourceSignature = sources.map { - "\($0.id.uuidString)|\($0.name)|\($0.normalizedRootPath)|\($0.isEnabled)|\($0.normalizedExtensions.joined(separator: ","))" - }.joined(separator: "||") - return "\(ownerNodeName)|\(ownerBaseURL ?? "")|\(configFilePath)|\(sourceSignature)" - } - - private func scanItems( - sources: [MediaLibrarySource], - ownerNodeName: String, - ownerBaseURL: String? - ) -> [MediaLibraryItem] { - let fileManager = FileManager.default - var items: [MediaLibraryItem] = [] - - for source in sources where source.isEnabled { - let root = source.normalizedRootPath - guard !root.isEmpty else { continue } - - let rootURL = URL(fileURLWithPath: root, isDirectory: true) - guard let enumerator = fileManager.enumerator( - at: rootURL, - includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants] - ) else { continue } - - let allowedExtensions = Set(source.normalizedExtensions) - for case let fileURL as URL in enumerator { - let ext = fileURL.pathExtension.lowercased() - guard allowedExtensions.isEmpty || allowedExtensions.contains(ext) else { continue } - guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]), - values.isRegularFile == true else { continue } - - let relativePath = fileURL.path.replacingOccurrences(of: rootURL.path + "/", with: "") - let id = "\(source.id.uuidString)|\(relativePath)" - items.append( - MediaLibraryItem( - id: id, - sourceID: source.id, - sourceName: source.name, - fileName: fileURL.lastPathComponent, - relativePath: relativePath, - absolutePath: fileURL.path, - fileExtension: ext, - sizeBytes: Int64(values.fileSize ?? 0), - modifiedAt: values.contentModificationDate ?? .distantPast, - ownerNodeName: ownerNodeName, - ownerBaseURL: ownerBaseURL - ) - ) - } - } - - return items.sorted { - if $0.modifiedAt != $1.modifiedAt { return $0.modifiedAt > $1.modifiedAt } - return $0.fileName.localizedCaseInsensitiveCompare($1.fileName) == .orderedAscending - } - } -} - -actor MediaThumbnailCache { - private let fileManager = FileManager.default - - private func cacheDirectoryURL() -> URL { - let url = SwiftBotStorage.folderURL().appendingPathComponent("media-thumbnails", isDirectory: true) - try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - func thumbnailResponse(for item: MediaLibraryItem) async -> BinaryHTTPResponse? { - let cacheURL = cachedThumbnailURL(for: item) - if let data = try? Data(contentsOf: cacheURL) { - return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=300"], body: data) - } - - guard let image = await generateThumbnail(for: item) else { return nil } - guard let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff), - let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.78]) else { - return nil - } - try? jpeg.write(to: cacheURL, options: .atomic) - return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=300"], body: jpeg) - } - - func frameResponse(for item: MediaLibraryItem, atSeconds: Double) async -> BinaryHTTPResponse? { - let cacheURL = cachedFrameURL(for: item, atSeconds: atSeconds) - if let data = try? Data(contentsOf: cacheURL) { - return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=120"], body: data) - } - - guard let image = await generateFrame(for: item, atSeconds: atSeconds) else { return nil } - guard let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff), - let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.72]) else { - return nil - } - try? jpeg.write(to: cacheURL, options: .atomic) - return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=120"], body: jpeg) - } - - private func cachedThumbnailURL(for item: MediaLibraryItem) -> URL { - let fingerprint = "\(item.absolutePath)|\(item.modifiedAt.timeIntervalSince1970)|\(item.sizeBytes)" - let digest = SHA256.hash(data: Data(fingerprint.utf8)).map { String(format: "%02x", $0) }.joined() - return cacheDirectoryURL().appendingPathComponent("\(digest).jpg") - } - - private func cachedFrameURL(for item: MediaLibraryItem, atSeconds: Double) -> URL { - let rounded = String(format: "%.1f", max(0, atSeconds)) - let fingerprint = "\(item.absolutePath)|\(item.modifiedAt.timeIntervalSince1970)|\(item.sizeBytes)|frame|\(rounded)" - let digest = SHA256.hash(data: Data(fingerprint.utf8)).map { String(format: "%02x", $0) }.joined() - let folder = cacheDirectoryURL().appendingPathComponent("frames", isDirectory: true) - try? fileManager.createDirectory(at: folder, withIntermediateDirectories: true) - return folder.appendingPathComponent("\(digest).jpg") - } - - private func generateThumbnail(for item: MediaLibraryItem) async -> NSImage? { - let url = URL(fileURLWithPath: item.absolutePath) - let asset = AVURLAsset(url: url) - let generator = AVAssetImageGenerator(asset: asset) - generator.appliesPreferredTrackTransform = true - generator.maximumSize = CGSize(width: 640, height: 360) - - let time = CMTime(seconds: 2, preferredTimescale: 600) - return await withCheckedContinuation { continuation in - generator.generateCGImageAsynchronously(for: time) { cgImage, _, error in - guard let cgImage, error == nil else { - continuation.resume(returning: nil) - return - } - continuation.resume(returning: NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))) - } - } - } - - private func generateFrame(for item: MediaLibraryItem, atSeconds: Double) async -> NSImage? { - let url = URL(fileURLWithPath: item.absolutePath) - let asset = AVURLAsset(url: url) - let generator = AVAssetImageGenerator(asset: asset) - generator.appliesPreferredTrackTransform = true - generator.maximumSize = CGSize(width: 420, height: 240) - - let time = CMTime(seconds: max(0, atSeconds), preferredTimescale: 600) - return await withCheckedContinuation { continuation in - generator.generateCGImageAsynchronously(for: time) { cgImage, _, error in - guard let cgImage, error == nil else { - continuation.resume(returning: nil) - return - } - continuation.resume(returning: NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))) - } - } - } -} - -enum AdminWebAutomaticHTTPSSetupEvent: Sendable, Equatable { - case verifyingCloudflareAccess - case cloudflareAccessVerified - case detectingCloudflareZone(domain: String) - case cloudflareZoneDetected(zone: String) - case creatingDNSChallengeRecord(recordName: String) - case dnsChallengeRecordCreated(recordName: String, reusedExistingRecord: Bool) - case waitingForDNSPropagation(recordName: String) - case dnsChallengeRecordPropagated(recordName: String) - case dnsChallengeRecordVerified(recordName: String, reusedExistingRecord: Bool) - case requestingTLSCertificate(domain: String) - case tlsCertificateIssued(domain: String) - case storingCertificate - case certificateStored(path: String) - case enablingHTTPSListener - case httpsListenerEnabled(url: String) -} - -enum AdminWebPublicAccessSetupEvent: Sendable, Equatable { - case verifyingCloudflareAccess - case cloudflareAccessVerified - case detectingCloudflareZone(domain: String) - case cloudflareZoneDetected(zone: String) - case creatingTunnel(hostname: String) - case tunnelCreated(name: String) - case tunnelDetected(name: String) - case creatingTunnelDNSRecord(hostname: String) - case tunnelDNSRecordCreated(hostname: String) - case storingTunnelCredentials - case startingTunnelProcess - case publicAccessEnabled(url: String) -} - -enum InternetAccessSetupEvent: Sendable, Equatable { - case verifyingCloudflareAccess - case cloudflareAccessVerified - case detectingCloudflareZone(domain: String) - case cloudflareZoneDetected(zone: String) - case creatingTunnel(hostname: String) - case tunnelCreated(name: String) - case tunnelDetected(name: String) - case creatingTunnelDNSRecord(hostname: String) - case tunnelDNSRecordCreated(hostname: String) - case issuingHTTPSCertificate(hostname: String) - case httpsCertificateIssued(hostname: String) - case startingCloudflareTunnel - case cloudflareTunnelStarted - case internetAccessEnabled(url: String) -} - -enum AdminWebHTTPSProvisioningError: LocalizedError { - case tlsActivationFailed - - var errorDescription: String? { - switch self { - case .tlsActivationFailed: - return "The certificate was issued, but SwiftBot could not start the Admin Web UI over HTTPS. Check the logs and TLS files, then try again." - } - } -} - -enum AdminWebPublicAccessError: LocalizedError { - case missingHostname - case invalidOriginURL - case tunnelStartupFailed(String) - - var errorDescription: String? { - switch self { - case .missingHostname: - return "Enter a public hostname before enabling Public Access." - case .invalidOriginURL: - return "SwiftBot could not determine the local Web UI address for Cloudflare Tunnel." - case .tunnelStartupFailed(let detail): - return detail - } - } -} - -let genericAdminWebHTTPSSetupFailureMessage = "HTTPS setup couldn’t be completed. Verify Cloudflare access and DNS propagation, then try again." -let genericAdminWebPublicAccessFailureMessage = "Public Access couldn’t be completed. Verify the hostname, Cloudflare access, and tunnel configuration, then try again." - @MainActor final class AppModel: ObservableObject { @Published var settings = BotSettings() @@ -343,8 +32,8 @@ final class AppModel: ObservableObject { @Published var workerConnectionTestStatus: String = "Not tested" @Published var workerConnectionTestIsSuccess = false @Published var workerConnectionTestInProgress = false - @Published var workerConnectionTestOutcome: WorkerConnectionTestOutcome? = nil - @Published var lastClusterStatusRefreshAt: Date? = nil + @Published var workerConnectionTestOutcome: WorkerConnectionTestOutcome? + @Published var lastClusterStatusRefreshAt: Date? @Published var appleIntelligenceOnline = false @Published var ollamaOnline = false @Published var openAIOnline = false @@ -363,17 +52,17 @@ final class AppModel: ObservableObject { @Published var connectionDiagnostics = ConnectionDiagnostics() /// Date after which another Test Connection is allowed (10s UI rate limit). - @Published var testConnectionCooldownUntil: Date? = nil + @Published var testConnectionCooldownUntil: Date? /// `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 { @@ -381,15 +70,15 @@ final class AppModel: ObservableObject { 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) @@ -398,11 +87,11 @@ final class AppModel: ObservableObject { } provider = localProviderBox } - + /// OAuth2 client ID resolved from a validated token; used to build the invite URL. - @Published var resolvedClientID: String? = nil + @Published var resolvedClientID: String? /// Result from the most recent rich token validation; exposed for onboarding UI error display. - @Published var lastTokenValidationResult: DiscordService.TokenValidationResult? = nil + @Published var lastTokenValidationResult: DiscordService.TokenValidationResult? let isBetaBuild: Bool = (Bundle.main.object(forInfoDictionaryKey: "ShipHookIsBetaBuild") as? Bool) ?? false var logs = LogStore() @@ -417,11 +106,11 @@ final class AppModel: ObservableObject { let mediaThumbnailCache = MediaThumbnailCache() let mediaExportCoordinator = MediaExportCoordinator() let discordCache = DiscordCache() - + /// Shared session for general Discord REST API calls (gateway, guild, message operations). /// Uses default configuration for connection pooling and reuse. let discordRESTSession = URLSession(configuration: .default) - + /// Dedicated session for Discord identity/token validation calls. /// Uses ephemeral configuration: no disk cache, no credential storage, short timeout. /// This ensures token validation responses are never cached and credentials aren't persisted. @@ -433,7 +122,7 @@ final class AppModel: ObservableObject { return c }() let identitySession = URLSession(configuration: AppModel.identitySessionConfig) - + lazy var aiService = DiscordAIService(session: discordRESTSession) lazy var identityRESTClient = DiscordIdentityRESTClient( session: discordRESTSession, @@ -476,10 +165,10 @@ final class AppModel: ObservableObject { let commandCooldown: TimeInterval = 3.0 let maxMediaClipDurationSeconds: Double = 15 * 60 let aiMemoryStopwords: Set = [ - "a","an","and","are","as","at","be","but","by","for","from","hey","how", - "i","if","in","into","is","it","its","me","my","of","on","or","our","so", - "that","the","their","them","then","there","these","they","this","to","up", - "use","was","we","what","when","where","which","who","why","with","you","your" + "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from", "hey", "how", + "i", "if", "in", "into", "is", "it", "its", "me", "my", "of", "on", "or", "our", "so", + "that", "the", "their", "them", "then", "there", "these", "they", "this", "to", "up", + "use", "was", "we", "what", "when", "where", "which", "who", "why", "with", "you", "your" ] lazy var memoryViewModel = MemoryViewModel(store: conversationStore, discordCache: discordCache) let eventBus = EventBus() @@ -508,20 +197,20 @@ final class AppModel: ObservableObject { // Max cache entries to prevent unbounded memory growth during extended operation private let maxAvatarCacheCount = 1000 - private func cacheUserAvatar(_ hash: String, for userId: String) { + func cacheUserAvatar(_ hash: String, for userId: String) { userAvatarHashById[userId] = hash if userAvatarHashById.count > maxAvatarCacheCount { userAvatarHashById.keys.prefix(200).forEach { userAvatarHashById.removeValue(forKey: $0) } } } - private func cacheGuildAvatar(_ hash: String, for key: String) { + func cacheGuildAvatar(_ hash: String, for key: String) { guildAvatarHashByMemberKey[key] = hash if guildAvatarHashByMemberKey.count > maxAvatarCacheCount { guildAvatarHashByMemberKey.keys.prefix(200).forEach { guildAvatarHashByMemberKey.removeValue(forKey: $0) } } } - + @Published var mediaLibrarySettings = MediaLibrarySettings() @Published var mediaExportJobs: [MediaExportJob] = [] var lastSlashRegistrationAt: Date? @@ -648,7 +337,7 @@ final class AppModel: ObservableObject { settings = loadedSettings isOnboardingComplete = onboardingCompleted(for: loadedSettings) - + // Initialize the appropriate data provider await MainActor.run { self.updateProvider() @@ -917,7 +606,6 @@ final class AppModel: ObservableObject { await cluster.pushSyncPayloadToNodes(payload) } - // MARK: - Media (see AppModel+Media.swift) func detectOllamaModel() { @@ -975,503 +663,14 @@ final class AppModel: ObservableObject { return settings.openAIAPIKey } - func addPatchyTarget(_ target: PatchySourceTarget) { - settings.patchy.sourceTargets.append(target) - saveSettings() - resolveSteamNameIfNeeded(for: target) - } - - func addWikiBridgeSourceTarget(_ target: WikiSource) { - settings.wikiBot.sources.append(target) - settings.wikiBot.normalizeSources() - saveSettings() - } - - func updateWikiBridgeSourceTarget(_ target: WikiSource) { - guard let idx = settings.wikiBot.sources.firstIndex(where: { $0.id == target.id }) else { return } - settings.wikiBot.sources[idx] = target - settings.wikiBot.normalizeSources() - saveSettings() - } - - func deleteWikiBridgeSourceTarget(_ targetID: UUID) { - settings.wikiBot.sources.removeAll { $0.id == targetID } - settings.wikiBot.normalizeSources() - saveSettings() - } - - func toggleWikiBridgeSourceTargetEnabled(_ targetID: UUID) { - guard let idx = settings.wikiBot.sources.firstIndex(where: { $0.id == targetID }) else { return } - settings.wikiBot.sources[idx].enabled.toggle() - settings.wikiBot.normalizeSources() - saveSettings() - } - - func setWikiBridgePrimarySource(_ targetID: UUID) { - settings.wikiBot.setPrimarySource(targetID) - settings.wikiBot.normalizeSources() - saveSettings() - } - - func testWikiBridgeSource(targetID: UUID) { - Task { - guard let target = settings.wikiBot.sources.first(where: { $0.id == targetID }) else { return } - let usesWeaponCommand = target.commands.contains { normalizedWikiCommandTrigger($0.trigger) == "weapon" } - let testQuery = usesWeaponCommand ? "AKM" : "Main Page" - let result = await wikiLookupService.lookupWiki(query: testQuery, source: target) - updateWikiBridgeSourceRuntimeState(id: targetID) { entry in - entry.lastLookupAt = Date() - if let result { - entry.lastStatus = "Resolved: \(result.title)" - } else { - entry.lastStatus = "No result for \"\(testQuery)\"" - } - } - persistSettingsQuietly() - } - } - - func runWikiBridgeSourceTestQuery(source: WikiSource, query: String) async -> FinalsWikiLookupResult? { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return await wikiLookupService.lookupWiki(query: trimmed, source: source) - } - - func updatePatchyTarget(_ target: PatchySourceTarget) { - guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == target.id }) else { return } - settings.patchy.sourceTargets[idx] = target - saveSettings() - resolveSteamNameIfNeeded(for: target) - } - - func deletePatchyTarget(_ targetID: UUID) { - settings.patchy.sourceTargets.removeAll { $0.id == targetID } - saveSettings() - } - - func togglePatchyTargetEnabled(_ targetID: UUID) { - guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == targetID }) else { return } - settings.patchy.sourceTargets[idx].isEnabled.toggle() - saveSettings() - } - - func setPatchyTargetEnabled(_ targetID: UUID, enabled: Bool) { - guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == targetID }) else { return } - settings.patchy.sourceTargets[idx].isEnabled = enabled - saveSettings() - } - - func runPatchyManualCheck() { - Task { - await runPatchyMonitoringCycle(trigger: "Manual") - } - } - - private func validatePatchyTarget(_ target: PatchySourceTarget, forceRefresh: Bool = false) async -> (isValid: Bool, detail: String) { - let channelId = target.channelId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !channelId.isEmpty else { - return (false, "Target channel ID is empty.") - } - - let now = Date() - if !forceRefresh, let cached = patchyTargetValidationCache[channelId], now.timeIntervalSince(cached.validatedAt) < 3600 { - return (cached.isValid, cached.detail) - } - - do { - _ = try await service.fetchChannel(channelId: channelId, token: settings.token) - let result = (true, "Ready") - patchyTargetValidationCache[channelId] = (result.0, result.1, now) - return result - } catch { - let detail = patchyErrorDiagnostic(from: error) - let result = (false, detail) - patchyTargetValidationCache[channelId] = (result.0, result.1, now) - return result - } - } - - func sendPatchyTest(targetID: UUID) { - Task { - guard let target = settings.patchy.sourceTargets.first(where: { $0.id == targetID }) else { return } - guard !target.channelId.isEmpty else { - appendPatchyLog("Test send skipped: target channel is empty.") - return - } - - let validation = await validatePatchyTarget(target, forceRefresh: true) - guard validation.isValid else { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = validation.detail - } - persistSettingsQuietly() - appendPatchyLog("Patchy test skipped: \(validation.detail)") - return - } - - do { - resolveSteamNameIfNeeded(for: target) - let source = try PatchyRuntime.makeSource(from: target) - let item = try await source.fetchLatest() - let mapped = PatchyRuntime.map(item: item, change: .unchanged(identifier: item.identifier)) - let fallback = PatchyRuntime.fallbackMessage(for: mapped) - let delivery = await sendPatchyNotificationDetailed( - channelId: target.channelId, - message: fallback, - embedJSON: mapped.embedJSON, - roleIDs: target.roleIDs - ) - - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastRunAt = Date() - entry.lastStatus = delivery.detail - } - persistSettingsQuietly() - appendPatchyLog("Test send [\(target.source.rawValue)] -> \(delivery.detail)") - } catch { - let diagnostic = patchyErrorDiagnostic(from: error) - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = "Patchy test failed: \(diagnostic)" - } - persistSettingsQuietly() - appendPatchyLog("Patchy test failed: \(diagnostic)") - } - } - } - - func pullPatchyUpdate(targetID: UUID) { - Task { - guard let target = settings.patchy.sourceTargets.first(where: { $0.id == targetID }) else { return } - - do { - resolveSteamNameIfNeeded(for: target) - let source = try PatchyRuntime.makeSource(from: target) - let item = try await source.fetchLatest() - let mapped = PatchyRuntime.map(item: item, change: .unchanged(identifier: item.identifier)) - - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = mapped.statusSummary - } - persistSettingsQuietly() - appendPatchyLog("Pull [\(target.source.rawValue)] -> \(mapped.statusSummary)") - } catch { - let diagnostic = patchyErrorDiagnostic(from: error) - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = "Pull failed: \(diagnostic)" - } - persistSettingsQuietly() - appendPatchyLog("Pull [\(target.source.rawValue)] failed: \(diagnostic)") - } - } - } - - func configurePatchyMonitoring() { - patchyMonitorTask?.cancel() - patchyMonitorTask = nil - - guard settings.patchy.monitoringEnabled else { - appendPatchyLog("Patchy monitoring paused.") - return - } - - patchyMonitorTask = Task { [weak self] in - guard let self else { return } - await self.runPatchyMonitoringCycle(trigger: "Startup") - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 3_600_000_000_000) - if Task.isCancelled { break } - await self.runPatchyMonitoringCycle(trigger: "Scheduled") - } - } - appendPatchyLog("Patchy monitoring started (hourly).") - } - - struct PatchySourceGroupKey: Hashable { - let source: PatchySourceKind - let steamAppID: String - } - - func runPatchyMonitoringCycle(trigger: String) async { - guard !patchyIsCycleRunning else { return } - guard let patchyChecker else { - appendPatchyLog("Patchy checker unavailable. Cycle skipped.") - return - } - - let enabledTargets = settings.patchy.sourceTargets.filter { $0.isEnabled && !$0.channelId.isEmpty } - guard !enabledTargets.isEmpty else { - appendPatchyLog("Patchy cycle (\(trigger)) skipped: no enabled targets.") - patchyLastCycleAt = Date() - return - } - - patchyIsCycleRunning = true - defer { - patchyIsCycleRunning = false - patchyLastCycleAt = Date() - } - - let grouped = Dictionary(grouping: enabledTargets) { target in - PatchySourceGroupKey( - source: target.source, - steamAppID: target.steamAppID.trimmingCharacters(in: .whitespacesAndNewlines) - ) - } - - for (_, targets) in grouped { - guard let referenceTarget = targets.first else { continue } - - do { - resolveSteamNameIfNeeded(for: referenceTarget) - let source = try PatchyRuntime.makeSource(from: referenceTarget) - let item = try await source.fetchLatest() - let mapped: PatchyFetchResult - if let driverItem = item as? DriverUpdateItem { - let newestVersion = driverItem.version.trimmingCharacters(in: .whitespacesAndNewlines) - let versionKey = PatchyRuntime.lastPostedDriverVersionKey(for: item.sourceKey) - let versionCheck = try await patchyChecker.check(identifier: newestVersion, for: versionKey) - mapped = PatchyRuntime.map(item: item, change: versionCheck) - for target in targets { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = mapped.statusSummary - } - } - - switch versionCheck { - case .firstSeen: - try await patchyChecker.save(identifier: newestVersion, for: versionKey) - appendPatchyLog("Patchy driver baseline initialized [\(referenceTarget.source.rawValue)] version=\(newestVersion)") - case .unchanged: - break - case .changed(let oldVersion, _): - guard let comparison = PatchyRuntime.compareDriverVersions(newestVersion, oldVersion) else { - try await patchyChecker.save(identifier: newestVersion, for: versionKey) - appendPatchyLog("Patchy migrated legacy driver baseline [\(referenceTarget.source.rawValue)] old=\(oldVersion) new=\(newestVersion)") - break - } - - guard comparison > 0 else { - appendPatchyLog("Patchy ignored non-newer driver [\(referenceTarget.source.rawValue)] latest=\(newestVersion) lastPosted=\(oldVersion)") - break - } - - let fallback = PatchyRuntime.fallbackMessage(for: mapped) - for target in targets { - let validation = await validatePatchyTarget(target) - guard validation.isValid else { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = validation.detail - } - appendPatchyLog("Patchy cycle [\(target.source.rawValue)] skipped target \(target.channelId): \(validation.detail)") - continue - } - - let delivery = await sendPatchyNotificationDetailed( - channelId: target.channelId, - message: fallback, - embedJSON: mapped.embedJSON, - roleIDs: target.roleIDs - ) - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastRunAt = Date() - entry.lastStatus = delivery.detail - } - if delivery.ok { - try await patchyChecker.save(identifier: newestVersion, for: versionKey) - } - } - } - } else if let steamItem = item as? SteamUpdateItem { - let newestStamp = PatchyRuntime.makeSteamOrderingStamp(item: steamItem) - let steamKey = PatchyRuntime.lastPostedSteamIdentifierKey(for: item.sourceKey) - let steamCheck = try await patchyChecker.check(identifier: newestStamp, for: steamKey) - mapped = PatchyRuntime.map(item: item, change: steamCheck) - - for target in targets { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = mapped.statusSummary - } - } - - switch steamCheck { - case .firstSeen: - try await patchyChecker.save(identifier: newestStamp, for: steamKey) - appendPatchyLog("Patchy Steam baseline initialized [\(referenceTarget.steamAppID)] stamp=\(newestStamp)") - case .unchanged: - break - case .changed(let oldStamp, _): - guard let comparison = PatchyRuntime.compareSteamOrderingStamp(newestStamp, oldStamp) else { - try await patchyChecker.save(identifier: newestStamp, for: steamKey) - appendPatchyLog("Patchy migrated legacy Steam baseline [\(referenceTarget.steamAppID)] old=\(oldStamp) new=\(newestStamp)") - break - } - - guard comparison > 0 else { - appendPatchyLog("Patchy ignored non-newer Steam item [\(referenceTarget.steamAppID)] latest=\(newestStamp) lastPosted=\(oldStamp)") - break - } - - let fallback = PatchyRuntime.fallbackMessage(for: mapped) - for target in targets { - let validation = await validatePatchyTarget(target) - guard validation.isValid else { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = validation.detail - } - appendPatchyLog("Patchy cycle [\(target.source.rawValue)] skipped target \(target.channelId): \(validation.detail)") - continue - } - - let delivery = await sendPatchyNotificationDetailed( - channelId: target.channelId, - message: fallback, - embedJSON: mapped.embedJSON, - roleIDs: target.roleIDs - ) - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastRunAt = Date() - entry.lastStatus = delivery.detail - } - if delivery.ok { - try await patchyChecker.save(identifier: newestStamp, for: steamKey) - } - } - } - } else { - let change = try await patchyChecker.check(item: item) - try await patchyChecker.save(item: item) - mapped = PatchyRuntime.map(item: item, change: change) - - for target in targets { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = mapped.statusSummary - } - } - - if change.isNewItem { - let fallback = PatchyRuntime.fallbackMessage(for: mapped) - for target in targets { - let validation = await validatePatchyTarget(target) - guard validation.isValid else { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = validation.detail - } - appendPatchyLog("Patchy cycle [\(target.source.rawValue)] skipped target \(target.channelId): \(validation.detail)") - continue - } - - let delivery = await sendPatchyNotificationDetailed( - channelId: target.channelId, - message: fallback, - embedJSON: mapped.embedJSON, - roleIDs: target.roleIDs - ) - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastRunAt = Date() - entry.lastStatus = delivery.detail - } - } - } - } - } catch { - for target in targets { - updatePatchyTargetRuntimeState(id: target.id) { entry in - entry.lastCheckedAt = Date() - entry.lastStatus = "Patchy check failed: \(error.localizedDescription)" - } - } - appendPatchyLog("Patchy cycle \(referenceTarget.source.rawValue) failed: \(error.localizedDescription)") - } - } - - persistSettingsQuietly() - } - - func updatePatchyTargetRuntimeState(id: UUID, apply: (inout PatchySourceTarget) -> Void) { - guard let idx = settings.patchy.sourceTargets.firstIndex(where: { $0.id == id }) else { return } - var target = settings.patchy.sourceTargets[idx] - apply(&target) - settings.patchy.sourceTargets[idx] = target - } - - func updateWikiBridgeSourceRuntimeState(id: UUID, apply: (inout WikiSource) -> Void) { - guard let idx = settings.wikiBot.sources.firstIndex(where: { $0.id == id }) else { return } - var target = settings.wikiBot.sources[idx] - apply(&target) - settings.wikiBot.sources[idx] = target - } - - func appendPatchyLog(_ line: String) { - let stamp = ISO8601DateFormatter().string(from: Date()) - let final = "[\(stamp)] \(line)" - patchyDebugLogs.insert(final, at: 0) - if patchyDebugLogs.count > 200 { - patchyDebugLogs.removeLast(patchyDebugLogs.count - 200) - } - logs.append("Patchy: \(line)") - } - - func persistSettingsQuietly() { - let snapshot = settings - Task { - do { - try await store.save(snapshot) - try await swiftMeshConfigStore.save(snapshot.swiftMeshSettings) - } catch { - await MainActor.run { - self.logs.append("❌ Failed saving settings: \(error.localizedDescription)") - } - } - } - } - - func migrateLegacyPatchySettingsIfNeeded(_ loaded: inout BotSettings) -> Bool { - guard loaded.patchy.sourceTargets.isEmpty, !loaded.patchy.targets.isEmpty else { - return false - } - - let migratedTargets = loaded.patchy.targets.map { legacy in - PatchySourceTarget( - isEnabled: legacy.isEnabled, - source: loaded.patchy.source, - steamAppID: loaded.patchy.steamAppID, - serverId: legacy.serverId, - channelId: legacy.channelId, - roleIDs: legacy.roleIDs - ) - } - - loaded.patchy.sourceTargets = migratedTargets - return true - } - - func migrateLegacyWikiBridgeSettingsIfNeeded(_ loaded: inout BotSettings) -> Bool { - let previousTargets = loaded.wikiBot.sources.count - let previousPrimary = loaded.wikiBot.sources.first(where: { $0.isPrimary })?.id - loaded.wikiBot.normalizeSources() - let currentPrimary = loaded.wikiBot.sources.first(where: { $0.isPrimary })?.id - return previousTargets != loaded.wikiBot.sources.count || previousPrimary != currentPrimary - } + // MARK: - Patchy (see AppModel+Patchy.swift) + // MARK: - WikiBridge (see AppModel+WikiBridge.swift) // MARK: - Bot Lifecycle (see AppModel+BotLifecycle.swift) - - // MARK: - Admin Web Server (see AppModel+AdminWeb.swift) - func stopBot() async { stopMediaMonitor() await service.disconnect() @@ -1501,455 +700,14 @@ final class AppModel: ObservableObject { logs.append("Bot stopped (SwiftMesh listener stopped)") } - func refreshClusterStatus() { - print("[DEBUG] AppModel.refreshClusterStatus() called") - Task { - print("[DEBUG] AppModel.refreshClusterStatus() Task started") - await pollClusterStatus() - let snapshot = await cluster.currentSnapshot() - await MainActor.run { - print("[DEBUG] AppModel.refreshClusterStatus() UI update") - self.clusterSnapshot = snapshot - self.lastClusterStatusRefreshAt = Date() - self.logSwiftMeshStatus(snapshot, context: "Refresh") - } - } - } - - func testWorkerLeaderConnection(leaderAddress: String? = nil, leaderPort: Int? = nil) { - let address = leaderAddress ?? settings.clusterLeaderAddress - let port = leaderPort ?? settings.clusterLeaderPort - - print("[DEBUG] AppModel.testWorkerLeaderConnection() called with address=\(address), port=\(port)") - Task { - print("[DEBUG] AppModel.testWorkerLeaderConnection() Task started") - await MainActor.run { - self.workerConnectionTestInProgress = true - self.workerConnectionTestIsSuccess = false - self.workerConnectionTestStatus = "Testing connection..." - self.workerConnectionTestOutcome = nil - } - - let outcome = await performWorkerConnectionTest( - leaderAddress: address, - leaderPort: port - ) - print("[DEBUG] AppModel.testWorkerLeaderConnection() outcome: \(outcome.isSuccess)") - - await MainActor.run { - self.workerConnectionTestInProgress = false - self.workerConnectionTestIsSuccess = outcome.isSuccess - self.workerConnectionTestStatus = outcome.message - self.workerConnectionTestOutcome = outcome - self.lastClusterStatusRefreshAt = Date() - self.logs.append("SwiftMesh worker connection test: \(outcome.message)") - } - } - } - - func refreshClusterStatusNow() async -> ClusterSnapshot { - await pollClusterStatus() - let snapshot = await cluster.currentSnapshot() - self.clusterSnapshot = snapshot - logSwiftMeshStatus(snapshot, context: "Refresh") - return snapshot - } - - func scheduleClusterNodesRefresh() { - clusterNodesRefreshTask?.cancel() - clusterNodesRefreshTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 250_000_000) - guard let self else { return } - await self.pollClusterStatus() - } - } - - func configureMeshSync() { - meshSyncTask?.cancel() - meshSyncTask = nil - - guard settings.clusterMode == .leader || settings.clusterMode == .standby else { return } - - meshSyncTask = Task { [weak self] in - while !Task.isCancelled { - // Leader pushes, Standby pulls - // Sync every 10 seconds so failover config changes propagate quickly. - try? await Task.sleep(nanoseconds: 10_000_000_000) - if Task.isCancelled { break } - - guard let self else { break } - - if self.settings.clusterMode == .leader { - // 1. Push worker registry to all nodes - await self.cluster.pushWorkerRegistryToStandbys() - // 2. Push incremental conversation batches per node - await self.pushIncrementalConversationsToAllNodes() - } else if self.settings.clusterMode == .standby { - // 3. Standby: Pull config files and wiki cache from Primary - await self.pullConfigFilesFromLeader() - await self.pullWikiCacheFromLeader() - } - } - } - } + // MARK: - Cluster (see AppModel+Cluster.swift) // MARK: - P1b: Off-peak background mesh refresh /// Schedules a low-priority background activity (15 min / 5 min tolerance) that fires /// existing standby/worker sync paths when the system is idle (NSBackgroundActivityScheduler). - func setupBackgroundRefreshScheduler() { - let scheduler = NSBackgroundActivityScheduler(identifier: "com.swiftbot.meshBackgroundRefresh") - scheduler.repeats = true - scheduler.interval = 15 * 60 // 15 minutes - scheduler.tolerance = 5 * 60 // 5-minute tolerance window - scheduler.qualityOfService = .background - scheduler.schedule { [weak self] completion in - guard let self else { completion(.finished); return } - Task { - await self.runBackgroundMeshRefresh() - completion(.finished) - } - } - backgroundRefreshScheduler = scheduler - } - - func runBackgroundMeshRefresh() async { - guard settings.clusterMode == .standby || settings.clusterMode == .worker else { return } - await pullConfigFilesFromLeader() - await requestResyncFromLeader(fromRecordID: localLastMergedRecordID) - } /// Leader: push incremental conversation batches to each registered node using per-node cursors. - func pushIncrementalConversationsToAllNodes() async { - let nodes = await cluster.registeredNodeInfo() - guard !nodes.isEmpty else { return } - let currentTerm = await cluster.currentLeaderTerm() - for (nodeName, baseURL) in nodes { - let cursor = await cluster.currentReplicationCursor(for: nodeName) - let fromID = cursor?.lastSentRecordID ?? "" - let (records, hasMore) = await conversationStore.recordsSince(fromRecordID: fromID, limit: 500) - let lastID = records.last?.id - let payload = MeshSyncPayload( - conversations: records, - commandLog: Array(commandLog.prefix(200)), - voiceLog: Array(voiceLog.prefix(200)), - activeVoice: activeVoice, - leaderTerm: currentTerm, - cursorRecordID: lastID, - hasMore: hasMore, - fromCursorRecordID: fromID - ) - let ok = await cluster.pushConversationsToSingleNode(baseURL, payload) - if ok, lastID != nil { - await cluster.updateReplicationCursor(for: nodeName, lastSentRecordID: lastID, term: currentTerm) - } - } - } - - func pullWikiCacheFromLeader() async { - guard let data = await cluster.fetchWikiCache() else { return } - if let entries = try? JSONDecoder().decode([WikiContextEntry].self, from: data) { - for entry in entries { - await wikiContextCache.upsertEntry(entry) - } - logs.append("SwiftMesh: pulled \(entries.count) wiki entry(s) from Primary") - } - } - - func pullConfigFilesFromLeader() async { - guard settings.clusterMode == .standby || settings.clusterMode == .worker else { return } - guard let data = await cluster.fetchConfigFiles() else { return } - let imported = await store.importMeshSyncedFiles( - data, - excludingFileNames: Set([ - SwiftBotStorage.swiftMeshConfigFileName, - SwiftBotStorage.clusterStateFileName - ]) - ) - guard imported > 0 else { return } - - logs.append("SwiftMesh: pulled \(imported) config file(s) from Primary") - await reloadSyncedConfigFromDisk() - } - - func reloadSyncedConfigFromDisk() async { - // Keep local mesh identity authoritative on this node. - let currentLocalMesh = settings.swiftMeshSettings - let currentLocalMedia = mediaLibrarySettings - let currentLocalAdminWebUI = settings.adminWebUI - let currentLocalRemoteAccessToken = settings.remoteAccessToken - var reloaded = await store.load() - let meshFromFile = await swiftMeshConfigStore.load() - let effectiveLocalMesh = meshFromFile ?? currentLocalMesh - reloaded.swiftMeshSettings = effectiveLocalMesh - if meshFromFile == nil { - // Self-heal missing mesh file so future reloads remain stable. - try? await swiftMeshConfigStore.save(effectiveLocalMesh) - } - if effectiveLocalMesh.mode == .standby || effectiveLocalMesh.mode == .worker { - reloaded.adminWebUI = currentLocalAdminWebUI - reloaded.remoteAccessToken = currentLocalRemoteAccessToken - } - reloaded.wikiBot.normalizeSources() - settings = reloaded - mediaLibrarySettings = currentLocalMedia - await mediaLibraryIndexer.invalidate() - await ruleStore.reloadFromDisk() - await aiService.configureLocalAIDMReplies( - enabled: settings.localAIDMReplyEnabled, - provider: settings.localAIProvider, - preferredProvider: settings.preferredAIProvider, - endpoint: localAIEndpointForService(), - model: settings.localAIModel, - openAIAPIKey: effectiveOpenAIAPIKey(), - openAIModel: settings.openAIModel, - systemPrompt: settings.localAISystemPrompt - ) - configurePatchyMonitoring() - await configureAdminWebServer() - await refreshAIStatus() - } - - func applyClusterSettingsRuntime(mode: ClusterMode, nodeName: String, leaderAddress: String, leaderPort: Int, listenPort: Int, sharedSecret: String) async { - // Phase 5 Safety Guard: Prevent invalid mesh ports from being used. - guard listenPort > 0 && listenPort <= 65535 else { - logs.append("❌ [SwiftMesh] Invalid port '\(listenPort)' — aborting mesh connection.") - return - } - - await cluster.applySettings( - mode: mode, - nodeName: nodeName, - leaderAddress: leaderAddress, - leaderPort: leaderPort, - listenPort: listenPort, - sharedSecret: sharedSecret, - leaderTerm: settings.clusterLeaderTerm - ) - - // Phase 4: Configuration Consistency - log final mesh endpoint - if mode != .standalone { - let host = ProcessInfo.processInfo.hostName - logs.append("SwiftMesh listening on \(host):\(listenPort)") - } - - await cluster.setOffloadPolicy( - aiReplies: settings.clusterOffloadAIReplies, - wikiLookups: settings.clusterOffloadWikiLookups - ) - // Sync secondary safety guard: only Primary nodes may send Discord output. - let isPrimary = mode == .standalone || mode == .leader - await service.setOutputAllowed(isPrimary) - configureMeshSync() - if mode == .standby { - await pullConfigFilesFromLeader() - } - await pollClusterStatus() - } - - func pollClusterStatus() async { - guard settings.clusterMode != .standalone else { - clusterNodes = [] - await refreshRegisteredWorkersDebugInfo() - return - } - - let emptyBody = Data() - let localStatusHeaders = await meshStatusAuthHeaders(path: "/cluster/status", method: "GET", body: emptyBody) - let localURL = URL(string: "http://127.0.0.1:\(settings.clusterListenPort)/cluster/status") - - if settings.clusterMode == .standby, - let remoteNodes = await fetchRemoteLeaderNodesIfAvailable() { - await applyClusterNodes(remoteNodes) - return - } - - if let localURL, - let response = await clusterStatusService.fetchStatus(from: localURL, headers: localStatusHeaders) { - let resolvedNodes = response.nodes.isEmpty ? fallbackClusterNodes() : response.nodes - await applyClusterNodes(resolvedNodes) - return - } - - if (settings.clusterMode == .worker || settings.clusterMode == .standby), - let remoteNodes = await fetchRemoteLeaderNodesIfAvailable() { - await applyClusterNodes(remoteNodes) - return - } - - let graceWindow: TimeInterval = 12 - if let lastSuccess = lastClusterStatusSuccessAt, - Date().timeIntervalSince(lastSuccess) <= graceWindow, - !lastGoodClusterNodes.isEmpty { - clusterNodes = lastGoodClusterNodes - } else { - clusterNodes = fallbackClusterNodes() - } - await refreshRegisteredWorkersDebugInfo() - } - - private func applyClusterNodes(_ nodes: [ClusterNodeStatus]) async { - clusterNodes = nodes - lastGoodClusterNodes = nodes - lastClusterStatusSuccessAt = Date() - await refreshRegisteredWorkersDebugInfo() - } - - private func refreshRegisteredWorkersDebugInfo() async { - let info = await cluster.registeredWorkersDebugInfo() - registeredWorkersDebugCount = info.count - registeredWorkersDebugSummary = info.summary - } - - private func fetchRemoteLeaderNodesIfAvailable() async -> [ClusterNodeStatus]? { - guard let baseURL = normalizedSwiftMeshBaseURL(from: settings.clusterLeaderAddress, defaultPort: settings.clusterLeaderPort), - let statusURL = URL(string: baseURL.absoluteString + "/cluster/status"), - let host = baseURL.host else { - return nil - } - - let emptyBody = Data() - let statusHeaders = await meshStatusAuthHeaders(path: "/cluster/status", method: "GET", body: emptyBody) - if let response = await clusterStatusService.fetchStatus(from: statusURL, headers: statusHeaders) { - let nodes = response.nodes.isEmpty ? fallbackClusterNodes() : response.nodes - if settings.clusterMode == .standby { - return ensureLocalStandbyNodePresent(in: nodes) - } - return nodes - } - - guard let pingURL = URL(string: baseURL.absoluteString + "/cluster/ping") else { - return nil - } - let pingHeaders = await meshStatusAuthHeaders(path: "/cluster/ping", method: "GET", body: emptyBody) - guard let ping = await clusterStatusService.fetchPing(from: pingURL, headers: pingHeaders), - ping.response.status.caseInsensitiveCompare("ok") == .orderedSame, - ping.response.role.caseInsensitiveCompare("leader") == .orderedSame else { - return nil - } - - var nodes = fallbackClusterNodes() - if let leaderIndex = nodes.firstIndex(where: { $0.role == .leader }) { - nodes[leaderIndex].status = .healthy - nodes[leaderIndex].latencyMs = ping.latencyMs - nodes[leaderIndex].displayName = ping.response.node - nodes[leaderIndex].hostname = host - return nodes - } - - nodes.append( - ClusterNodeStatus( - id: "leader-\(host.lowercased())", - hostname: host, - displayName: ping.response.node, - role: .leader, - hardwareModel: "Unknown", - cpu: 0, - mem: 0, - cpuName: "Unknown CPU", - physicalMemoryBytes: 0, - uptime: 0, - latencyMs: ping.latencyMs, - status: .healthy, - jobsActive: 0 - ) - ) - if settings.clusterMode == .standby { - return ensureLocalStandbyNodePresent(in: nodes) - } - return nodes - } - - private func ensureLocalStandbyNodePresent(in nodes: [ClusterNodeStatus]) -> [ClusterNodeStatus] { - guard settings.clusterMode == .standby else { return nodes } - guard let localWorker = fallbackClusterNodes().first(where: { $0.role == .worker }) else { - return nodes - } - - let hasLocal = nodes.contains { node in - guard node.role == .worker else { return false } - if node.displayName.caseInsensitiveCompare(localWorker.displayName) == .orderedSame { - return true - } - return node.hostname.caseInsensitiveCompare(localWorker.hostname) == .orderedSame - } - guard !hasLocal else { return nodes } - - var merged = nodes - merged.append(localWorker) - return merged - } - - private func meshStatusAuthHeaders(path: String, method: String, body: Data) async -> [String: String] { - let secret = settings.clusterSharedSecret.trimmingCharacters(in: .whitespacesAndNewlines) - guard !secret.isEmpty else { return [:] } - - let nonce = UUID().uuidString - let timestamp = Int(Date().timeIntervalSince1970) - let signature = await cluster.meshSignature( - method: method, - nonce: nonce, - timestamp: timestamp, - path: path, - body: body - ) - return [ - "X-Mesh-Nonce": nonce, - "X-Mesh-Timestamp": String(timestamp), - "X-Mesh-Signature": signature - ] - } - - func fallbackClusterNodes() -> [ClusterNodeStatus] { - let localNodeName = settings.clusterNodeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? (Host.current().localizedName ?? "SwiftBot Node") - : settings.clusterNodeName.trimmingCharacters(in: .whitespacesAndNewlines) - let hostname = ProcessInfo.processInfo.hostName - let role: ClusterNodeRole = settings.clusterMode == .leader ? .leader : .worker - let uptime = max(0, Date().timeIntervalSince(launchedAt)) - let hardwareInfo = HardwareInfo.current() - var nodes: [ClusterNodeStatus] = [ - ClusterNodeStatus( - id: "\(role.rawValue)-\(hostname.lowercased())-\(settings.clusterListenPort)", - hostname: hostname, - displayName: localNodeName, - role: role, - hardwareModel: hardwareInfo.modelIdentifier, - cpu: 0, - mem: 0, - cpuName: hardwareInfo.cpuName, - physicalMemoryBytes: hardwareInfo.physicalMemoryBytes, - uptime: uptime, - latencyMs: nil, - status: clusterSnapshot.serverState.nodeHealthStatus, - jobsActive: 0 - ) - ] - - if (settings.clusterMode == .worker || settings.clusterMode == .standby), - !settings.clusterLeaderAddress.isEmpty { - let host = URL(string: settings.clusterLeaderAddress)?.host ?? "Primary" - nodes.append( - ClusterNodeStatus( - id: "leader-\(host.lowercased())", - hostname: host, - displayName: host, - role: .leader, - hardwareModel: "Unknown", - cpu: 0, - mem: 0, - cpuName: "Unknown CPU", - physicalMemoryBytes: 0, - uptime: 0, - latencyMs: nil, - status: .disconnected, - jobsActive: 0 - ) - ) - } - - return nodes - } var isWorkerServiceRunning: Bool { guard settings.clusterMode == .worker else { return false } @@ -2030,371 +788,7 @@ final class AppModel: ObservableObject { // MARK: - P0.5: Member join welcome - func handleMemberJoin(_ event: GatewayMemberJoinEvent) async { - // Legacy settings path still active for backward compatibility. - // New config: use a "Member Joined" trigger rule in Actions instead. - let legacyEnabled = settings.behavior.memberJoinWelcomeEnabled && - !settings.behavior.memberJoinWelcomeChannelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - let hasRules = ruleStore.rules.contains { $0.isEnabled && $0.trigger == .memberJoined } - guard legacyEnabled || hasRules else { return } - - let now = Date() - let guildId = event.guildID - let userId = event.userID - - // Increment member count for this guild (best-effort; sourced from GUILD_CREATE). - let memberCount = (guildMemberCounts[guildId] ?? 0) + 1 - guildMemberCounts[guildId] = memberCount - - // Burst-guard: track join timestamps per guild; cap array to 50 entries. - var timestamps = guildJoinTimestamps[guildId] ?? [] - timestamps = timestamps.filter { now.timeIntervalSince($0) < 5 } - timestamps.append(now) - if timestamps.count > 50 { timestamps = Array(timestamps.suffix(50)) } - guildJoinTimestamps[guildId] = timestamps - - let burstThreshold = 10 - if timestamps.count > burstThreshold { - // Raid-safe: summarize instead of individual welcome. - if timestamps.count == burstThreshold + 1 { - // Post once at the threshold crossing, not on every subsequent join. - let channelId = settings.behavior.memberJoinWelcomeChannelId - .trimmingCharacters(in: .whitespacesAndNewlines) - let serverName = connectedServers[guildId] ?? "the server" - _ = await send(channelId, "👥 Multiple members joined \(serverName) — welcome everyone!") - logs.append("Member join burst detected in \(guildId); switched to summary mode.") - } - return - } - - // Dedupe: skip if same user joined this guild within 10 seconds. - let dedupeKey = "\(guildId):\(userId)" - if let last = recentMemberJoins[dedupeKey], now.timeIntervalSince(last) < 10 { return } - recentMemberJoins[dedupeKey] = now - // Bounded cleanup: cap at 500 entries, remove entries older than 60s. - if recentMemberJoins.count > 500 { - let pruned = recentMemberJoins.filter { now.timeIntervalSince($0.value) < 60 } - recentMemberJoins = Dictionary(uniqueKeysWithValues: Array(pruned.prefix(500))) - } - - // Template sanitization: neutralize @everyone and @here to prevent mass-ping abuse. - let safeUsername = event.rawUsername - .replacingOccurrences(of: "@everyone", with: "@​everyone") - .replacingOccurrences(of: "@here", with: "@​here") - - let serverName = connectedServers[guildId] ?? "the server" - let message = settings.behavior.memberJoinWelcomeTemplate - .replacingOccurrences(of: "{username}", with: safeUsername) - .replacingOccurrences(of: "{server}", with: serverName) - .replacingOccurrences(of: "{memberCount}", with: "\(memberCount)") - - if legacyEnabled { - let channelId = settings.behavior.memberJoinWelcomeChannelId - .trimmingCharacters(in: .whitespacesAndNewlines) - _ = await send(channelId, message) - } - - // Rule-based execution: evaluate any enabled "Member Joined" trigger rules. - let ruleEvent = VoiceRuleEvent( - kind: .memberJoin, - guildId: guildId, - userId: userId, - username: safeUsername, - channelId: "", - fromChannelId: nil, - toChannelId: nil, - durationSeconds: nil, - messageContent: nil, - messageId: nil, - mediaFileName: nil, - mediaRelativePath: nil, - mediaSourceName: nil, - mediaNodeName: nil, - triggerMessageId: nil, - triggerChannelId: nil, - triggerGuildId: guildId, - triggerUserId: userId, - isDirectMessage: false, - authorIsBot: nil, - joinedAt: event.joinedAt - ) - let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) - for rule in matchedRules { - _ = PipelineContext() - for action in rule.processedActions where action.type == .sendMessage { - let ruleMessage = action.message - .replacingOccurrences(of: "{username}", with: safeUsername) - .replacingOccurrences(of: "{server}", with: serverName) - .replacingOccurrences(of: "{memberCount}", with: "\(memberCount)") - .replacingOccurrences(of: "{userId}", with: userId) - let targetChannel = action.channelId.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - guard !targetChannel.isEmpty else { continue } - _ = await send(targetChannel, ruleMessage) - } - } - - // Log username only — no internal IDs or metadata. - addEvent(ActivityEvent(timestamp: now, kind: .info, message: "👋 \(safeUsername) joined \(serverName)")) - logs.append("Member join welcome sent for \(safeUsername) in \(serverName)") - } - - func handleMemberLeave(_ event: GatewayMemberLeaveEvent) async { - let now = Date() - let guildId = event.guildID - let userId = event.userID - - // Best-effort member count decrement - if let count = guildMemberCounts[guildId] { - guildMemberCounts[guildId] = max(0, count - 1) - } - - let username = event.username - - let ruleEvent = VoiceRuleEvent( - kind: .memberLeave, - guildId: guildId, - userId: userId, - username: username, - channelId: "", - fromChannelId: nil, - toChannelId: nil, - durationSeconds: nil, - messageContent: nil, - messageId: nil, - mediaFileName: nil, - mediaRelativePath: nil, - mediaSourceName: nil, - mediaNodeName: nil, - triggerMessageId: nil, - triggerChannelId: nil, - triggerGuildId: guildId, - triggerUserId: userId, - isDirectMessage: false, - authorIsBot: nil, - joinedAt: nil - ) - - let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) - for rule in matchedRules { - _ = await service.executeRulePipeline(actions: rule.processedActions, for: ruleEvent, isDirectMessage: ruleEvent.isDirectMessage) - } - - addEvent(ActivityEvent(timestamp: now, kind: .info, message: "🚪 \(username) left the server")) - logs.append("Member leave handled for \(username)") - } - - func handleGuildCreate(_ event: GatewayGuildCreateEvent) async { - guildCreateEventCount += 1 - if let memberCount = event.memberCount { - guildMemberCounts[event.guildID] = memberCount - } - - await discordCache.upsertGuild(id: event.guildID, name: event.guildName) - await discordCache.setGuildVoiceChannels(guildID: event.guildID, channels: parseVoiceChannels(from: event.rawMap)) - await discordCache.setGuildTextChannels(guildID: event.guildID, channels: parseTextChannels(from: event.rawMap)) - await discordCache.setGuildRoles(guildID: event.guildID, roles: parseRoles(from: event.rawMap)) - await discordCache.mergeChannelTypes(parseChannelTypes(from: event.rawMap)) - await cacheGuildMembers(from: event.rawMap) - await syncPublishedDiscordCacheFromService() - await syncVoicePresenceFromGuildSnapshot(guildId: event.guildID, guildMap: event.rawMap) - scheduleDiscordCacheSave() - await registerSlashCommandsIfNeeded() - } - - func handleChannelCreate(_ event: GatewayChannelCreateEvent) async { - await discordCache.setChannelType(channelID: event.channelID, type: event.type) - await discordCache.upsertChannel( - guildID: event.guildID, - channelID: event.channelID, - name: event.name, - type: event.type - ) - await syncPublishedDiscordCacheFromService() - scheduleDiscordCacheSave() - } - - func handleGuildDelete(_ event: GatewayGuildDeleteEvent) async { - await discordCache.removeGuild(id: event.guildID) - await syncPublishedDiscordCacheFromService() - await clearVoicePresence(guildID: event.guildID) - scheduleDiscordCacheSave() - } - - func patchyErrorDiagnostic(from error: Error) -> String { - let ns = error as NSError - let statusCode = ns.userInfo["statusCode"] as? Int ?? ns.code - let body = (ns.userInfo["responseBody"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - // Try to parse Discord's specific error code from the JSON body - var discordCode: Int? = nil - if let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let code = json["code"] as? Int { - discordCode = code - } - - // Map to HIG-aligned, actionable messages - switch (statusCode, discordCode) { - case (403, 50001?): - return "SwiftBot cannot view this channel. Check permissions in the Discord server." - case (403, 50013?): - return "SwiftBot lacks 'Embed Links' or 'Mention' permissions in this channel." - case (404, 10003?): - return "Channel not found. It may have been deleted — please remove or update this target." - case (401, _): - return "Invalid Bot Token. Please check your token in General Settings." - case (429, _): - return "Sending too fast. Discord is temporarily limiting requests." - default: - if !body.isEmpty && body != "-" { - let trimmedBody = body.count > 120 ? String(body.prefix(117)) + "..." : body - return "Failed to send (HTTP \(statusCode)). Details: \(trimmedBody)" - } - return "Failed to send (HTTP \(statusCode)). Check Patchy logs for details." - } - } - - func syncVoicePresenceFromGuildSnapshot(guildId: String, guildMap: [String: DiscordJSON]) async { - guard case let .array(voiceStates)? = guildMap["voice_states"] else { return } - - let now = Date() - var snapshot: [VoiceMemberPresence] = [] - for state in voiceStates { - guard case let .object(stateMap) = state, - case let .string(userId)? = stateMap["user_id"], - case let .string(channelId)? = stateMap["channel_id"] - else { continue } - - if case let .object(member)? = stateMap["member"], - case let .object(user)? = member["user"], - case let .string(avatarHash)? = user["avatar"], - !avatarHash.isEmpty { - cacheUserAvatar(avatarHash, for: userId) - if case let .string(guildAvatarHash)? = member["avatar"], !guildAvatarHash.isEmpty { - cacheGuildAvatar(guildAvatarHash, for: "\(guildId)-\(userId)") - } - } else if case let .object(user)? = stateMap["user"], - case let .string(avatarHash)? = user["avatar"], - !avatarHash.isEmpty { - cacheUserAvatar(avatarHash, for: userId) - } - - let username = await voiceDisplayName(from: stateMap, userId: userId) - let key = "\(guildId)-\(userId)" - let joinedAt = now - - snapshot.append( - VoiceMemberPresence( - id: key, - userId: userId, - username: username, - guildId: guildId, - channelId: channelId, - channelName: channelDisplayName(guildId: guildId, channelId: channelId), - joinedAt: joinedAt - ) - ) - } - - activeVoice = await voicePresenceStore.syncGuildSnapshot(guildId, members: snapshot) - } - - func cacheGuildMembers(from guildMap: [String: DiscordJSON]) async { - guard case let .array(members)? = guildMap["members"] else { return } - - for member in members { - guard case let .object(memberMap) = member else { continue } - if case let .string(nick)? = memberMap["nick"], !nick.isEmpty, - case let .object(user)? = memberMap["user"], - case let .string(userId)? = user["id"] { - await discordCache.upsertUser(id: userId, preferredName: nick) - continue - } - - guard case let .object(user)? = memberMap["user"], - case let .string(userId)? = user["id"] else { continue } - - if case let .string(avatarHash)? = user["avatar"], !avatarHash.isEmpty { - cacheUserAvatar(avatarHash, for: userId) - } - - if case let .string(globalName)? = user["global_name"], !globalName.isEmpty { - await discordCache.upsertUser(id: userId, preferredName: globalName) - } else if case let .string(username)? = user["username"], !username.isEmpty { - await discordCache.upsertUser(id: userId, preferredName: username) - } - } - } - - func syncPublishedDiscordCacheFromService() async { - let snapshot = await discordCache.currentSnapshot() - connectedServers = snapshot.connectedServers - availableVoiceChannelsByServer = snapshot.availableVoiceChannelsByServer - availableTextChannelsByServer = snapshot.availableTextChannelsByServer - availableRolesByServer = snapshot.availableRolesByServer - knownUsersById = snapshot.usernamesById - } - - func scheduleDiscordCacheSave() { - discordCacheSaveTask?.cancel() - discordCacheSaveTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 800_000_000) - guard !Task.isCancelled, let self = self else { return } - do { - let snapshot = await self.discordCache.currentSnapshot() - try await discordCacheStore.save(snapshot) - } catch { - await MainActor.run { - self.logs.append("❌ Failed saving Discord cache: \(error.localizedDescription)") - } - } - } - } - - func resolveSteamNameIfNeeded(for target: PatchySourceTarget) { - guard target.source == .steam else { return } - let appID = target.steamAppID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !appID.isEmpty else { return } - if let existing = settings.patchy.steamAppNames[appID], !existing.isEmpty { - return - } - - Task { - if let name = await fetchSteamAppName(appID: appID) { - await MainActor.run { - self.settings.patchy.steamAppNames[appID] = name - self.persistSettingsQuietly() - } - } - } - } - - func fetchSteamAppName(appID: String) async -> String? { - guard let url = URL(string: "https://store.steampowered.com/api/appdetails?appids=\(appID)&l=english") else { - return nil - } - - do { - let (data, response) = try await URLSession.shared.data(from: url) - guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - return nil - } - guard - let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let appNode = root[appID] as? [String: Any], - let success = appNode["success"] as? Bool, success, - let dataNode = appNode["data"] as? [String: Any], - let name = dataNode["name"] as? String - else { - return nil - } - - let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned - } catch { - return nil - } - } + // MARK: - Discord Events (see AppModel+DiscordEvents.swift) func commandServerName(from map: [String: DiscordJSON]) -> String { guard case let .string(guildId)? = map["guild_id"] else { @@ -2522,8 +916,8 @@ final class MemoryViewModel: ObservableObject { struct WorkerConnectionTestOutcome { let message: String let isSuccess: Bool - var latencyMs: Double? = nil - var nodeName: String? = nil + var latencyMs: Double? + var nodeName: String? } enum WorkerReachabilityResult { @@ -2585,7 +979,6 @@ actor ClusterStatusPollingService { } } - // MARK: - Notification Names extension Notification.Name { diff --git a/SwiftBotApp/AppModelTypes.swift b/SwiftBotApp/AppModelTypes.swift index a4b2c4f..93ad8d5 100644 --- a/SwiftBotApp/AppModelTypes.swift +++ b/SwiftBotApp/AppModelTypes.swift @@ -7,13 +7,13 @@ struct ConnectionDiagnostics { case error(Int, String) } - var heartbeatLatencyMs: Int? = nil + var heartbeatLatencyMs: Int? var restHealth: RESTHealth = .unknown - var rateLimitRemaining: Int? = nil - var lastTestAt: Date? = nil + var rateLimitRemaining: Int? + var lastTestAt: Date? var lastTestMessage: String = "" /// Last non-normal WebSocket close code from Discord (e.g. 4004, 4014). Nil = no abnormal close. - var lastGatewayCloseCode: Int? = nil + var lastGatewayCloseCode: Int? } struct BinaryHTTPResponse: Sendable { @@ -33,11 +33,11 @@ struct MediaLibrarySettings: Codable, Hashable { var sources: [MediaLibrarySource] = [] var exportRootPath: String = "" var exportIncludeInLibrary: Bool = true - var exportSourceID: UUID? = nil + var exportSourceID: UUID? } struct MediaLibrarySource: Codable, Hashable, Identifiable { - var id: UUID = UUID() + var id = UUID() var name: String = "Gameplay" var rootPath: String = "" var isEnabled: Bool = true @@ -153,3 +153,115 @@ struct MediaExportJobResponse: Codable, Hashable { var job: MediaExportJob? var error: String? } + +// 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 AdminWebCertificateRenewalConfiguration: Equatable { + let enabled: Bool + let domain: String + let cloudflareToken: String +} + +// MARK: - Admin Web Setup Events & Errors + +enum AdminWebAutomaticHTTPSSetupEvent: Sendable, Equatable { + case verifyingCloudflareAccess + case cloudflareAccessVerified + case detectingCloudflareZone(domain: String) + case cloudflareZoneDetected(zone: String) + case creatingDNSChallengeRecord(recordName: String) + case dnsChallengeRecordCreated(recordName: String, reusedExistingRecord: Bool) + case waitingForDNSPropagation(recordName: String) + case dnsChallengeRecordPropagated(recordName: String) + case dnsChallengeRecordVerified(recordName: String, reusedExistingRecord: Bool) + case requestingTLSCertificate(domain: String) + case tlsCertificateIssued(domain: String) + case storingCertificate + case certificateStored(path: String) + case enablingHTTPSListener + case httpsListenerEnabled(url: String) +} + +enum AdminWebPublicAccessSetupEvent: Sendable, Equatable { + case verifyingCloudflareAccess + case cloudflareAccessVerified + case detectingCloudflareZone(domain: String) + case cloudflareZoneDetected(zone: String) + case creatingTunnel(hostname: String) + case tunnelCreated(name: String) + case tunnelDetected(name: String) + case creatingTunnelDNSRecord(hostname: String) + case tunnelDNSRecordCreated(hostname: String) + case storingTunnelCredentials + case startingTunnelProcess + case publicAccessEnabled(url: String) +} + +enum InternetAccessSetupEvent: Sendable, Equatable { + case verifyingCloudflareAccess + case cloudflareAccessVerified + case detectingCloudflareZone(domain: String) + case cloudflareZoneDetected(zone: String) + case creatingTunnel(hostname: String) + case tunnelCreated(name: String) + case tunnelDetected(name: String) + case creatingTunnelDNSRecord(hostname: String) + case tunnelDNSRecordCreated(hostname: String) + case issuingHTTPSCertificate(hostname: String) + case httpsCertificateIssued(hostname: String) + case startingCloudflareTunnel + case cloudflareTunnelStarted + case internetAccessEnabled(url: String) +} + +enum AdminWebHTTPSProvisioningError: LocalizedError { + case tlsActivationFailed + + var errorDescription: String? { + switch self { + case .tlsActivationFailed: + return "The certificate was issued, but SwiftBot could not start the Admin Web UI over HTTPS. Check the logs and TLS files, then try again." + } + } +} + +enum AdminWebPublicAccessError: LocalizedError { + case missingHostname + case invalidOriginURL + case tunnelStartupFailed(String) + + var errorDescription: String? { + switch self { + case .missingHostname: + return "Enter a public hostname before enabling Public Access." + case .invalidOriginURL: + return "SwiftBot could not determine the local Web UI address for Cloudflare Tunnel." + case .tunnelStartupFailed(let detail): + return detail + } + } +} + +let genericAdminWebHTTPSSetupFailureMessage = "HTTPS setup couldn’t be completed. Verify Cloudflare access and DNS propagation, then try again." +let genericAdminWebPublicAccessFailureMessage = "Public Access couldn’t be completed. Verify the hostname, Cloudflare access, and tunnel configuration, then try again." diff --git a/SwiftBotApp/ClusterCoordinator.swift b/SwiftBotApp/ClusterCoordinator.swift index 4eb8ca8..e454866 100644 --- a/SwiftBotApp/ClusterCoordinator.swift +++ b/SwiftBotApp/ClusterCoordinator.swift @@ -92,7 +92,7 @@ actor ClusterCoordinator { private var wikiHandler: WikiHandler? private var conversationFetcher: ConversationFetcher? private var listener: NWListener? - private var listenerActivePort: Int? = nil + private var listenerActivePort: Int? private var onSnapshot: (@Sendable (ClusterSnapshot) async -> Void)? private var onJobLog: JobLogHandler? private var onSync: SyncHandler? @@ -778,7 +778,7 @@ actor ClusterCoordinator { let headerData = buffer[.. Self.maxHTTPRequestSize { throw NWError.posix(.EMSGSIZE) } @@ -788,11 +788,11 @@ actor ClusterCoordinator { } } } - + if buffer.count >= Self.maxHTTPRequestSize { throw NWError.posix(.EMSGSIZE) } - + return buffer } @@ -1061,7 +1061,7 @@ actor ClusterCoordinator { private func monitorLeaderHealth(_ leaderBaseURL: String) async { guard mode == .standby else { return } - + let isHealthy = await isWorkerReachable(leaderBaseURL) if isHealthy { if standbyHealthMisses > 0 { @@ -1077,7 +1077,7 @@ actor ClusterCoordinator { snapshot.diagnostics = "Primary health miss \(standbyHealthMisses)/\(Self.standbyPromotionThreshold)" meshLogger.warning("Primary health miss \(self.standbyHealthMisses, privacy: .public)/\(Self.standbyPromotionThreshold, privacy: .public)") await publishSnapshot() - + if standbyHealthMisses >= Self.standbyPromotionThreshold { await promoteToLeader() } @@ -1098,7 +1098,7 @@ actor ClusterCoordinator { // Persist the new term immediately so a restart cannot emit a stale term. await onTermChanged?(leaderTerm) - + // Notify AppModel to start bot services await onPromotion?() @@ -1114,20 +1114,20 @@ actor ClusterCoordinator { // Restart server as leader await restartServerIfNeeded() - + // Notify workers of the new leader let workers = Array(registeredWorkers.values) if !workers.isEmpty { snapshot.diagnostics = "Promoted to Primary. Notifying \(workers.count) workers..." await publishSnapshot() - + let payload = MeshLeaderChangedPayload( term: leaderTerm, leaderAddress: localWorkerAdvertisedBaseURL(), leaderNodeName: nodeName, sharedSecret: sharedSecret ) - + for worker in workers { await notifyWorkerOfLeaderChange(worker, payload: payload) } @@ -1391,7 +1391,7 @@ actor ClusterCoordinator { !host.isEmpty else { return nil } - + // Mesh host guard: allow internet peers while still blocking obvious unsafe targets. if !isSSRFSafeHost(host) { return nil @@ -1957,7 +1957,7 @@ actor ClusterCoordinator { lastSeen: Date() ) } - + snapshot.diagnostics = "Synced worker registry (\(payload.workers.count) workers)" await publishSnapshot() @@ -1994,7 +1994,7 @@ actor ClusterCoordinator { let limit = min(max(1, req.pageSize), Self.maxSyncBatchSize) let (records, hasMore) = await fetcher(req.fromRecordID, limit) let lastID = records.last?.id - + // Also fetch current image usage if this is the last page (or just always for simplicity) let imageUsage = await meshHandler?("image-usage") let decodedUsage = imageUsage.flatMap { try? JSONDecoder().decode([String: Int].self, from: $0) } diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 71a28be..756f85e 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -561,7 +561,7 @@ actor DiscordService { private func processRuleActionsIfNeeded(_ payload: GatewayPayload) async { guard payload.op == 0 else { return } - + // Prevent standby nodes from executing rule actions guard outputAllowed else { return } diff --git a/SwiftBotApp/GeneralPreferencesView.swift b/SwiftBotApp/GeneralPreferencesView.swift index bdf77d5..f8ca443 100644 --- a/SwiftBotApp/GeneralPreferencesView.swift +++ b/SwiftBotApp/GeneralPreferencesView.swift @@ -5,7 +5,7 @@ struct GeneralPreferencesView: View { @EnvironmentObject var app: AppModel @State private var showRunSetupPrompt = false - + // Discord Authentication State (Moved from Discord tab) @State private var showToken = false @State private var transientToastMessage: String? diff --git a/SwiftBotApp/ModeSelectionView.swift b/SwiftBotApp/ModeSelectionView.swift index 0453dae..a62c4b0 100644 --- a/SwiftBotApp/ModeSelectionView.swift +++ b/SwiftBotApp/ModeSelectionView.swift @@ -11,7 +11,7 @@ struct ModeSelectionView: View { setupMode != .remote || app.remoteControlFeatureEnabled } } - + var body: some View { VStack(spacing: 16) { ForEach(availableModes) { setupMode in @@ -28,16 +28,16 @@ struct ModeSelectionView: View { 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) @@ -55,7 +55,7 @@ private struct ModeSelectionButton: View { #Preview { @Previewable @State var selectedMode: SetupMode? - + ModeSelectionView(mode: $selectedMode) .padding() .frame(width: 500, height: 400) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 1d86322..c5fa44b 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -12,4 +12,3 @@ struct UptimeInfo { return String(format: "%02dm %02ds", m, s) } } - diff --git a/SwiftBotApp/Models/AIModels.swift b/SwiftBotApp/Models/AIModels.swift index 2e85f4f..e6980e4 100644 --- a/SwiftBotApp/Models/AIModels.swift +++ b/SwiftBotApp/Models/AIModels.swift @@ -223,9 +223,9 @@ actor ConversationStore { messagesByScope.removeAll() emitUpdate() } - + func summaries() -> [MemorySummary] { - messagesByScope.map { (scope, records) in + messagesByScope.map { scope, records in MemorySummary( scope: scope, messageCount: records.count, @@ -233,7 +233,7 @@ actor ConversationStore { ) } } - + func allRecordsSorted() -> [MemoryRecord] { allMessages().sorted { $0.timestamp < $1.timestamp } } @@ -269,7 +269,7 @@ actor ConversationStore { guard !existing.contains(where: { $0.id == messageID }) else { return } append(scope: scope, messageID: messageID, userID: userID, content: content, timestamp: timestamp, role: role) } - + func recentMessages(in scope: MemoryScope, limit: Int) -> [MemoryRecord] { let messages = messagesByScope[scope] ?? [] return messages.sorted { $0.timestamp > $1.timestamp }.prefix(limit).map { $0 } diff --git a/SwiftBotApp/Models/BotSettings.swift b/SwiftBotApp/Models/BotSettings.swift index 4072415..6d98127 100644 --- a/SwiftBotApp/Models/BotSettings.swift +++ b/SwiftBotApp/Models/BotSettings.swift @@ -15,7 +15,6 @@ enum AITestOverrides { } #endif - // MARK: - Core Models struct GuildSettings: Codable, Hashable { @@ -56,7 +55,7 @@ struct AdminWebUISettings: Codable, Hashable { // Internal constants (not user-configurable) static let defaultBindHost = "127.0.0.1" static let defaultPort = 38888 - + var enabled: Bool = false var publicBaseURL: String = "" var internetAccessEnabled: Bool = false @@ -65,7 +64,7 @@ struct AdminWebUISettings: Codable, Hashable { var selectedZoneID: String = "" var selectedZoneName: String = "" var cloudflareAPIToken: String = "" - + // Legacy compatibility - always returns fixed values var bindHost: String { Self.defaultBindHost } var port: Int { Self.defaultPort } @@ -79,7 +78,7 @@ struct AdminWebUISettings: Codable, Hashable { var importedCertificateFile: String = "" var importedPrivateKeyFile: String = "" var importedCertificateChainFile: String = "" - + // OAuth Providers (Discord is active, others are placeholders) var discordOAuth = OAuthProviderSettings() var appleOAuth = OAuthProviderSettings() @@ -88,7 +87,7 @@ struct AdminWebUISettings: Codable, Hashable { var localAuthEnabled: Bool = false var localAuthUsername: String = "admin" var localAuthPassword: String = "" - + // Legacy compatibility - migrated to oauth providers var discordClientID: String { discordOAuth.clientID } var discordClientSecret: String { discordOAuth.clientSecret } @@ -148,25 +147,25 @@ struct AdminWebUISettings: Codable, Hashable { enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false publicBaseURL = try container.decodeIfPresent(String.self, forKey: .publicBaseURL) ?? "" - + // Migration: prefer hostname hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" subdomain = try container.decodeIfPresent(String.self, forKey: .subdomain) ?? "swiftbot" selectedZoneID = try container.decodeIfPresent(String.self, forKey: .selectedZoneID) ?? "" selectedZoneName = try container.decodeIfPresent(String.self, forKey: .selectedZoneName) ?? "" - + cloudflareAPIToken = try container.decodeIfPresent(String.self, forKey: .cloudflareAPIToken) ?? "" - + // Migration: internetAccessEnabled replaces publicAccessEnabled let decodedInternetAccessEnabled = try container.decodeIfPresent(Bool.self, forKey: .internetAccessEnabled) let decodedPublicAccessEnabled = try container.decodeIfPresent(Bool.self, forKey: .publicAccessEnabled) internetAccessEnabled = decodedInternetAccessEnabled ?? decodedPublicAccessEnabled ?? false - + publicAccessTunnelID = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelID) ?? "" publicAccessTunnelName = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelName) ?? "" publicAccessTunnelAccountID = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelAccountID) ?? "" publicAccessTunnelToken = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelToken) ?? "" - + // OAuth Providers - decode or migrate from legacy fields discordOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .discordOAuth) ?? OAuthProviderSettings( @@ -312,7 +311,7 @@ struct BotSettings: Codable, Hashable { var bugAutoFixRejectEmoji: String = "🛑" var bugAutoFixAllowedUsernames: [String] = [] var aiMemoryNotes: [AIMemoryNote] = [] - var localAISystemPrompt: String = "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." + var localAISystemPrompt: String = "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." // swiftlint:disable:this line_length var behavior = BotBehaviorSettings() var wikiBot = WikiBotSettings() var patchy = PatchySettings() @@ -460,7 +459,7 @@ struct BotSettings: Codable, Hashable { bugAutoFixRejectEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixRejectEmoji) ?? "🛑" bugAutoFixAllowedUsernames = try container.decodeIfPresent([String].self, forKey: .bugAutoFixAllowedUsernames) ?? [] aiMemoryNotes = try container.decodeIfPresent([AIMemoryNote].self, forKey: .aiMemoryNotes) ?? [] - localAISystemPrompt = try container.decodeIfPresent(String.self, forKey: .localAISystemPrompt) ?? "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." + localAISystemPrompt = try container.decodeIfPresent(String.self, forKey: .localAISystemPrompt) ?? "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." // swiftlint:disable:this line_length behavior = try container.decodeIfPresent(BotBehaviorSettings.self, forKey: .behavior) ?? BotBehaviorSettings() wikiBot = try container.decodeIfPresent(WikiBotSettings.self, forKey: .wikiBot) ?? WikiBotSettings() patchy = try container.decodeIfPresent(PatchySettings.self, forKey: .patchy) ?? PatchySettings() @@ -549,7 +548,7 @@ struct BotBehaviorSettings: Codable, Hashable { } struct WikiCommand: Codable, Hashable, Identifiable { - var id: UUID = UUID() + var id = UUID() var trigger: String = "!wiki" var endpoint: String = "search" var description: String = "" @@ -594,20 +593,20 @@ struct WikiFormatting: Codable, Hashable { } struct WikiParsingRule: Codable, Hashable, Identifiable { - var id: UUID = UUID() + var id = UUID() var pageType: String = "weapon" var templateName: String = "Weapon" } struct WikiSource: Codable, Hashable, Identifiable { - var id: UUID = UUID() + var id = UUID() var name: String = "Wiki Source" var baseURL: String = "https://example.fandom.com" var apiPath: String = "/api.php" var enabled: Bool = true var isPrimary: Bool = false var commands: [WikiCommand] = [] - var formatting: WikiFormatting = WikiFormatting() + var formatting = WikiFormatting() var parsingRules: [WikiParsingRule] = [] var lastLookupAt: Date? var lastStatus: String = "Never used" @@ -984,4 +983,3 @@ private extension String { return trimmed.isEmpty ? nil : trimmed } } - diff --git a/SwiftBotApp/Models/ClusterModels.swift b/SwiftBotApp/Models/ClusterModels.swift index f023f19..c3923c2 100644 --- a/SwiftBotApp/Models/ClusterModels.swift +++ b/SwiftBotApp/Models/ClusterModels.swift @@ -12,7 +12,7 @@ enum PatchySourceKind: String, Codable, CaseIterable, Identifiable { } struct PatchyDeliveryTarget: Codable, Hashable, Identifiable { - var id: UUID = UUID() + var id = UUID() var isEnabled: Bool = true var name: String = "Target" var serverId: String = "" @@ -21,7 +21,7 @@ struct PatchyDeliveryTarget: Codable, Hashable, Identifiable { } struct PatchySourceTarget: Codable, Hashable, Identifiable { - var id: UUID = UUID() + var id = UUID() var isEnabled: Bool = true var source: PatchySourceKind = .nvidia var steamAppID: String = "570" diff --git a/SwiftBotApp/Models/DiscordCache.swift b/SwiftBotApp/Models/DiscordCache.swift index 3b3aa08..9a1109b 100644 --- a/SwiftBotApp/Models/DiscordCache.swift +++ b/SwiftBotApp/Models/DiscordCache.swift @@ -1,7 +1,7 @@ import Foundation struct DiscordCacheSnapshot: Codable, Hashable { - var updatedAt: Date = Date() + var updatedAt = Date() var connectedServers: [String: String] = [:] var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] = [:] var availableTextChannelsByServer: [String: [GuildTextChannel]] = [:] diff --git a/SwiftBotApp/Models/EventBus.swift b/SwiftBotApp/Models/EventBus.swift index e1263f6..555e45e 100644 --- a/SwiftBotApp/Models/EventBus.swift +++ b/SwiftBotApp/Models/EventBus.swift @@ -18,7 +18,7 @@ struct SubscriptionToken: Hashable, Identifiable { final class EventBus { private actor Storage { private var subscribers: [ObjectIdentifier: [SubscriptionToken: (Any) async -> Void]] = [:] - + func add(type: ObjectIdentifier, token: SubscriptionToken, handler: @escaping (Any) async -> Void) { if subscribers[type] != nil { subscribers[type]![token] = handler @@ -26,7 +26,7 @@ final class EventBus { subscribers[type] = [token: handler] } } - + func remove(token: SubscriptionToken) { for (key, var dict) in subscribers { dict[token] = nil @@ -37,15 +37,15 @@ final class EventBus { } } } - + func snapshotHandlers(for type: ObjectIdentifier) -> [(Any) async -> Void] { guard let dict = subscribers[type] else { return [] } return Array(dict.values) } } - + private let storage = Storage() - + /// Subscribes to events of the specified type. @discardableResult func subscribe(_ type: E.Type, handler: @escaping (E) async -> Void) async -> SubscriptionToken { @@ -62,7 +62,7 @@ final class EventBus { func unsubscribe(_ token: SubscriptionToken) async { await storage.remove(token: token) } - + /// Publishes an event to all subscribers of its type. func publish(_ event: E) async { let handlers = await storage.snapshotHandlers(for: ObjectIdentifier(E.self)) @@ -78,13 +78,6 @@ struct VoiceJoined: Event { let userId: String let username: String let channelId: String - - init(guildId: String, userId: String, username: String, channelId: String) { - self.guildId = guildId - self.userId = userId - self.username = username - self.channelId = channelId - } } /// An event signaling a user has left a voice channel. @@ -94,14 +87,6 @@ struct VoiceLeft: Event { let username: String let channelId: String let durationSeconds: Int - - init(guildId: String, userId: String, username: String, channelId: String, durationSeconds: Int) { - self.guildId = guildId - self.userId = userId - self.username = username - self.channelId = channelId - self.durationSeconds = durationSeconds - } } /// An event signaling that a message was received. @@ -112,13 +97,4 @@ struct MessageReceived: Event { let username: String let content: String let isDirectMessage: Bool - - init(guildId: String?, channelId: String, userId: String, username: String, content: String, isDirectMessage: Bool) { - self.guildId = guildId - self.channelId = channelId - self.userId = userId - self.username = username - self.content = content - self.isDirectMessage = isDirectMessage - } } diff --git a/SwiftBotApp/Models/GatewayModels.swift b/SwiftBotApp/Models/GatewayModels.swift index 7fe92db..7821def 100644 --- a/SwiftBotApp/Models/GatewayModels.swift +++ b/SwiftBotApp/Models/GatewayModels.swift @@ -22,14 +22,26 @@ enum DiscordJSON: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - if container.decodeNil() { self = .null } - else if let value = try? container.decode(String.self) { self = .string(value) } - else if let value = try? container.decode(Int.self) { self = .int(value) } - else if let value = try? container.decode(Double.self) { self = .double(value) } - else if let value = try? container.decode(Bool.self) { self = .bool(value) } - else if let value = try? container.decode([String: DiscordJSON].self) { self = .object(value) } - else if let value = try? container.decode([DiscordJSON].self) { self = .array(value) } - else { throw DecodingError.typeMismatch(DiscordJSON.self, .init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON type")) } + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([String: DiscordJSON].self) { + self = .object(value) + } else if let value = try? container.decode([DiscordJSON].self) { + self = .array(value) + } else { + throw DecodingError.typeMismatch( + DiscordJSON.self, + .init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON type") + ) + } } func encode(to encoder: Encoder) throws { diff --git a/SwiftBotApp/Models/MediaLibraryIndexer.swift b/SwiftBotApp/Models/MediaLibraryIndexer.swift new file mode 100644 index 0000000..9299d71 --- /dev/null +++ b/SwiftBotApp/Models/MediaLibraryIndexer.swift @@ -0,0 +1,108 @@ +import AppKit +import AVFoundation +import Foundation + +actor MediaLibraryIndexer { + private struct CacheEntry { + let signature: String + let payload: MediaLibraryPayload + let createdAt: Date + } + + private var cachedEntry: CacheEntry? + private let cacheTTL: TimeInterval = 30 + + func cachedItem(for id: String) -> MediaLibraryItem? { + cachedEntry?.payload.items.first(where: { $0.id == id }) + } + + func invalidate() { + cachedEntry = nil + } + + func snapshot( + sources: [MediaLibrarySource], + ownerNodeName: String, + ownerBaseURL: String?, + configFilePath: String + ) -> MediaLibraryPayload { + let signature = makeSignature(sources: sources, ownerNodeName: ownerNodeName, ownerBaseURL: ownerBaseURL, configFilePath: configFilePath) + if let cachedEntry, cachedEntry.signature == signature, Date().timeIntervalSince(cachedEntry.createdAt) < cacheTTL { + return cachedEntry.payload + } + + let payload = MediaLibraryPayload( + nodeName: ownerNodeName, + configFilePath: configFilePath, + sources: sources, + items: scanItems(sources: sources, ownerNodeName: ownerNodeName, ownerBaseURL: ownerBaseURL), + generatedAt: Date() + ) + cachedEntry = CacheEntry(signature: signature, payload: payload, createdAt: Date()) + return payload + } + + private func makeSignature( + sources: [MediaLibrarySource], + ownerNodeName: String, + ownerBaseURL: String?, + configFilePath: String + ) -> String { + let sourceSignature = sources.map { + "\($0.id.uuidString)|\($0.name)|\($0.normalizedRootPath)|\($0.isEnabled)|\($0.normalizedExtensions.joined(separator: ","))" + }.joined(separator: "||") + return "\(ownerNodeName)|\(ownerBaseURL ?? "")|\(configFilePath)|\(sourceSignature)" + } + + private func scanItems( + sources: [MediaLibrarySource], + ownerNodeName: String, + ownerBaseURL: String? + ) -> [MediaLibraryItem] { + let fileManager = FileManager.default + var items: [MediaLibraryItem] = [] + + for source in sources where source.isEnabled { + let root = source.normalizedRootPath + guard !root.isEmpty else { continue } + + let rootURL = URL(fileURLWithPath: root, isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: rootURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { continue } + + let allowedExtensions = Set(source.normalizedExtensions) + for case let fileURL as URL in enumerator { + let ext = fileURL.pathExtension.lowercased() + guard allowedExtensions.isEmpty || allowedExtensions.contains(ext) else { continue } + guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]), + values.isRegularFile == true else { continue } + + let relativePath = fileURL.path.replacingOccurrences(of: rootURL.path + "/", with: "") + let id = "\(source.id.uuidString)|\(relativePath)" + items.append( + MediaLibraryItem( + id: id, + sourceID: source.id, + sourceName: source.name, + fileName: fileURL.lastPathComponent, + relativePath: relativePath, + absolutePath: fileURL.path, + fileExtension: ext, + sizeBytes: Int64(values.fileSize ?? 0), + modifiedAt: values.contentModificationDate ?? .distantPast, + ownerNodeName: ownerNodeName, + ownerBaseURL: ownerBaseURL + ) + ) + } + } + + return items.sorted { + if $0.modifiedAt != $1.modifiedAt { return $0.modifiedAt > $1.modifiedAt } + return $0.fileName.localizedCaseInsensitiveCompare($1.fileName) == .orderedAscending + } + } +} diff --git a/SwiftBotApp/Models/MediaThumbnailCache.swift b/SwiftBotApp/Models/MediaThumbnailCache.swift new file mode 100644 index 0000000..5985162 --- /dev/null +++ b/SwiftBotApp/Models/MediaThumbnailCache.swift @@ -0,0 +1,99 @@ +import AppKit +import AVFoundation +import CryptoKit +import Foundation + +actor MediaThumbnailCache { + private let fileManager = FileManager.default + + private func cacheDirectoryURL() -> URL { + let url = SwiftBotStorage.folderURL().appendingPathComponent("media-thumbnails", isDirectory: true) + try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + func thumbnailResponse(for item: MediaLibraryItem) async -> BinaryHTTPResponse? { + let cacheURL = cachedThumbnailURL(for: item) + if let data = try? Data(contentsOf: cacheURL) { + return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=300"], body: data) + } + + guard let image = await generateThumbnail(for: item) else { return nil } + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.78]) else { + return nil + } + try? jpeg.write(to: cacheURL, options: .atomic) + return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=300"], body: jpeg) + } + + func frameResponse(for item: MediaLibraryItem, atSeconds: Double) async -> BinaryHTTPResponse? { + let cacheURL = cachedFrameURL(for: item, atSeconds: atSeconds) + if let data = try? Data(contentsOf: cacheURL) { + return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=120"], body: data) + } + + guard let image = await generateFrame(for: item, atSeconds: atSeconds) else { return nil } + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.72]) else { + return nil + } + try? jpeg.write(to: cacheURL, options: .atomic) + return BinaryHTTPResponse(status: "200 OK", contentType: "image/jpeg", headers: ["Cache-Control": "public, max-age=120"], body: jpeg) + } + + private func cachedThumbnailURL(for item: MediaLibraryItem) -> URL { + let fingerprint = "\(item.absolutePath)|\(item.modifiedAt.timeIntervalSince1970)|\(item.sizeBytes)" + let digest = SHA256.hash(data: Data(fingerprint.utf8)).map { String(format: "%02x", $0) }.joined() + return cacheDirectoryURL().appendingPathComponent("\(digest).jpg") + } + + private func cachedFrameURL(for item: MediaLibraryItem, atSeconds: Double) -> URL { + let rounded = String(format: "%.1f", max(0, atSeconds)) + let fingerprint = "\(item.absolutePath)|\(item.modifiedAt.timeIntervalSince1970)|\(item.sizeBytes)|frame|\(rounded)" + let digest = SHA256.hash(data: Data(fingerprint.utf8)).map { String(format: "%02x", $0) }.joined() + let folder = cacheDirectoryURL().appendingPathComponent("frames", isDirectory: true) + try? fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + return folder.appendingPathComponent("\(digest).jpg") + } + + private func generateThumbnail(for item: MediaLibraryItem) async -> NSImage? { + let url = URL(fileURLWithPath: item.absolutePath) + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 640, height: 360) + + let time = CMTime(seconds: 2, preferredTimescale: 600) + return await withCheckedContinuation { continuation in + generator.generateCGImageAsynchronously(for: time) { cgImage, _, error in + guard let cgImage, error == nil else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))) + } + } + } + + private func generateFrame(for item: MediaLibraryItem, atSeconds: Double) async -> NSImage? { + let url = URL(fileURLWithPath: item.absolutePath) + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 420, height: 240) + + let time = CMTime(seconds: max(0, atSeconds), preferredTimescale: 600) + return await withCheckedContinuation { continuation in + generator.generateCGImageAsynchronously(for: time) { cgImage, _, error in + guard let cgImage, error == nil else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))) + } + } + } +} diff --git a/SwiftBotApp/Models/RuleEngineModels.swift b/SwiftBotApp/Models/RuleEngineModels.swift index c39fdc1..9d319e4 100644 --- a/SwiftBotApp/Models/RuleEngineModels.swift +++ b/SwiftBotApp/Models/RuleEngineModels.swift @@ -115,7 +115,7 @@ final class RuleEngine { private var cancellable: AnyCancellable? private var _activeRules: [Rule] = [] private let lock = NSLock() - + private var activeRules: [Rule] { get { lock.lock() @@ -250,12 +250,12 @@ final class PluginManager { final class WeeklySummaryPlugin: BotPlugin { let name = "WeeklySummary" - + private var tokens: [SubscriptionToken] = [] private var voiceDurations: [String: Int] = [:] // userId -> accumulated seconds - + init() {} - + func register(on bus: EventBus) async { let joinToken = await bus.subscribe(VoiceJoined.self) { _ in // No-op for accumulation; could log here if needed @@ -268,25 +268,25 @@ final class WeeklySummaryPlugin: BotPlugin { } tokens.append(leftToken) } - + func unregister(from bus: EventBus) async { for token in tokens { await bus.unsubscribe(token) } tokens.removeAll() } - + func snapshotSummary() -> String { let sortedUsers = voiceDurations.sorted { $0.value > $1.value } guard !sortedUsers.isEmpty else { return "No voice activity recorded yet." } - + let summaryLines = sortedUsers.prefix(5).map { userId, seconds in let minutes = seconds / 60 return "\(userId): \(minutes) minute\(minutes == 1 ? "" : "s")" } - + return "Weekly Voice Summary:\n" + summaryLines.joined(separator: "\n") } } @@ -415,7 +415,7 @@ enum ContextVariable: String, CaseIterable, Codable, Hashable { case mediaPath = "{media.path}" case mediaSource = "{media.source}" case mediaNode = "{media.node}" - + var displayName: String { switch self { case .user: return "User" @@ -448,7 +448,7 @@ enum ContextVariable: String, CaseIterable, Codable, Hashable { case .mediaNode: return "Media Node" } } - + var category: String { switch self { case .user, .userId, .username, .userNickname, .userMention: @@ -477,7 +477,7 @@ extension Set where Element == ContextVariable { /// Returns a user-friendly description of the required context (Task 1) var friendlyRequirement: String { if self.isEmpty { return "" } - + // Priority based on trigger types if self.contains(where: { $0.category == "Message" || $0.category == "Reaction" }) { return "a message trigger" @@ -488,7 +488,7 @@ extension Set where Element == ContextVariable { if self.contains(where: { $0.category == "User" }) { return "a user trigger" } - + return "additional context" } } @@ -537,7 +537,7 @@ enum DiscordPermission: String, CaseIterable, Codable, Hashable { case sendMessagesInThreads = "SEND_MESSAGES_IN_THREADS" case useEmbeddedActivities = "USE_EMBEDDED_ACTIVITIES" case moderateMembers = "MODERATE_MEMBERS" - + var displayName: String { switch self { case .createInstantInvite: return "Create Invite" @@ -582,7 +582,7 @@ enum DiscordPermission: String, CaseIterable, Codable, Hashable { case .moderateMembers: return "Timeout Members" } } - + var bitValue: UInt64 { switch self { case .createInstantInvite: return 1 << 0 @@ -703,7 +703,7 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { case .mediaAdded: return "Media Added" } } - + /// Variables provided by this trigger type var providedVariables: Set { switch self { @@ -770,7 +770,7 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { case .channelType: return "number.square" } } - + /// Variables required to evaluate this condition var requiredVariables: Set { switch self { @@ -813,7 +813,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case delay = "Delay" case setVariable = "Set Variable" case randomChoice = "Random" - + // New Modifier Types case replyToTrigger = "Reply To Trigger Message" case mentionUser = "Mention User" @@ -821,7 +821,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case disableMention = "Disable User Mentions" case sendToChannel = "Send To Channel" case sendToDM = "Send To DM" - + // AI Types case generateAIResponse = "Generate AI Response" case summariseMessage = "Summarise Message" @@ -862,7 +862,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .rewriteMessage: return "pencil" } } - + /// Variables required by this action type var requiredVariables: Set { switch self { @@ -879,7 +879,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { return [] } } - + /// Variables provided/output by this action type var outputVariables: Set { switch self { @@ -893,14 +893,14 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { return [.aiEntities] case .rewriteMessage: return [.aiRewrite] - case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, + case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: return [] } } - + /// Discord permissions required for this action var requiredPermissions: Set { switch self { @@ -924,13 +924,13 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { return [.manageWebhooks] } } - + /// Category for block library organization var category: BlockCategory { switch self { case .replyToTrigger, .disableMention, .sendToChannel, .sendToDM, .mentionUser, .mentionRole: return .messaging - case .sendMessage, .sendDM, .addReaction, .deleteMessage, .createChannel, .webhook, + case .sendMessage, .sendDM, .addReaction, .deleteMessage, .createChannel, .webhook, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice: return .actions case .generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: @@ -996,7 +996,7 @@ struct RuleAction: Identifiable, Codable, Equatable { var replyWithAI: Bool = false var message: String = "🔊 <@{userId}> connected to <#{channelId}>" var statusText: String = "Voice notifier active" - + // New fields for extended action types var dmContent: String = "" // For sendDM var emoji: String = "👍" // For addReaction @@ -1012,17 +1012,17 @@ struct RuleAction: Identifiable, Codable, Equatable { var variableValue: String = "" // For setVariable var randomOptions: [String] = [] // For randomChoice var deleteDelaySeconds: Int = 0 // For deleteMessage (delayed delete) - + // AI Processing block fields var categories: String = "" // For classifyMessage (comma-separated categories) var entityTypes: String = "" // For extractEntities (comma-separated entity types) var rewriteStyle: String = "" // For rewriteMessage (style description) - + // Unified Send Message content source (replaces replyWithAI, etc.) var contentSource: ContentSource = .custom - + // Message destination mode (per UX spec: replyToTrigger, sameChannel, specificChannel) - var destinationMode: MessageDestination? = nil + var destinationMode: MessageDestination? enum CodingKeys: String, CodingKey { case id @@ -1087,23 +1087,23 @@ struct RuleAction: Identifiable, Codable, Equatable { categories = try container.decodeIfPresent(String.self, forKey: .categories) ?? "" entityTypes = try container.decodeIfPresent(String.self, forKey: .entityTypes) ?? "" rewriteStyle = try container.decodeIfPresent(String.self, forKey: .rewriteStyle) ?? "" - + // Decode contentSource with legacy migration let decodedContentSource = try container.decodeIfPresent(ContentSource.self, forKey: .contentSource) let decodedReplyWithAI = try container.decodeIfPresent(Bool.self, forKey: .replyWithAI) ?? false - + // Migration: replyWithAI true -> contentSource = aiResponse if decodedContentSource == nil && decodedReplyWithAI && type == .sendMessage { contentSource = .aiResponse } else { contentSource = decodedContentSource ?? .custom } - + // Decode destinationMode with legacy migration let decodedDestinationMode = try container.decodeIfPresent(MessageDestination.self, forKey: .destinationMode) let decodedReplyToTrigger = try container.decodeIfPresent(Bool.self, forKey: .replyToTriggerMessage) ?? false let hasExplicitChannel = !(try container.decodeIfPresent(String.self, forKey: .channelId) ?? "").isEmpty - + // Migration logic per UX spec: // - Existing destinationMode -> keep it // - Legacy replyToTriggerMessage=true -> replyToTrigger @@ -1166,7 +1166,7 @@ enum ContentSource: String, Codable, CaseIterable { case aiClassification = "ai.classification" case aiEntities = "ai.entities" case aiRewrite = "ai.rewrite" - + var displayName: String { switch self { case .custom: return "Custom Message" @@ -1184,7 +1184,7 @@ enum MessageDestination: String, Codable, CaseIterable { case replyToTrigger = "replyToTrigger" case sameChannel = "sameChannel" case specificChannel = "specificChannel" - + var displayName: String { switch self { case .replyToTrigger: return "Reply to Trigger" @@ -1220,7 +1220,7 @@ extension MessageDestination { typealias Action = RuleAction struct Rule: Identifiable, Codable, Equatable { - var id: UUID = UUID() + var id = UUID() var name: String = "New Action" var trigger: TriggerType? var conditions: [Condition] = [] @@ -1279,17 +1279,17 @@ struct Rule: Identifiable, Codable, Equatable { } // MARK: - Codable Migration - + /// Coding keys for Rule enum CodingKeys: String, CodingKey { case id, name, trigger, conditions, modifiers, actions, aiBlocks, isEnabled case triggerServerId, triggerVoiceChannelId, triggerMessageContains, replyToDMs, includeStageChannels } - + /// Custom decoder that migrates legacy properties and separates AI blocks from actions init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - + id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) trigger = try container.decodeIfPresent(TriggerType.self, forKey: .trigger) @@ -1298,38 +1298,38 @@ struct Rule: Identifiable, Codable, Equatable { actions = try container.decode([RuleAction].self, forKey: .actions) aiBlocks = try container.decodeIfPresent([RuleAction].self, forKey: .aiBlocks) ?? [] isEnabled = try container.decode(Bool.self, forKey: .isEnabled) - + // Legacy properties - keep for backwards compatibility but migrate to conditions triggerServerId = try container.decodeIfPresent(String.self, forKey: .triggerServerId) ?? "" triggerVoiceChannelId = try container.decodeIfPresent(String.self, forKey: .triggerVoiceChannelId) ?? "" triggerMessageContains = try container.decodeIfPresent(String.self, forKey: .triggerMessageContains) ?? "" replyToDMs = try container.decodeIfPresent(Bool.self, forKey: .replyToDMs) ?? false includeStageChannels = try container.decodeIfPresent(Bool.self, forKey: .includeStageChannels) ?? true - + // Migration: Convert legacy trigger properties to filter conditions // Only add if not already present to avoid duplicates on repeated saves var migratedConditions: [Condition] = [] - + // Migrate triggerServerId -> Condition.server if !triggerServerId.isEmpty && !conditions.contains(where: { $0.type == .server }) { migratedConditions.append(Condition(type: .server, value: triggerServerId)) } - + // Migrate triggerVoiceChannelId -> Condition.voiceChannel if !triggerVoiceChannelId.isEmpty && !conditions.contains(where: { $0.type == .voiceChannel }) { migratedConditions.append(Condition(type: .voiceChannel, value: triggerVoiceChannelId)) } - + // Migrate triggerMessageContains -> Condition.messageContains if !triggerMessageContains.isEmpty && triggerMessageContains != "up to?" && !conditions.contains(where: { $0.type == .messageContains }) { migratedConditions.append(Condition(type: .messageContains, value: triggerMessageContains)) } - + // Append migrated conditions to existing conditions if !migratedConditions.isEmpty { conditions.append(contentsOf: migratedConditions) } - + // Migration: Move AI blocks from actions to aiBlocks for backwards compatibility let aiBlockTypes: [ActionType] = [.generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage] let (aiBlocksFromActions, remainingActions) = actions.reduce(into: ([RuleAction](), [RuleAction]())) { result, action in @@ -1362,17 +1362,17 @@ struct Rule: Identifiable, Codable, Equatable { /// AI Processing → Message Modifiers → Actions var processedActions: [RuleAction] { var pipeline: [RuleAction] = [] - + // 1. AI Processing blocks first pipeline.append(contentsOf: aiBlocks) - + // 2. Message Modifiers pipeline.append(contentsOf: modifiers) - + // 3. Actions (excluding AI blocks and extracting embedded modifiers) for action in actions { var actionWithModifiers = action - + // Legacy: replyWithAI toggle creates an AI block if action.type == .sendMessage && action.replyWithAI && action.contentSource == .custom { var aiBlock = RuleAction() @@ -1381,7 +1381,7 @@ struct Rule: Identifiable, Codable, Equatable { pipeline.insert(aiBlock, at: aiBlocks.count) actionWithModifiers.replyWithAI = false } - + // Extract reply-to-trigger as a modifier if action.type == .sendMessage && action.replyToTriggerMessage && action.destinationMode == nil { var replyBlock = RuleAction() @@ -1389,7 +1389,7 @@ struct Rule: Identifiable, Codable, Equatable { pipeline.append(replyBlock) actionWithModifiers.replyToTriggerMessage = false } - + // Extract mention disable as a modifier if !action.mentionUser { // Default was true in legacy var disableMentionBlock = RuleAction() @@ -1397,10 +1397,10 @@ struct Rule: Identifiable, Codable, Equatable { pipeline.append(disableMentionBlock) actionWithModifiers.mentionUser = true // Reset so we don't repeat } - + pipeline.append(actionWithModifiers) } - + return pipeline } @@ -1418,13 +1418,13 @@ struct Rule: Identifiable, Codable, Equatable { case .mediaAdded: return "When new media is detected" } } - + /// Returns any blocks that are incompatible with the current trigger var incompatibleBlocks: [UUID] { guard let trigger = trigger else { return [] } let available = trigger.providedVariables var ids: [UUID] = [] - + for condition in conditions { if !condition.type.requiredVariables.isSubset(of: available) { ids.append(condition.id) @@ -1447,10 +1447,10 @@ struct Rule: Identifiable, Codable, Equatable { guard let trigger = trigger, !isEditingTrigger else { return [] } - + var issues: [ValidationIssue] = [] let availableVariables = trigger.providedVariables - + // Check conditions for variable availability for condition in conditions { let requiredVars = condition.type.requiredVariables @@ -1488,7 +1488,7 @@ struct Rule: Identifiable, Codable, Equatable { )) } } - + // Check actions for variable availability and permissions for action in actions { let requiredVars = action.type.requiredVariables @@ -1501,7 +1501,7 @@ struct Rule: Identifiable, Codable, Equatable { blockId: action.id )) } - + // Task 5: Prevent empty Send Message actions if action.type == .sendMessage, action.contentSource == .custom, @@ -1524,7 +1524,7 @@ struct Rule: Identifiable, Codable, Equatable { blockId: action.id )) } - + // Check permissions (warnings, not errors - bot may have permissions) let requiredPerms = action.type.requiredPermissions if !requiredPerms.isEmpty { @@ -1549,17 +1549,17 @@ struct Rule: Identifiable, Codable, Equatable { return issues } - + /// Checks if rule has any blocking errors var hasBlockingErrors: Bool { validationIssues.contains { $0.severity == .error } } - + /// Returns just the errors (not warnings) var validationErrors: [ValidationIssue] { validationIssues.filter { $0.severity == .error } } - + /// Returns just the warnings var validationWarnings: [ValidationIssue] { validationIssues.filter { $0.severity == .warning } @@ -1573,18 +1573,18 @@ struct ValidationIssue: Identifiable, Hashable { let message: String let blockType: BlockType let blockId: UUID - + enum ValidationSeverity: String, Codable, CaseIterable { case warning = "Warning" case error = "Error" - + var icon: String { switch self { case .warning: return "exclamationmark.triangle" case .error: return "xmark.octagon" } } - + var color: String { switch self { case .warning: return "orange" @@ -1592,7 +1592,7 @@ struct ValidationIssue: Identifiable, Hashable { } } } - + enum BlockType: String, Codable, CaseIterable { case rule = "Rule" case trigger = "Trigger" diff --git a/SwiftBotApp/OnboardingRootView.swift b/SwiftBotApp/OnboardingRootView.swift index bb9a390..d8db8fa 100644 --- a/SwiftBotApp/OnboardingRootView.swift +++ b/SwiftBotApp/OnboardingRootView.swift @@ -6,9 +6,9 @@ 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" @@ -16,7 +16,7 @@ enum SetupMode: String, CaseIterable, Identifiable { case .remote: return "Connect to SwiftBot Remote" } } - + var subtitle: String { switch self { case .standalone: return "Run SwiftBot locally on this Mac." @@ -24,7 +24,7 @@ enum SetupMode: String, CaseIterable, Identifiable { case .remote: return "Control an existing SwiftBot node remotely. Beta feature." } } - + var icon: String { switch self { case .standalone: return "server.rack" @@ -38,16 +38,16 @@ enum SetupMode: String, CaseIterable, Identifiable { 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) { @@ -55,16 +55,16 @@ struct OnboardingRootView: View { .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 { @@ -101,16 +101,16 @@ struct OnboardingRootView: View { } .ignoresSafeArea() } - + // MARK: - Navigation - + private func navigateTo(_ newMode: SetupMode?) { movesForward = newMode != nil mode = newMode } - + // MARK: - Subtitle - + private var stepSubtitle: String { switch mode { case nil: diff --git a/SwiftBotApp/OnboardingStyles.swift b/SwiftBotApp/OnboardingStyles.swift index 0d8c838..09b5e27 100644 --- a/SwiftBotApp/OnboardingStyles.swift +++ b/SwiftBotApp/OnboardingStyles.swift @@ -137,7 +137,7 @@ extension View { .strokeBorder(.white.opacity(0.22), lineWidth: 1) ) } - + /// Glass-style button for onboarding actions func onboardingGlassButton() -> some View { self diff --git a/SwiftBotApp/OnboardingView.swift b/SwiftBotApp/OnboardingView.swift index 1722efc..cdfe01b 100644 --- a/SwiftBotApp/OnboardingView.swift +++ b/SwiftBotApp/OnboardingView.swift @@ -20,7 +20,7 @@ struct OnboardingGateView: View { @State private var step: Step = .choosePath @State private var tokenInput: String = "" @State private var showToken: Bool = false - @State private var inviteURL: String? = nil + @State private var inviteURL: String? @State private var inviteConfirmed: Bool = false @State private var isLoadingInviteURL: Bool = false @State private var inviteLoadFailed: Bool = false @@ -595,4 +595,3 @@ struct OnboardingGateView: View { } } } - diff --git a/SwiftBotApp/OverviewView.swift b/SwiftBotApp/OverviewView.swift index f803101..d6e661a 100644 --- a/SwiftBotApp/OverviewView.swift +++ b/SwiftBotApp/OverviewView.swift @@ -8,7 +8,7 @@ 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 = "" @@ -34,7 +34,7 @@ struct OverviewView: View { } // MARK: - Data Access via Provider - + private var settings: BotSettings { provider.settings } private var status: BotStatus { provider.status } private var stats: StatCounter { provider.stats } diff --git a/SwiftBotApp/PatchyViewModel.swift b/SwiftBotApp/PatchyViewModel.swift index 15d7bc2..a0a50c4 100644 --- a/SwiftBotApp/PatchyViewModel.swift +++ b/SwiftBotApp/PatchyViewModel.swift @@ -1,5 +1,4 @@ import Foundation -import UpdateEngine struct PatchyFetchResult: Sendable { let sourceName: String diff --git a/SwiftBotApp/Persistence.swift b/SwiftBotApp/Persistence.swift index e5b7fd9..19112a2 100644 --- a/SwiftBotApp/Persistence.swift +++ b/SwiftBotApp/Persistence.swift @@ -366,7 +366,7 @@ final class LogStore: ObservableObject { func append(_ line: String) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.contains("ViewBridge to RemoteViewService Terminated") || + if trimmed.contains("ViewBridge to RemoteViewService Terminated") || trimmed.contains("NSViewBridgeErrorCanceled") { return } diff --git a/SwiftBotApp/PreferencesView.swift b/SwiftBotApp/PreferencesView.swift index 1be62cb..63a6518 100644 --- a/SwiftBotApp/PreferencesView.swift +++ b/SwiftBotApp/PreferencesView.swift @@ -2,7 +2,7 @@ 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 @@ -96,7 +96,7 @@ struct PreferencesView: View { // 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 diff --git a/SwiftBotApp/RemoteSetupView.swift b/SwiftBotApp/RemoteSetupView.swift index e37a49f..598b30c 100644 --- a/SwiftBotApp/RemoteSetupView.swift +++ b/SwiftBotApp/RemoteSetupView.swift @@ -5,15 +5,15 @@ import SwiftUI 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 { @@ -33,15 +33,15 @@ struct RemoteSetupView: View { 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) @@ -49,14 +49,14 @@ struct RemoteSetupView: View { .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: { @@ -75,9 +75,9 @@ struct RemoteSetupView: View { 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 { @@ -91,25 +91,25 @@ struct RemoteSetupView: View { 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 { @@ -117,9 +117,9 @@ struct RemoteSetupView: View { step = ok ? .confirmed : .failed } } - + // MARK: - Authenticating View - + private var authenticatingView: some View { VStack(spacing: 16) { HStack(spacing: 10) { @@ -128,13 +128,13 @@ struct RemoteSetupView: View { 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") } @@ -144,9 +144,9 @@ struct RemoteSetupView: View { .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() @@ -157,9 +157,9 @@ struct RemoteSetupView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Testing remote connection, please wait") } - + // MARK: - Confirmed View - + private var confirmedView: some View { VStack(spacing: 16) { HStack(spacing: 8) { @@ -170,13 +170,13 @@ struct RemoteSetupView: View { 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 { @@ -188,7 +188,7 @@ struct RemoteSetupView: View { } } .frame(maxWidth: 560, alignment: .leading) - + Button { app.completeRemoteModeOnboarding( primaryNodeAddress: remoteAddressInput, @@ -201,9 +201,9 @@ struct RemoteSetupView: View { .onboardingGlassButton() } } - + // MARK: - Failed View - + private var failedView: some View { VStack(spacing: 16) { if let error = remoteTester.lastError, !error.isEmpty { @@ -213,14 +213,14 @@ struct RemoteSetupView: View { .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") } diff --git a/SwiftBotApp/RootView.swift b/SwiftBotApp/RootView.swift index 98e2447..82d1ba8 100644 --- a/SwiftBotApp/RootView.swift +++ b/SwiftBotApp/RootView.swift @@ -27,7 +27,7 @@ struct RootView: View { fallbackView } } - + @ViewBuilder private var fallbackView: some View { ProgressView("Loading dashboard...") @@ -116,7 +116,6 @@ private struct BetaBadgeView: View { } } - struct DashboardSidebar: View { @EnvironmentObject var app: AppModel @Binding var selection: SidebarItem diff --git a/SwiftBotApp/Services/BotDataProvider.swift b/SwiftBotApp/Services/BotDataProvider.swift index 76d694d..9f9853a 100644 --- a/SwiftBotApp/Services/BotDataProvider.swift +++ b/SwiftBotApp/Services/BotDataProvider.swift @@ -9,7 +9,7 @@ protocol BotDataProvider: ObservableObject { var changePublisher: AnyPublisher { get } // MARK: - State Properties - + var settings: BotSettings { get } var status: BotStatus { get } var stats: StatCounter { get } @@ -24,22 +24,22 @@ protocol BotDataProvider: ObservableObject { 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 } @@ -49,9 +49,9 @@ protocol BotDataProvider: ObservableObject { 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 @@ -68,9 +68,9 @@ final class AnyBotDataProvider: ObservableObject, BotDataProvider { private var changeCancellable: AnyCancellable? nonisolated(unsafe) let objectWillChange = ObservableObjectPublisher() - + // MARK: - BotDataProvider Properties (forwarded) - + var settings: BotSettings { _base.settings } var status: BotStatus { _base.status } var stats: StatCounter { _base.stats } @@ -92,70 +92,70 @@ final class AnyBotDataProvider: ObservableObject, BotDataProvider { 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/DiscordAIService.swift b/SwiftBotApp/Services/DiscordAIService.swift index ea8d054..8f3b91b 100644 --- a/SwiftBotApp/Services/DiscordAIService.swift +++ b/SwiftBotApp/Services/DiscordAIService.swift @@ -297,6 +297,7 @@ struct OpenAIImageEngine { private struct ImageGenerationResponse: Decodable { struct ImageData: Decodable { + // swiftlint:disable:next identifier_name let b64_json: String? } @@ -548,10 +549,10 @@ actor DiscordAIService { return (index, normalized.isEmpty ? nil : normalized) } } - + // Collect first non-nil result - var bestResult: (index: Int, reply: String)? = nil - + var bestResult: (index: Int, reply: String)? + for await (index, result) in group { if let result { bestResult = (index, result) @@ -559,7 +560,7 @@ actor DiscordAIService { break } } - + return bestResult?.reply } } @@ -579,7 +580,7 @@ actor DiscordAIService { await ollamaModelResolver(baseURL, preferredModel) } - private nonisolated func stripLeadingSpeakerPrefix(_ text: String, username: String) -> String { + nonisolated private func stripLeadingSpeakerPrefix(_ text: String, username: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let speaker = username.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !speaker.isEmpty else { return trimmed } diff --git a/SwiftBotApp/Services/LocalBotProvider.swift b/SwiftBotApp/Services/LocalBotProvider.swift index 9a7f399..1ab6b49 100644 --- a/SwiftBotApp/Services/LocalBotProvider.swift +++ b/SwiftBotApp/Services/LocalBotProvider.swift @@ -31,69 +31,69 @@ final class LocalBotProvider: ObservableObject, BotDataProvider { 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 }) { @@ -103,35 +103,35 @@ final class LocalBotProvider: ObservableObject, BotDataProvider { } 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 { app.addPatchyTarget(target) } - + func updatePatchyTarget(_ target: PatchySourceTarget) async throws { app.updatePatchyTarget(target) } - + func deletePatchyTarget(_ id: UUID) async throws { app.deletePatchyTarget(id) } - + func togglePatchyTargetEnabled(_ id: UUID) async throws { app.togglePatchyTargetEnabled(id) } - + func sendPatchyTest(targetID: UUID) async throws { app.sendPatchyTest(targetID: targetID) } - + func runPatchyManualCheck() async throws { app.runPatchyManualCheck() } diff --git a/SwiftBotApp/Services/RemoteBotProvider.swift b/SwiftBotApp/Services/RemoteBotProvider.swift index 71c907f..0cf2b3f 100644 --- a/SwiftBotApp/Services/RemoteBotProvider.swift +++ b/SwiftBotApp/Services/RemoteBotProvider.swift @@ -18,16 +18,16 @@ final class RemoteBotProvider: BotDataProvider { @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 @@ -35,21 +35,21 @@ final class RemoteBotProvider: BotDataProvider { 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") @@ -57,19 +57,19 @@ final class RemoteBotProvider: BotDataProvider { 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) } @@ -77,7 +77,7 @@ final class RemoteBotProvider: BotDataProvider { print("RemoteBotProvider refresh failed: \(error)") } } - + private func updateState( status: RemoteStatusPayload, rules: RemoteRulesPayload, @@ -86,7 +86,7 @@ final class RemoteBotProvider: BotDataProvider { ) { 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 @@ -95,51 +95,51 @@ final class RemoteBotProvider: BotDataProvider { 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, @@ -166,62 +166,62 @@ final class RemoteBotProvider: BotDataProvider { 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 { try await api.post("/api/patchy/target/upsert", body: AdminWebPatchyTargetPatch(target: target)) await refresh() } - + func updatePatchyTarget(_ target: PatchySourceTarget) async throws { try await api.post("/api/patchy/target/upsert", body: AdminWebPatchyTargetPatch(target: target)) await refresh() } - + func deletePatchyTarget(_ id: UUID) async throws { try await api.post("/api/patchy/target/delete", body: AdminWebPatchyTargetIDPatch(targetID: id)) await refresh() } - + func togglePatchyTargetEnabled(_ id: UUID) async throws { // Find current state to toggle guard let target = settings.patchy.sourceTargets.first(where: { $0.id == id }) else { return } try await api.post("/api/patchy/target/toggle", body: AdminWebPatchyTargetEnabledPatch(targetID: id, enabled: !target.isEnabled)) await refresh() } - + func sendPatchyTest(targetID: UUID) async throws { try await api.post("/api/patchy/target/test", body: AdminWebPatchyTargetIDPatch(targetID: targetID)) // No refresh needed immediately, logs will follow in background refresh } - + func runPatchyManualCheck() async throws { try await api.post("/api/patchy/check") } - + 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 } @@ -229,7 +229,7 @@ final class RemoteBotProvider: BotDataProvider { } } } - + deinit { refreshTask?.cancel() } diff --git a/SwiftBotApp/SettingsView.swift b/SwiftBotApp/SettingsView.swift index e4b078e..faa977d 100644 --- a/SwiftBotApp/SettingsView.swift +++ b/SwiftBotApp/SettingsView.swift @@ -77,7 +77,7 @@ struct GeneralSettingsView: View { VStack(alignment: .leading, spacing: 10) { Text("Bot Token") .font(.subheadline.weight(.medium)) - + HStack(spacing: 10) { Group { if showToken { @@ -89,7 +89,7 @@ struct GeneralSettingsView: View { .textFieldStyle(.roundedBorder) .font(.system(.body, design: .monospaced)) .disabled(isFailoverManagedNode) - + Button { showToken.toggle() } label: { @@ -99,7 +99,7 @@ struct GeneralSettingsView: View { .buttonStyle(.plain) .help(showToken ? "Hide token" : "Show token") } - + Text("Create a bot in the Discord Developer Portal and paste its token here.") .font(.caption) .foregroundStyle(.secondary) @@ -121,7 +121,7 @@ struct GeneralSettingsView: View { } .buttonStyle(.bordered) .disabled(!canGenerateInviteLink || inviteActionInProgress) - + if inviteActionInProgress { ProgressView() .controlSize(.small) @@ -513,7 +513,7 @@ struct GeneralSettingsView: View { ) { VStack(alignment: .leading, spacing: 12) { settingsToggleRow("Enable Admin Web UI", isOn: $app.settings.adminWebUI.enabled) - + if app.settings.adminWebUI.enabled { VStack(alignment: .leading, spacing: 8) { Text("Port") @@ -534,7 +534,7 @@ struct GeneralSettingsView: View { ) { VStack(alignment: .leading, spacing: 12) { settingsToggleRow("Internet Access (Cloudflare)", isOn: $app.settings.adminWebUI.internetAccessEnabled) - + if app.settings.adminWebUI.internetAccessEnabled { VStack(alignment: .leading, spacing: 8) { Text("Subdomain") @@ -560,7 +560,7 @@ struct GeneralSettingsView: View { Toggle("", isOn: $source.isEnabled) .toggleStyle(.switch) .labelsHidden() - + VStack(alignment: .leading, spacing: 4) { TextField("Source Name", text: $source.name) .font(.subheadline.weight(.semibold)) @@ -586,9 +586,9 @@ struct GeneralSettingsView: View { .buttonStyle(.plain) } } - + Spacer() - + Button { app.mediaLibrarySettings.sources.removeAll { $0.id == source.id } } label: { @@ -600,7 +600,7 @@ struct GeneralSettingsView: View { .padding(12) .background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } - + Button { app.mediaLibrarySettings.sources.append(MediaLibrarySource(name: "New Source", rootPath: "")) } label: { @@ -691,7 +691,7 @@ private struct SettingsDisclosureCard: View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline.weight(.bold)) - + if !isExpanded { VStack(alignment: .leading, spacing: 2) { ForEach(summaryLines, id: \.self) { line in @@ -702,9 +702,9 @@ private struct SettingsDisclosureCard: View { } } } - + Spacer() - + Image(systemName: "chevron.right") .font(.system(size: 14, weight: .bold)) .rotationEffect(.degrees(isExpanded ? 90 : 0)) @@ -719,7 +719,7 @@ private struct SettingsDisclosureCard: View { VStack(alignment: .leading, spacing: 0) { Divider() .padding(.horizontal, 24) - + content() .padding(24) .disabled(contentDisabled) diff --git a/SwiftBotApp/StandaloneSetupView.swift b/SwiftBotApp/StandaloneSetupView.swift index 14d4ce8..402911e 100644 --- a/SwiftBotApp/StandaloneSetupView.swift +++ b/SwiftBotApp/StandaloneSetupView.swift @@ -5,19 +5,19 @@ import SwiftUI 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 inviteURL: String? @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 @@ -32,7 +32,7 @@ struct StandaloneSetupView: View { .onboardingTextFieldStyle() .font(.system(.body, design: .monospaced)) .disabled(step == .validating || step == .confirmed) - + Button { showToken.toggle() } label: { @@ -43,7 +43,7 @@ struct StandaloneSetupView: View { .accessibilityLabel(showToken ? "Hide token" : "Show token") } .frame(maxWidth: 560) - + // Error if step == .failed, let result = app.lastTokenValidationResult { Text(result.errorMessage) @@ -52,7 +52,7 @@ struct StandaloneSetupView: View { .multilineTextAlignment(.center) .frame(maxWidth: 560) } - + // Actions switch step { case .entry, .failed: @@ -67,9 +67,9 @@ struct StandaloneSetupView: View { tokenInput = app.settings.token } } - + // MARK: - Subviews - + private var actionButtons: some View { HStack(spacing: 12) { Button(action: onBack) { @@ -77,7 +77,7 @@ struct StandaloneSetupView: View { } .buttonStyle(.bordered) .controlSize(.large) - + Button { app.settings.launchMode = .standaloneBot app.settings.token = tokenInput @@ -95,7 +95,7 @@ struct StandaloneSetupView: View { .disabled(tokenInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } - + private var validatingView: some View { HStack(spacing: 10) { ProgressView() @@ -106,7 +106,7 @@ struct StandaloneSetupView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Validating token, please wait") } - + private var confirmedView: some View { VStack(spacing: 16) { if let result = app.lastTokenValidationResult { @@ -118,17 +118,17 @@ struct StandaloneSetupView: View { } .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) @@ -136,7 +136,7 @@ struct StandaloneSetupView: View { .toggleStyle(.switch) .frame(maxWidth: 560, alignment: .center) } - + Button { app.completeOnboarding() } label: { Label("Go to Dashboard", systemImage: "arrow.right.circle.fill") .frame(minWidth: 200) @@ -153,7 +153,7 @@ struct StandaloneSetupView: View { } } } - + private var loadingInviteView: some View { HStack(spacing: 8) { ProgressView() @@ -165,7 +165,7 @@ struct StandaloneSetupView: View { .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) @@ -173,13 +173,13 @@ struct StandaloneSetupView: View { .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() @@ -189,7 +189,7 @@ struct StandaloneSetupView: View { } .onboardingGlassButton() .accessibilityHint("Copies the bot invite link to your clipboard") - + Button { if let u = URL(string: url) { NSWorkspace.shared.open(u) diff --git a/SwiftBotApp/SwiftBotApp.swift b/SwiftBotApp/SwiftBotApp.swift index 790c119..92cb672 100644 --- a/SwiftBotApp/SwiftBotApp.swift +++ b/SwiftBotApp/SwiftBotApp.swift @@ -172,7 +172,7 @@ struct SwiftBotApp: App { private func handleDeepLink(_ url: URL) { guard url.scheme == "swiftbot" else { return } - + switch url.host { case "auth": handleAuthDeepLink(url) @@ -184,7 +184,7 @@ struct SwiftBotApp: App { 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 { diff --git a/SwiftBotApp/SwiftMeshSetupView.swift b/SwiftBotApp/SwiftMeshSetupView.swift index feb1ec2..c4ef473 100644 --- a/SwiftBotApp/SwiftMeshSetupView.swift +++ b/SwiftBotApp/SwiftMeshSetupView.swift @@ -5,13 +5,13 @@ import SwiftUI 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: @@ -24,19 +24,19 @@ struct SwiftMeshSetupView: View { 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) @@ -49,18 +49,18 @@ struct SwiftMeshSetupView: View { .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() @@ -83,9 +83,9 @@ struct SwiftMeshSetupView: View { step = app.workerConnectionTestIsSuccess ? .confirmed : .failed } } - + // MARK: - Testing View - + private var testingView: some View { HStack(spacing: 10) { ProgressView() @@ -96,9 +96,9 @@ struct SwiftMeshSetupView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Testing SwiftMesh connection, please wait") } - + // MARK: - Confirmed View - + private var confirmedView: some View { VStack(spacing: 16) { HStack(spacing: 8) { @@ -109,7 +109,7 @@ struct SwiftMeshSetupView: View { Text(app.workerConnectionTestStatus) .font(.body) } - + Button { app.saveSettings() app.completeOnboarding() @@ -121,9 +121,9 @@ struct SwiftMeshSetupView: View { .controlSize(.large) } } - + // MARK: - Failed View - + private var failedView: some View { VStack(spacing: 16) { HStack(spacing: 8) { @@ -135,14 +135,14 @@ struct SwiftMeshSetupView: View { .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() @@ -154,7 +154,7 @@ struct SwiftMeshSetupView: View { .buttonStyle(GlassActionButtonStyle()) .controlSize(.large) } - + Text("Limited Mode launches SwiftBot without Discord or SwiftMesh. Configure both from Settings after launch.") .font(.caption) .foregroundStyle(.secondary) diff --git a/SwiftBotApp/UpdateEngine/AMDService.swift b/SwiftBotApp/UpdateEngine/AMDService.swift new file mode 100644 index 0000000..f77816a --- /dev/null +++ b/SwiftBotApp/UpdateEngine/AMDService.swift @@ -0,0 +1,654 @@ +import Foundation + +public struct AMDService: Sendable { + public struct DriverInfo: Sendable { + public let releaseNotes: ReleaseNotes + public let embedJSON: String + public let rawDebug: String + public let releaseIdentifier: String + + public init( + releaseNotes: ReleaseNotes, + embedJSON: String, + rawDebug: String, + releaseIdentifier: String + ) { + self.releaseNotes = releaseNotes + self.embedJSON = embedJSON + self.rawDebug = rawDebug + self.releaseIdentifier = releaseIdentifier + } + } + + private let session: URLSession + private let sitemapURL: URL + private let userAgent: String + private let formatter: EmbedFormatter + + public init( + session: URLSession = .shared, + sitemapURL: URL = URL(string: "https://www.amd.com/en.sitemap.xml")!, + userAgent: String = "Mozilla/5.0 (UpdateEngine)", + formatter: EmbedFormatter = EmbedFormatter() + ) { + self.session = session + self.sitemapURL = sitemapURL + self.userAgent = userAgent + self.formatter = formatter + } + + public func fetchLatestDriver() async throws -> DriverInfo { + let sitemapRequest = makeRequest(url: sitemapURL) + let (sitemapData, sitemapResponse) = try await session.data(for: sitemapRequest) + try validateHTTP(sitemapResponse) + + let rawSitemap = String(data: sitemapData, encoding: .utf8) ?? "" + let entries = parseSitemapEntries(from: rawSitemap) + + guard let latestEntry = entries.max(by: { compareSitemapEntries($0, $1) < 0 }) else { + throw AMDServiceError.noReleaseNotesFound + } + + let releaseRequest = makeRequest(url: latestEntry.url) + let (releaseData, releaseResponse) = try await session.data(for: releaseRequest) + try validateHTTP(releaseResponse) + + let rawReleaseHTML = String(data: releaseData, encoding: .utf8) ?? "" + let cleanedReleaseHTML = removeScriptAndStyleBlocks(rawReleaseHTML) + + let detectedVersion = firstCapture( + pattern: #"Adrenalin Edition\s*([0-9]+(?:\.[0-9]+)+)\s*Release Notes"#, + in: cleanedReleaseHTML + ) + + let version = detectedVersion ?? latestEntry.version.replacingOccurrences(of: "-", with: ".") + let releaseDate = extractReleaseDate(from: cleanedReleaseHTML, fallback: latestEntry.lastModified) + let sections = parseSummarySections(from: cleanedReleaseHTML) + + let releaseNotes = ReleaseNotes( + title: "AMD Software: Adrenalin Edition \(version) Release Notes", + author: "AMD Radeon Drivers", + url: latestEntry.url.absoluteString, + version: version, + date: releaseDate, + sections: sections, + thumbnailURL: "https://cdn.patchbot.io/games/140/amd-gpu-drivers_sm.webp", + color: 16711680 + ) + + let debugRaw = """ + AMD sitemap XML: + \(rawSitemap) + + AMD release notes HTML: + \(rawReleaseHTML) + """ + + return DriverInfo( + releaseNotes: releaseNotes, + embedJSON: formatter.format(releaseNotes: releaseNotes), + rawDebug: debugRaw, + releaseIdentifier: "amd:\(version)" + ) + } + + private func makeRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 30 + return request + } + + private func parseSitemapEntries(from xml: String) -> [SitemapEntry] { + let pattern = #"(?is)\s*(https://www\.amd\.com/en/resources/support-articles/release-notes/RN-RAD-WIN-([0-9]{2}-[0-9]{1,2}-[0-9]{1,2})\.html)\s*([^<]+)\s*"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let xmlRange = NSRange(xml.startIndex.. Int { + if let left = parseSitemapVersion(lhs.version), let right = parseSitemapVersion(rhs.version), left != right { + return left.lexicographicallyPrecedes(right) ? -1 : 1 + } + if lhs.lastModified != rhs.lastModified { + return lhs.lastModified < rhs.lastModified ? -1 : 1 + } + return lhs.url.absoluteString < rhs.url.absoluteString ? -1 : 1 + } + + private func parseSitemapVersion(_ value: String) -> [Int]? { + let parts = value.split(separator: "-").compactMap { Int($0) } + return parts.count == 3 ? parts : nil + } + + private func extractReleaseDate(from html: String, fallback: Date) -> String { + if let published = firstCapture(pattern: #"\"datePublished\"\s*:\s*\"([^\"]+)\""#, in: html), + let parsed = parseISODate(published) { + return formatDate(parsed) + } + + if let updated = firstCapture(pattern: #"(?is) [ReleaseSection] { + if let highlights = extractSections(headerCandidates: ["Highlights"], from: html) { + return highlights + } + + if let fixedIssues = extractSections(headerCandidates: ["Fixed Issues"], from: html) { + return fixedIssues + } + + if let knownIssues = extractSections(headerCandidates: ["Known Issues"], from: html) { + return knownIssues + } + + if let fixedIssues = extractNamedListSection(named: "Fixed Issues", from: html) { + return [fixedIssues] + } + + if let knownIssues = extractNamedListSection(named: "Known Issues", from: html) { + return [knownIssues] + } + + if let firstParagraph = firstMeaningfulParagraph(in: html) { + return [ + ReleaseSection( + title: "Release Information", + bullets: [Bullet(text: firstParagraph)] + ) + ] + } + + return [fallbackSection(from: html)] + } + + private func extractSections(headerCandidates: [String], from html: String) -> [ReleaseSection]? { + guard let section = extractSectionBlock(headerCandidates: headerCandidates, from: html) else { + return nil + } + + let bullets = parseBulletsWithHierarchy(from: section.content) + if !bullets.isEmpty { + let splitSections = splitBulletsIntoSections(defaultTitle: section.title, bullets: bullets) + if !splitSections.isEmpty { + return splitSections + } + } + + guard let paragraph = firstMeaningfulParagraph(in: section.content) else { + return nil + } + + return [ReleaseSection(title: section.title, bullets: [Bullet(text: paragraph)])] + } + + private func extractNamedListSection(named name: String, from html: String) -> ReleaseSection? { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let pattern = #"(?is)]*>\s*(?:<(?:b|strong)[^>]*>)?\s*"# + escapedName + #"\s*(?:)?\s*(]*>.*?)\s*"# + + guard let sublistHTML = firstCapture(pattern: pattern, in: html) else { + return nil + } + + let bullets = parseBulletsWithHierarchy(from: sublistHTML) + guard !bullets.isEmpty else { + return nil + } + + return ReleaseSection(title: name, bullets: bullets) + } + + private func extractSectionBlock(headerCandidates: [String], from html: String) -> SectionBlock? { + guard let regex = try? NSRegularExpression(pattern: #"(?is)]*>(.*?)"#) else { + return nil + } + + let headers = regex.matches(in: html, range: NSRange(html.startIndex.. HeaderMatch? in + guard + match.numberOfRanges > 1, + let bodyRange = Range(match.range(at: 1), in: html), + let fullRange = Range(match.range(at: 0), in: html) + else { + return nil + } + + let rawTitle = String(html[bodyRange]) + let normalizedTitle = normalizeHeaderTitle(cleanHTML(rawTitle, preserveNewlines: false)) + return HeaderMatch(title: cleanHTML(rawTitle, preserveNewlines: false), normalizedTitle: normalizedTitle, range: fullRange) + } + + guard !headers.isEmpty else { + return nil + } + + let normalizedCandidates = Set(headerCandidates.map(normalizeHeaderTitle)) + guard let matchedIndex = headers.firstIndex(where: { normalizedCandidates.contains($0.normalizedTitle) }) else { + return nil + } + + let match = headers[matchedIndex] + let contentStart = match.range.upperBound + let contentEnd = matchedIndex + 1 < headers.count ? headers[matchedIndex + 1].range.lowerBound : html.endIndex + guard contentStart < contentEnd else { + return nil + } + + let content = String(html[contentStart.. String { + title + .lowercased() + .replacingOccurrences(of: #"[^a-z0-9]+"#, with: "", options: .regularExpression) + } + + private func splitBulletsIntoSections(defaultTitle: String, bullets: [Bullet]) -> [ReleaseSection] { + var sections: [ReleaseSection] = [] + var currentTitle = defaultTitle + var currentBullets: [Bullet] = [] + + func flushCurrentSection() { + guard !currentBullets.isEmpty else { + return + } + sections.append(ReleaseSection(title: currentTitle, bullets: currentBullets)) + currentBullets = [] + } + + for bullet in bullets { + if let sectionTitle = canonicalSectionHeader(from: bullet.text) { + flushCurrentSection() + currentTitle = sectionTitle + currentBullets = bullet.subBullets.map { Bullet(text: $0) } + continue + } + + currentBullets.append(bullet) + } + + flushCurrentSection() + return sections + } + + private func canonicalSectionHeader(from text: String) -> String? { + let trimmed = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: ":")) + let normalized = normalizeHeaderTitle(trimmed) + + switch normalized { + case "highlights": + return "Highlights" + case "fixedissues": + return "Fixed Issues" + case "knownissues": + return "Known Issues" + default: + return nil + } + } + + private func parseBulletsWithHierarchy(from html: String) -> [Bullet] { + let sanitized = removeScriptAndStyleBlocks(html) + .replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + + guard let regex = try? NSRegularExpression(pattern: #"(?is)<[^>]+>"#) else { + return [] + } + + let range = NSRange(sanitized.startIndex.. 1, !bullets.isEmpty { + let parent = bullets.removeLast() + let updated = Bullet(text: parent.text, subBullets: parent.subBullets + [text]) + bullets.append(updated) + } else { + bullets.append(Bullet(text: text)) + } + } + + for match in matches { + guard + let tagRange = Range(match.range(at: 0), in: sanitized) + else { + continue + } + + if cursor < tagRange.lowerBound, isInListItem { + currentText += String(sanitized[cursor.. ReleaseSection { + if let firstParagraph = firstMeaningfulParagraph(in: html) { + return ReleaseSection( + title: "Release Information", + bullets: [Bullet(text: firstParagraph)] + ) + } + + if let description = firstCapture( + pattern: #"(?is) String? { + let pattern = #"(?is)]*>(.*?)

"# + for paragraph in allCaptures(pattern: pattern, in: html) { + let text = cleanHTML(paragraph, preserveNewlines: true) + guard isMeaningfulParagraph(text) else { + continue + } + return text + } + + let fallback = cleanHTML(html, preserveNewlines: true) + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: isMeaningfulParagraph) + return fallback + } + + private func isMeaningfulParagraph(_ text: String) -> Bool { + guard text.count >= 20 else { + return false + } + + let lower = text.lowercased() + if lower.hasPrefix("last updated") { + return false + } + return true + } + + private func parseTagName(_ tag: String) -> String { + var token = tag.trimmingCharacters(in: .whitespacesAndNewlines) + guard token.hasPrefix("<"), token.count >= 2 else { + return "" + } + + token.removeFirst() + if token.hasPrefix("/") { + token.removeFirst() + } + + let characters = token.prefix { character in + character.isLetter || character.isNumber + } + return String(characters).lowercased() + } + + private func normalizeListText(_ raw: String) -> String { + let decoded = decodeHTMLEntities(raw) + let withoutTags = decoded.replacingOccurrences(of: #"(?is)<[^>]+>"#, with: " ", options: .regularExpression) + let compactSpaces = withoutTags.replacingOccurrences(of: #"[ \t]+"#, with: " ", options: .regularExpression) + let compactNewlines = compactSpaces + .replacingOccurrences(of: #" *\n *"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: #"\n{2,}"#, with: "\n", options: .regularExpression) + return compactNewlines.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func removeScriptAndStyleBlocks(_ html: String) -> String { + html + .replacingOccurrences(of: #"(?is)]*>.*?"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"(?is)]*>.*?"#, with: "", options: .regularExpression) + } + + private func parseISODate(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: value) { + return date + } + + let regular = ISO8601DateFormatter() + regular.formatOptions = [.withInternetDateTime] + return regular.date(from: value) + } + + private func parseDateWithZoneOffset(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + return formatter.date(from: value) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MMMM dd, yyyy" + return formatter.string(from: date) + } + + private func firstCapture(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil + } + + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) + else { + return nil + } + + return String(text[captureRange]) + } + + private func allCaptures(pattern: String, in text: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) + else { + return nil + } + + return String(text[captureRange]) + } + } + + private func cleanHTML(_ raw: String, preserveNewlines: Bool) -> String { + var text = removeScriptAndStyleBlocks(raw) + text = text.replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"(?is)<[^>]+>"#, with: " ", options: .regularExpression) + text = decodeHTMLEntities(text) + + if preserveNewlines { + text = text.replacingOccurrences(of: #"[ \t]+"#, with: " ", options: .regularExpression) + text = text.replacingOccurrences(of: #" *\n *"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression) + } else { + text = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func decodeHTMLEntities(_ text: String) -> String { + var output = text + let entities: [(String, String)] = [ + (" ", " "), + ("&", "&"), + (""", "\""), + ("'", "'"), + ("'", "'"), + ("<", "<"), + (">", ">") + ] + + for (entity, value) in entities { + output = output.replacingOccurrences(of: entity, with: value) + } + + if let numericRegex = try? NSRegularExpression(pattern: #"&#([0-9]{1,7});"#) { + let matches = numericRegex.matches(in: output, range: NSRange(output.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: output), + let fullRange = Range(match.range(at: 0), in: output), + let value = Int(output[valueRange]), + let scalar = UnicodeScalar(value) + else { + continue + } + + output.replaceSubrange(fullRange, with: String(scalar)) + } + } + + return output + } + + private func validateHTTP(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw AMDServiceError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + throw AMDServiceError.httpError(statusCode: http.statusCode) + } + } +} + +private struct HeaderMatch { + let title: String + let normalizedTitle: String + let range: Range +} + +private struct SectionBlock { + let title: String + let content: String +} + +private struct SitemapEntry { + let url: URL + let version: String + let lastModified: Date +} + +public enum AMDServiceError: LocalizedError, Sendable { + case invalidResponse + case httpError(statusCode: Int) + case noReleaseNotesFound + + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "AMD endpoint returned an invalid response object." + case .httpError(let statusCode): + return "AMD endpoint request failed with HTTP \(statusCode)." + case .noReleaseNotesFound: + return "No AMD Radeon Adrenalin release notes were found." + } + } +} diff --git a/SwiftBotApp/UpdateEngine/BuiltInUpdateSources.swift b/SwiftBotApp/UpdateEngine/BuiltInUpdateSources.swift new file mode 100644 index 0000000..2c8a700 --- /dev/null +++ b/SwiftBotApp/UpdateEngine/BuiltInUpdateSources.swift @@ -0,0 +1,104 @@ +import Foundation + +/// NVIDIA Game Ready update source. +public struct NVIDIAUpdateSource: UpdateSource, Sendable { + public let sourceKey: String + private let service: NVIDIAService + + public init( + sourceKey: String = CacheKeyBuilder.build(vendor: "NVIDIA", channel: "gameReady"), + service: NVIDIAService = NVIDIAService() + ) { + self.sourceKey = sourceKey + self.service = service + } + + public func fetchLatest() async throws -> any UpdateItem { + let info = try await service.fetchLatestDriver() + return DriverUpdateItem( + sourceKey: sourceKey, + identifier: info.releaseIdentifier, + version: info.releaseNotes.version, + releaseNotes: info.releaseNotes, + embedJSON: info.embedJSON, + rawDebug: info.rawDebug + ) + } +} + +/// AMD Radeon update source. +public struct AMDUpdateSource: UpdateSource, Sendable { + public let sourceKey: String + private let service: AMDService + + public init( + sourceKey: String = CacheKeyBuilder.build(vendor: "AMD", channel: "default"), + service: AMDService = AMDService() + ) { + self.sourceKey = sourceKey + self.service = service + } + + public func fetchLatest() async throws -> any UpdateItem { + let info = try await service.fetchLatestDriver() + return DriverUpdateItem( + sourceKey: sourceKey, + identifier: info.releaseIdentifier, + version: info.releaseNotes.version, + releaseNotes: info.releaseNotes, + embedJSON: info.embedJSON, + rawDebug: info.rawDebug + ) + } +} + +/// Intel Arc driver update source. +public struct IntelUpdateSource: UpdateSource, Sendable { + public let sourceKey: String + private let service: IntelService + + public init( + sourceKey: String = CacheKeyBuilder.build(vendor: "Intel", channel: "default"), + service: IntelService = IntelService() + ) { + self.sourceKey = sourceKey + self.service = service + } + + public func fetchLatest() async throws -> any UpdateItem { + let info = try await service.fetchLatestDriver() + return DriverUpdateItem( + sourceKey: sourceKey, + identifier: info.releaseIdentifier, + version: info.releaseNotes.version, + releaseNotes: info.releaseNotes, + embedJSON: info.embedJSON, + rawDebug: info.rawDebug + ) + } +} + +/// Steam news update source for a specific app. +public struct SteamNewsUpdateSource: UpdateSource, Sendable { + public let appID: String + public let sourceKey: String + private let service: SteamService + + public init(appID: String, service: SteamService = SteamService()) { + self.appID = appID + self.sourceKey = CacheKeyBuilder.build(vendor: "steam", channel: appID) + self.service = service + } + + public func fetchLatest() async throws -> any UpdateItem { + let info = try await service.fetchLatestNews(for: appID) + return SteamUpdateItem( + sourceKey: sourceKey, + identifier: info.releaseIdentifier, + version: info.newsItem.dateFormatted, + newsItem: info.newsItem, + embedJSON: info.embedJSON, + rawDebug: info.rawDebug + ) + } +} diff --git a/SwiftBotApp/UpdateEngine/CacheKeyBuilder.swift b/SwiftBotApp/UpdateEngine/CacheKeyBuilder.swift new file mode 100644 index 0000000..c03ceee --- /dev/null +++ b/SwiftBotApp/UpdateEngine/CacheKeyBuilder.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Utility for deterministic cache key generation. +public enum CacheKeyBuilder { + /// Build a normalized base key from vendor/channel. + public static func build(vendor: String, channel: String = "default") -> String { + let vendorPart = normalizeComponent(vendor) + let channelPart = normalizeComponent(channel) + return "\(vendorPart)-\(channelPart)" + } + + /// Build a guild-scoped cache key. + public static func buildGuildScoped(guildID: String, baseKey: String) -> String { + buildScoped(scopeType: "guild", scopeID: guildID, baseKey: baseKey) + } + + /// Build an arbitrary scoped cache key. + /// Example output: `guild:123456:nvidia-gameready`. + public static func buildScoped(scopeType: String, scopeID: String, baseKey: String) -> String { + let typePart = normalizeComponent(scopeType) + let idPart = sanitizeScopeID(scopeID) + let keyPart = normalizeCacheKey(baseKey) + return "\(typePart):\(idPart):\(keyPart)" + } + + /// Normalize a prebuilt cache key. + public static func normalizeCacheKey(_ key: String) -> String { + key + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "-") + } + + private static func normalizeComponent(_ raw: String) -> String { + raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: ":", with: "-") + } + + private static func sanitizeScopeID(_ raw: String) -> String { + raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ":", with: "_") + .replacingOccurrences(of: " ", with: "-") + } +} diff --git a/SwiftBotApp/UpdateEngine/EmbedFormatter.swift b/SwiftBotApp/UpdateEngine/EmbedFormatter.swift new file mode 100644 index 0000000..1efadc0 --- /dev/null +++ b/SwiftBotApp/UpdateEngine/EmbedFormatter.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Converts release notes into Discord embed JSON payloads. +public struct EmbedFormatter: Sendable { + private let titleLimit = 256 + private let descriptionLimit = 4096 + + public init() {} + + public func format(releaseNotes: ReleaseNotes) -> String { + let description = formatSections(releaseNotes.sections) + let truncatedDescription = truncateIfNeeded(description, limit: descriptionLimit) + + let embed: [String: Any] = [ + "author": ["name": releaseNotes.author], + "title": truncate(releaseNotes.title, limit: titleLimit), + "url": releaseNotes.url, + "description": truncatedDescription, + "color": releaseNotes.color, + "thumbnail": ["url": releaseNotes.thumbnailURL], + "fields": [ + ["name": "Version", "value": releaseNotes.version, "inline": true], + ["name": "Release Date", "value": releaseNotes.date, "inline": true] + ] + ] + + let payload: [String: Any] = [ + "embeds": [embed] + ] + + return encodeToJSON(payload) + } + + private func formatSections(_ sections: [ReleaseSection]) -> String { + let renderedSections = sections.map { section in + var sectionText = "**\(section.title)**" + for bullet in section.bullets { + sectionText += "\n• \(bullet.text)" + for subBullet in bullet.subBullets { + sectionText += "\n ◦ \(subBullet)" + } + } + return sectionText + } + + return renderedSections.joined(separator: "\n\n") + } + + private func truncate(_ text: String, limit: Int) -> String { + guard text.count > limit else { + return text + } + return String(text.prefix(limit - 3)) + "..." + } + + private func truncateIfNeeded(_ text: String, limit: Int) -> String { + guard text.count > limit else { + return text + } + + let sections = text.components(separatedBy: "\n\n") + var output = "" + + for section in sections { + let candidate = output.isEmpty ? section : output + "\n\n" + section + if candidate.count > limit - 50 { + break + } + output = candidate + } + + if output.isEmpty { + return truncate(text, limit: limit) + } + + return output + "\n\n*Content truncated to fit Discord limits*" + } + + private func encodeToJSON(_ payload: [String: Any]) -> String { + guard + let data = try? JSONSerialization.data( + withJSONObject: payload, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ), + let json = String(data: data, encoding: .utf8) + else { + return "{}" + } + + return json + } +} diff --git a/SwiftBotApp/UpdateEngine/IntelService.swift b/SwiftBotApp/UpdateEngine/IntelService.swift new file mode 100644 index 0000000..f0a06c2 --- /dev/null +++ b/SwiftBotApp/UpdateEngine/IntelService.swift @@ -0,0 +1,931 @@ +import Foundation + +public struct IntelService: Sendable { + public struct DriverInfo: Sendable { + public let releaseNotes: ReleaseNotes + public let embedJSON: String + public let rawDebug: String + public let releaseIdentifier: String + + public init( + releaseNotes: ReleaseNotes, + embedJSON: String, + rawDebug: String, + releaseIdentifier: String + ) { + self.releaseNotes = releaseNotes + self.embedJSON = embedJSON + self.rawDebug = rawDebug + self.releaseIdentifier = releaseIdentifier + } + } + + private let session: URLSession + private let pageURL: URL + private let modelURL: URL + private let mirrorURL: URL + private let userAgent: String + private let formatter: EmbedFormatter + + public init( + session: URLSession = .shared, + pageURL: URL = URL(string: "https://www.intel.com/content/www/us/en/download/785597/intel-arc-graphics-windows.html")!, + modelURL: URL? = nil, + mirrorURL: URL? = nil, + userAgent: String = "Mozilla/5.0 (UpdateEngine)", + formatter: EmbedFormatter = EmbedFormatter() + ) { + self.session = session + self.pageURL = pageURL + self.modelURL = modelURL ?? Self.defaultModelURL(for: pageURL) + self.mirrorURL = mirrorURL ?? Self.defaultMirrorURL(for: pageURL) + self.userAgent = userAgent + self.formatter = formatter + } + + public func fetchLatestDriver() async throws -> DriverInfo { + var debugParts: [String] = [] + var attemptFailures: [String] = [] + var candidates: [DriverInfo] = [] + + if let primaryResult = try await tryFetchAndParse( + url: pageURL, + label: "Primary Intel Arc page", + debugParts: &debugParts, + failures: &attemptFailures + ) { + candidates.append(primaryResult) + } + + if let modelResult = try await tryFetchAndParse( + url: modelURL, + label: "Intel Arc model page", + debugParts: &debugParts, + failures: &attemptFailures + ) { + candidates.append(modelResult) + } + + if let mirroredResult = try await tryFetchAndParse( + url: mirrorURL, + label: "Mirror Intel Arc page", + debugParts: &debugParts, + failures: &attemptFailures + ) { + candidates.append(mirroredResult) + } + + if let newest = candidates.max(by: { compareDriverVersions($0.releaseNotes.version, $1.releaseNotes.version) < 0 }) { + return newest + } + + let failureSummary = attemptFailures.isEmpty + ? "No parse attempts succeeded." + : attemptFailures.joined(separator: " | ") + throw IntelServiceError.parseFailed( + "Failed to parse Intel Arc driver metadata. \(failureSummary)" + ) + } + + private func tryFetchAndParse( + url: URL, + label: String, + debugParts: inout [String], + failures: inout [String] + ) async throws -> DriverInfo? { + let payload = try await fetchPayload(from: url) + let statusCode = payload.response?.statusCode ?? -1 + + debugParts.append("\(label) status: \(statusCode)") + debugParts.append("\(label) body:\n\(payload.body)") + + guard (200...299).contains(statusCode) else { + failures.append("\(label) HTTP \(statusCode)") + return nil + } + + let content = extractMirrorMarkdownIfPresent(payload.body) + let parsed: ParsedDriver + do { + parsed = try parse(content: content, baseURL: pageURL, response: payload.response) + } catch { + failures.append("\(label) parse error: \(error.localizedDescription)") + return nil + } + + let releaseNotes = ReleaseNotes( + title: "Intel Arc Graphics Driver \(parsed.version)", + author: "Intel Arc Drivers", + url: parsed.releaseNotesURL.absoluteString, + version: parsed.version, + date: formatDate(parsed.releaseDate), + sections: [parsed.summarySection], + thumbnailURL: "https://cdn.patchbot.io/games/145/intel-gpu-drivers_sm.png", + color: 0x0071C5 + ) + + let rawDebug = debugParts.joined(separator: "\n\n") + return DriverInfo( + releaseNotes: releaseNotes, + embedJSON: formatter.format(releaseNotes: releaseNotes), + rawDebug: rawDebug, + releaseIdentifier: parsed.version + ) + } + + private func compareDriverVersions(_ lhs: String, _ rhs: String) -> Int { + let leftParts = lhs.split(separator: ".").compactMap { Int($0) } + let rightParts = rhs.split(separator: ".").compactMap { Int($0) } + let maxCount = max(leftParts.count, rightParts.count) + for index in 0.. PagePayload { + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + let body = String(data: data, encoding: .utf8) ?? "" + return PagePayload(body: body, response: response as? HTTPURLResponse) + } + + private func parse( + content: String, + baseURL: URL, + response: HTTPURLResponse? + ) throws -> ParsedDriver { + guard let version = extractVersion(from: content) else { + throw IntelServiceError.parseFailed("Version number not found in Intel Arc payload.") + } + + guard let releaseDate = extractReleaseDate(from: content, response: response) else { + throw IntelServiceError.parseFailed("Release date not found in Intel Arc payload.") + } + + guard let summarySection = extractSummarySection(from: content) else { + throw IntelServiceError.parseFailed("Summary section not found in Intel Arc payload.") + } + + let releaseNotesURL = extractReleaseNotesURL(from: content, baseURL: baseURL) ?? baseURL + return ParsedDriver( + version: version, + releaseDate: releaseDate, + releaseNotesURL: releaseNotesURL, + summarySection: summarySection + ) + } + + private func extractMirrorMarkdownIfPresent(_ payload: String) -> String { + guard let markerRange = payload.range(of: "Markdown Content:") else { + return payload + } + + let markdown = payload[markerRange.upperBound...] + return String(markdown).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func extractVersion(from content: String) -> String? { + let patterns = [ + #"(?i)\bdriver\s*[:\-]?\s*([0-9]{2,3}(?:\.[0-9]{1,4}){3})\b"#, + #"(?i)\bversion\s*[:\-]?\s*([0-9]{2,3}(?:\.[0-9]{1,4}){3})\b"#, + #"\b([0-9]{2,3}(?:\.[0-9]{1,4}){3})\b"# + ] + + for pattern in patterns { + if let match = firstCapture(pattern: pattern, in: content) { + return match + } + } + return nil + } + + private func extractReleaseDate(from content: String, response: HTTPURLResponse?) -> Date? { + if let isoDate = firstCapture(pattern: "\"datePublished\"\\s*:\\s*\"([^\"]+)\"", in: content), + let parsed = parseDate(isoDate) { + return parsed + } + + let patterns = [ + #"(?is)dc-page-banner-actions-action-updated.*?]*>\s*([0-9]{1,2}/[0-9]{1,2}/[0-9]{4})\s*"#, + #"(?i)\brelease\s*date\b[^0-9A-Za-z]{0,20}([0-9]{1,2}/[0-9]{1,2}/[0-9]{4})"#, + #"(?i)\blast\s*updated\b[^0-9A-Za-z]{0,20}([0-9]{1,2}/[0-9]{1,2}/[0-9]{4})"#, + #"(?i)\b(?:release\s*date|last\s*updated)\b[^A-Za-z0-9]{0,20}([A-Za-z]+\s+\d{1,2},\s*\d{4})"# + ] + + for pattern in patterns { + if let value = firstCapture(pattern: pattern, in: content), + let parsed = parseDate(value) { + return parsed + } + } + + if let headerDate = response?.value(forHTTPHeaderField: "Last-Modified"), + let parsed = parseRFC1123Date(headerDate) { + return parsed + } + + return nil + } + + private func extractReleaseNotesURL(from content: String, baseURL: URL) -> URL? { + let htmlPattern = #"(?is)]+href=["']([^"']+)["'][^>]*>.*?release\s*notes.*?"# + if let rawURL = firstCapture(pattern: htmlPattern, in: content), + let resolved = normalizeIntelURL(rawURL, relativeTo: baseURL) { + return resolved + } + + let markdownPattern = #"(?i)\[[^\]]*release\s*notes[^\]]*\]\((https?://[^)\s]+)\)"# + if let rawURL = firstCapture(pattern: markdownPattern, in: content), + let resolved = normalizeIntelURL(rawURL, relativeTo: baseURL) { + return resolved + } + + return nil + } + + private func normalizeIntelURL(_ rawURL: String, relativeTo baseURL: URL) -> URL? { + guard let url = URL(string: rawURL, relativeTo: baseURL)?.absoluteURL else { + return nil + } + + guard let host = url.host?.lowercased(), host.contains("intel.com") else { + return nil + } + + return url + } + + private func extractSummarySection(from content: String) -> ReleaseSection? { + if containsHTML(content) { + return extractSummaryFromHTML(content) + } + return extractSummaryFromMarkdown(content) + } + + private func extractSummaryFromHTML(_ html: String) -> ReleaseSection? { + let cleaned = removeScriptAndStyleBlocks(html) + + if let highlightsHTML = extractStrongLabelSection( + labelCandidates: ["Gaming Highlights", "Highlights"], + stopLabels: ["OS Support", "Platform Support", "Notes"], + from: cleaned + ) { + let bullets = parseBulletsFromHTML(highlightsHTML) + if !bullets.isEmpty { + return ReleaseSection(title: "Highlights", bullets: bullets) + } + + if let paragraph = firstMeaningfulParagraph(inHTML: highlightsHTML) { + return ReleaseSection(title: "Highlights", bullets: [Bullet(text: paragraph)]) + } + } + + if let highlights = extractHTMLSection( + headers: ["Gaming Highlights", "Highlights"], + from: cleaned + ) { + let bullets = parseBulletsFromHTML(highlights.content) + if !bullets.isEmpty { + return ReleaseSection(title: "Highlights", bullets: bullets) + } + } + + if let detailed = extractHTMLSection( + headers: ["Detailed Description", "Introduction"], + from: cleaned + ) { + let bullets = parseBulletsFromHTML(detailed.content) + if !bullets.isEmpty { + return ReleaseSection(title: detailed.title, bullets: bullets) + } + + if let paragraph = firstMeaningfulParagraph(inHTML: detailed.content) { + return ReleaseSection(title: detailed.title, bullets: [Bullet(text: paragraph)]) + } + } + + if let paragraph = firstMeaningfulParagraph(inHTML: cleaned) { + return ReleaseSection(title: "Release Summary", bullets: [Bullet(text: paragraph)]) + } + + return nil + } + + private func extractStrongLabelSection( + labelCandidates: [String], + stopLabels: [String], + from html: String + ) -> String? { + for candidate in labelCandidates { + let escapedCandidate = NSRegularExpression.escapedPattern(for: candidate) + let stopPattern = stopLabels.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") + let pattern = #"(?is)\s*"# + escapedCandidate + #"\s*:?\s*(.*?)(?:\s*(?:"# + stopPattern + #")\s*:?\s*|$)"# + if let section = firstCapture(pattern: pattern, in: html) { + return section + } + } + + return nil + } + + private func extractSummaryFromMarkdown(_ markdown: String) -> ReleaseSection? { + let sections = parseMarkdownSections(from: markdown) + + if let highlights = sections.first(where: { normalizeHeader($0.title).contains("highlights") }) { + let bullets = parseBulletsFromMarkdown(lines: highlights.bodyLines) + if !bullets.isEmpty { + return ReleaseSection(title: "Highlights", bullets: bullets) + } + } + + if let firstSection = sections.first(where: isMeaningfulMarkdownSection) { + let bullets = parseBulletsFromMarkdown(lines: firstSection.bodyLines) + if !bullets.isEmpty { + return ReleaseSection(title: firstSection.title, bullets: bullets) + } + + if let paragraph = firstMeaningfulMarkdownParagraph(lines: firstSection.bodyLines) { + return ReleaseSection(title: firstSection.title, bullets: [Bullet(text: paragraph)]) + } + } + + if let paragraph = firstMeaningfulMarkdownParagraph(lines: markdown.components(separatedBy: .newlines)) { + return ReleaseSection(title: "Release Summary", bullets: [Bullet(text: paragraph)]) + } + + return nil + } + + private func containsHTML(_ content: String) -> Bool { + content.range(of: #"(?is) HTMLSection? { + guard let regex = try? NSRegularExpression(pattern: #"(?is)]*>(.*?)"#) else { + return nil + } + + let matches = regex.matches(in: html, range: NSRange(html.startIndex.. HTMLHeaderMatch? in + guard + match.numberOfRanges > 1, + let titleRange = Range(match.range(at: 1), in: html), + let fullRange = Range(match.range(at: 0), in: html) + else { + return nil + } + + let title = cleanHTML(String(html[titleRange]), preserveNewlines: false) + return HTMLHeaderMatch(title: title, normalizedTitle: normalizeHeader(title), range: fullRange) + } + + guard !matches.isEmpty else { + return nil + } + + let normalizedHeaders = Set(headers.map(normalizeHeader)) + guard let selectedIndex = matches.firstIndex(where: { normalizedHeaders.contains($0.normalizedTitle) }) else { + return nil + } + + let selected = matches[selectedIndex] + let bodyStart = selected.range.upperBound + let bodyEnd = selectedIndex + 1 < matches.count ? matches[selectedIndex + 1].range.lowerBound : html.endIndex + guard bodyStart < bodyEnd else { + return nil + } + + let body = String(html[bodyStart.. [Bullet] { + let sanitized = removeScriptAndStyleBlocks(html) + .replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + + guard let regex = try? NSRegularExpression(pattern: #"(?is)<[^>]+>"#) else { + return [] + } + + let matches = regex.matches(in: sanitized, range: NSRange(sanitized.startIndex.. 1, !bullets.isEmpty { + let parent = bullets.removeLast() + let updated = Bullet(text: parent.text, subBullets: parent.subBullets + [text]) + bullets.append(updated) + } else { + bullets.append(Bullet(text: text)) + } + } + + for match in matches { + guard let tagRange = Range(match.range, in: sanitized) else { + continue + } + + if cursor < tagRange.lowerBound, isInListItem { + currentText += String(sanitized[cursor.. [MarkdownSection] { + let lines = markdown.components(separatedBy: .newlines) + var sections: [MarkdownSection] = [] + var currentTitle: String? + var currentBody: [String] = [] + var index = 0 + + func finishCurrentSectionIfNeeded() { + guard let title = currentTitle else { + return + } + let body = currentBody.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + sections.append(MarkdownSection(title: title, bodyLines: body)) + } + + while index < lines.count { + let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + + if let title = markdownHeadingTitle(currentLine: line, nextLine: index + 1 < lines.count ? lines[index + 1] : nil) { + finishCurrentSectionIfNeeded() + currentTitle = title + currentBody = [] + if index + 1 < lines.count, isMarkdownUnderline(lines[index + 1]) { + index += 2 + } else { + index += 1 + } + continue + } + + if let title = markdownBoldHeadingTitle(from: line) { + finishCurrentSectionIfNeeded() + currentTitle = title + currentBody = [] + index += 1 + continue + } + + if currentTitle != nil { + currentBody.append(lines[index]) + } + index += 1 + } + + finishCurrentSectionIfNeeded() + return sections.filter { section in + !section.title.isEmpty && section.bodyLines.contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + } + + private func parseBulletsFromMarkdown(lines: [String]) -> [Bullet] { + var bullets: [Bullet] = [] + + for rawLine in lines { + let line = rawLine.replacingOccurrences(of: "\t", with: " ") + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + continue + } + + let leadingSpaces = line.prefix { $0 == " " }.count + if let listText = firstCapture(pattern: #"^(?:[-*+]|[0-9]+\.)\s+(.*)$"#, in: trimmed) { + let cleaned = cleanMarkdownText(listText) + guard !cleaned.isEmpty else { + continue + } + + if leadingSpaces >= 2, !bullets.isEmpty { + let parent = bullets.removeLast() + let updated = Bullet(text: parent.text, subBullets: parent.subBullets + [cleaned]) + bullets.append(updated) + } else { + bullets.append(Bullet(text: cleaned)) + } + continue + } + + let cleaned = cleanMarkdownText(trimmed) + guard !cleaned.isEmpty else { + continue + } + + if bullets.isEmpty { + bullets.append(Bullet(text: cleaned)) + } else { + let last = bullets.removeLast() + if leadingSpaces >= 2 { + let updated = Bullet(text: last.text, subBullets: last.subBullets + [cleaned]) + bullets.append(updated) + } else { + let updatedText = "\(last.text) \(cleaned)" + bullets.append(Bullet(text: updatedText, subBullets: last.subBullets)) + } + } + } + + return bullets + } + + private func firstMeaningfulParagraph(inHTML html: String) -> String? { + let paragraphs = allCaptures(pattern: #"(?is)]*>(.*?)

"#, in: html) + for paragraph in paragraphs { + let cleaned = cleanHTML(paragraph, preserveNewlines: true) + if isMeaningfulParagraph(cleaned) { + return cleaned + } + } + + let fallback = cleanHTML(html, preserveNewlines: true) + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: isMeaningfulParagraph) + return fallback + } + + private func firstMeaningfulMarkdownParagraph(lines: [String]) -> String? { + for line in lines { + let cleaned = cleanMarkdownText(line) + if isMeaningfulParagraph(cleaned) { + return cleaned + } + } + return nil + } + + private func isMeaningfulMarkdownSection(_ section: MarkdownSection) -> Bool { + let normalizedTitle = normalizeHeader(section.title) + if normalizedTitle.contains("title") || normalizedTitle.contains("urlsource") || normalizedTitle.contains("availabledownloads") { + return false + } + + return firstMeaningfulMarkdownParagraph(lines: section.bodyLines) != nil + } + + private func markdownHeadingTitle(currentLine: String, nextLine: String?) -> String? { + guard !currentLine.isEmpty else { + return nil + } + + if let nextLine, isMarkdownUnderline(nextLine) { + return cleanMarkdownText(currentLine) + } + return nil + } + + private func markdownBoldHeadingTitle(from line: String) -> String? { + guard + line.hasPrefix("**"), + line.hasSuffix("**"), + line.count > 4 + else { + return nil + } + + let start = line.index(line.startIndex, offsetBy: 2) + let end = line.index(line.endIndex, offsetBy: -2) + let inner = line[start.. Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 3 else { + return false + } + return trimmed.allSatisfy { $0 == "-" || $0 == "=" } + } + + private func isMeaningfulParagraph(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 20 else { + return false + } + + let lower = trimmed.lowercased() + if lower.hasPrefix("title:") || lower.hasPrefix("url source:") { + return false + } + return true + } + + private func removeScriptAndStyleBlocks(_ html: String) -> String { + html + .replacingOccurrences(of: #"(?is)]*>.*?"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"(?is)]*>.*?"#, with: "", options: .regularExpression) + } + + private func cleanHTML(_ raw: String, preserveNewlines: Bool) -> String { + var text = removeScriptAndStyleBlocks(raw) + text = text.replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"(?is)<[^>]+>"#, with: " ", options: .regularExpression) + text = decodeHTMLEntities(text) + + if preserveNewlines { + text = text.replacingOccurrences(of: #"[ \t]+"#, with: " ", options: .regularExpression) + text = text.replacingOccurrences(of: #" *\n *"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression) + } else { + text = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func cleanMarkdownText(_ raw: S) -> String { + var text = String(raw) + text = replaceRegex(pattern: #"\[([^\]]+)\]\([^)]+\)"#, in: text, with: "$1") + text = replaceRegex(pattern: #"`([^`]+)`"#, in: text, with: "$1") + text = text.replacingOccurrences(of: "**", with: "") + text = text.replacingOccurrences(of: "__", with: "") + text = text.replacingOccurrences(of: "*", with: "") + text = text.replacingOccurrences(of: "_", with: "") + text = decodeHTMLEntities(text) + text = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func replaceRegex(pattern: String, in text: String, with template: String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return text + } + + let range = NSRange(text.startIndex.. String { + let decoded = decodeHTMLEntities(raw) + let compactSpaces = decoded.replacingOccurrences(of: #"[ \t]+"#, with: " ", options: .regularExpression) + let compactNewlines = compactSpaces + .replacingOccurrences(of: #" *\n *"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: #"\n{2,}"#, with: "\n", options: .regularExpression) + return compactNewlines.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func parseTagName(_ tag: String) -> String { + var token = tag.trimmingCharacters(in: .whitespacesAndNewlines) + guard token.hasPrefix("<"), token.count >= 2 else { + return "" + } + + token.removeFirst() + if token.hasPrefix("/") { + token.removeFirst() + } + + let name = token.prefix { $0.isLetter || $0.isNumber } + return String(name).lowercased() + } + + private func normalizeHeader(_ title: String) -> String { + title + .lowercased() + .replacingOccurrences(of: #"[^a-z0-9]+"#, with: "", options: .regularExpression) + } + + private func decodeHTMLEntities(_ text: String) -> String { + var output = text + let entities: [(String, String)] = [ + (" ", " "), + ("&", "&"), + (""", "\""), + ("'", "'"), + ("'", "'"), + ("<", "<"), + (">", ">") + ] + + for (entity, value) in entities { + output = output.replacingOccurrences(of: entity, with: value) + } + + if let numericRegex = try? NSRegularExpression(pattern: #"&#([0-9]{1,7});"#) { + let matches = numericRegex.matches(in: output, range: NSRange(output.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: output), + let fullRange = Range(match.range(at: 0), in: output), + let value = Int(output[valueRange]), + let scalar = UnicodeScalar(value) + else { + continue + } + + output.replaceSubrange(fullRange, with: String(scalar)) + } + } + + return output + } + + private func parseDate(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + + let isoWithFractional = ISO8601DateFormatter() + isoWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = isoWithFractional.date(from: trimmed) { + return parsed + } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + if let parsed = iso.date(from: trimmed) { + return parsed + } + + let formats = [ + "MM/dd/yyyy", + "M/d/yyyy", + "MMMM d, yyyy", + "MMM d, yyyy", + "yyyy-MM-dd" + ] + + for format in formats { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = format + if let parsed = formatter.date(from: trimmed) { + return parsed + } + } + + return nil + } + + private func parseRFC1123Date(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter.date(from: value) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MMMM dd, yyyy" + return formatter.string(from: date) + } + + private func firstCapture(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil + } + + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) + else { + return nil + } + + return String(text[captureRange]) + } + + private func allCaptures(pattern: String, in text: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) + else { + return nil + } + + return String(text[captureRange]) + } + } + + private static func defaultMirrorURL(for pageURL: URL) -> URL { + let source = pageURL.absoluteString.replacingOccurrences(of: "https://", with: "http://") + return URL(string: "https://r.jina.ai/\(source)")! + } + + private static func defaultModelURL(for pageURL: URL) -> URL { + let base = pageURL.absoluteString + if base.hasSuffix(".html") { + let model = base.replacingOccurrences(of: ".html", with: ".model.json") + if let url = URL(string: model) { + return url + } + } + + return pageURL + } +} + +private struct PagePayload { + let body: String + let response: HTTPURLResponse? +} + +private struct ParsedDriver { + let version: String + let releaseDate: Date + let releaseNotesURL: URL + let summarySection: ReleaseSection +} + +private struct HTMLHeaderMatch { + let title: String + let normalizedTitle: String + let range: Range +} + +private struct HTMLSection { + let title: String + let content: String +} + +private struct MarkdownSection { + let title: String + let bodyLines: [String] +} + +public enum IntelServiceError: LocalizedError, Sendable { + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .parseFailed(let details): + return "Intel Arc parsing failed: \(details)" + } + } +} diff --git a/SwiftBotApp/UpdateEngine/NVIDIAService.swift b/SwiftBotApp/UpdateEngine/NVIDIAService.swift new file mode 100644 index 0000000..7194853 --- /dev/null +++ b/SwiftBotApp/UpdateEngine/NVIDIAService.swift @@ -0,0 +1,193 @@ +import Foundation + +public struct NVIDIAService: Sendable { + public struct DriverInfo: Sendable { + public let releaseNotes: ReleaseNotes + public let embedJSON: String + public let rawDebug: String + public let releaseIdentifier: String + + public init( + releaseNotes: ReleaseNotes, + embedJSON: String, + rawDebug: String, + releaseIdentifier: String + ) { + self.releaseNotes = releaseNotes + self.embedJSON = embedJSON + self.rawDebug = rawDebug + self.releaseIdentifier = releaseIdentifier + } + } + + private let session: URLSession + private let apiEndpoint: URL + private let formatter: EmbedFormatter + + public init( + session: URLSession = .shared, + apiEndpoint: URL = URL(string: "https://gfwsl.geforce.com/services_toolkit/services/com/nvidia/services/AjaxDriverService.php")!, + formatter: EmbedFormatter = EmbedFormatter() + ) { + self.session = session + self.apiEndpoint = apiEndpoint + self.formatter = formatter + } + + public func fetchLatestDriver() async throws -> DriverInfo { + var request = URLRequest(url: apiEndpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + + let params = [ + "func": "DriverManualLookup", + "psid": "120", + "pfid": "916", + "osID": "135", + "languageCode": "1033", + "beta": "0", + "isWHQL": "1", + "dltype": "-1", + "dch": "1", + "sort1": "0", + "numberOfResults": "200" + ] + + request.httpBody = params + .map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + .data(using: .utf8) + + let (data, response) = try await session.data(for: request) + try validateHTTP(response) + + let rawJSON = String(data: data, encoding: .utf8) ?? "" + guard let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NVIDIAServiceError.invalidJSONResponse + } + + guard let idsArray = jsonObject["IDS"] as? [[String: Any]], !idsArray.isEmpty else { + throw NVIDIAServiceError.noDriverFound + } + + let candidates = try idsArray.compactMap { entry -> DriverCandidate? in + guard let downloadInfo = entry["downloadInfo"] as? [String: Any] else { + return nil + } + guard let versionString = downloadInfo["Version"] as? String else { + return nil + } + guard let releaseDate = downloadInfo["ReleaseDateTime"] as? String else { + return nil + } + let version = try extractVersionStrict(from: versionString) + return DriverCandidate(version: version, releaseDate: releaseDate) + } + + guard let latestDriver = candidates.max(by: { compareVersions($0.version, $1.version) < 0 }) else { + throw NVIDIAServiceError.noDriverFound + } + + let version = latestDriver.version + let releaseDate = latestDriver.releaseDate + let releaseIdentifier = "nvidia:\(version)" + + let releaseNotes = ReleaseNotes( + title: "GeForce Game Ready Driver v\(version)", + author: "NVIDIA GeForce Driver", + url: "https://www.nvidia.com/en-us/geforce/drivers/", + version: version, + date: releaseDate, + sections: [ + ReleaseSection( + title: "Driver Information", + bullets: [ + Bullet(text: "Latest Game Ready Driver for Windows 11 64-bit"), + Bullet(text: "Optimized for the latest games and applications") + ] + ) + ], + thumbnailURL: "https://cdn.patchbot.io/games/142/nvidia-geforce_1710977247_sm.jpg", + color: 5763719 + ) + + return DriverInfo( + releaseNotes: releaseNotes, + embedJSON: formatter.format(releaseNotes: releaseNotes), + rawDebug: "NVIDIA Driver API Response:\n\(rawJSON)", + releaseIdentifier: releaseIdentifier + ) + } + + private struct DriverCandidate: Sendable { + let version: String + let releaseDate: String + } + + private func compareVersions(_ lhs: String, _ rhs: String) -> Int { + let leftParts = lhs.split(separator: ".").compactMap { Int($0) } + let rightParts = rhs.split(separator: ".").compactMap { Int($0) } + let maxCount = max(leftParts.count, rightParts.count) + for index in 0.. String { + let pattern = #"\b(\d{3}\.\d{2})\b"# + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) + else { + throw NVIDIAServiceError.versionExtractionFailed(text: text) + } + + return String(text[captureRange]) + } + + private func validateHTTP(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw NVIDIAServiceError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + throw NVIDIAServiceError.httpError(statusCode: http.statusCode) + } + } +} + +public enum NVIDIAServiceError: LocalizedError, Sendable { + case invalidResponse + case httpError(statusCode: Int) + case invalidJSONResponse + case noDriverFound + case missingField(String) + case versionExtractionFailed(text: String) + + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "NVIDIA API returned an invalid response object." + case .httpError(let statusCode): + return "NVIDIA API request failed with HTTP \(statusCode)." + case .invalidJSONResponse: + return "NVIDIA API returned invalid JSON." + case .noDriverFound: + return "No NVIDIA driver entries were returned." + case .missingField(let name): + return "NVIDIA response missing required field '\(name)'." + case .versionExtractionFailed(let text): + return "Failed to extract NVIDIA version from '\(text)'." + } + } +} diff --git a/SwiftBotApp/UpdateEngine/ReleaseNotes.swift b/SwiftBotApp/UpdateEngine/ReleaseNotes.swift new file mode 100644 index 0000000..c237e6b --- /dev/null +++ b/SwiftBotApp/UpdateEngine/ReleaseNotes.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct ReleaseNotes: Sendable, Codable, Hashable { + public let title: String + public let author: String + public let url: String + public let version: String + public let date: String + public let sections: [ReleaseSection] + public let thumbnailURL: String + public let color: Int + + public init( + title: String, + author: String, + url: String, + version: String, + date: String, + sections: [ReleaseSection], + thumbnailURL: String, + color: Int + ) { + self.title = title + self.author = author + self.url = url + self.version = version + self.date = date + self.sections = sections + self.thumbnailURL = thumbnailURL + self.color = color + } +} + +public struct ReleaseSection: Sendable, Codable, Hashable { + public let title: String + public let bullets: [Bullet] + + public init(title: String, bullets: [Bullet]) { + self.title = title + self.bullets = bullets + } +} + +public struct Bullet: Sendable, Codable, Hashable { + public let text: String + public let subBullets: [String] + + public init(text: String, subBullets: [String] = []) { + self.text = text + self.subBullets = subBullets + } +} diff --git a/SwiftBotApp/UpdateEngine/SteamService.swift b/SwiftBotApp/UpdateEngine/SteamService.swift new file mode 100644 index 0000000..309114b --- /dev/null +++ b/SwiftBotApp/UpdateEngine/SteamService.swift @@ -0,0 +1,251 @@ +import Foundation + +public struct SteamNewsItem: Sendable, Codable, Hashable { + public let gid: String + public let title: String + public let url: String + public let contents: String + public let date: Int + public let feedLabel: String + public let appID: Int + + public init( + gid: String, + title: String, + url: String, + contents: String, + date: Int, + feedLabel: String, + appID: Int + ) { + self.gid = gid + self.title = title + self.url = url + self.contents = contents + self.date = date + self.feedLabel = feedLabel + self.appID = appID + } + + public var dateFormatted: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MMMM dd, yyyy" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(date))) + } +} + +public struct SteamService: Sendable { + public struct NewsInfo: Sendable { + public let newsItem: SteamNewsItem + public let embedJSON: String + public let rawDebug: String + public let releaseIdentifier: String + + public init(newsItem: SteamNewsItem, embedJSON: String, rawDebug: String, releaseIdentifier: String) { + self.newsItem = newsItem + self.embedJSON = embedJSON + self.rawDebug = rawDebug + self.releaseIdentifier = releaseIdentifier + } + } + + private let session: URLSession + private let formatter: EmbedFormatter + + public init(session: URLSession = .shared, formatter: EmbedFormatter = EmbedFormatter()) { + self.session = session + self.formatter = formatter + } + + public func fetchLatestNews(for appID: String) async throws -> NewsInfo { + guard let appIDInt = Int(appID) else { + throw SteamServiceError.invalidAppID(appID) + } + + let urlString = "https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/?appid=\(appID)&count=100&maxlength=5000" + guard let url = URL(string: urlString) else { + throw SteamServiceError.invalidURL + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + + let (data, response) = try await session.data(for: request) + try validateHTTP(response) + + let rawJSON = String(data: data, encoding: .utf8) ?? "" + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(SteamAPIResponse.self, from: data) + + let acceptedSubstrings = ["update", "patch", "hotfix", "notes"] + let rejectedSubstrings = ["store", "sale", "community", "event", "spotlight"] + + func isValidTitle(_ title: String) -> Bool { + let lower = title.lowercased() + let containsAccepted = acceptedSubstrings.contains { lower.contains($0) } + let containsRejected = rejectedSubstrings.contains { lower.contains($0) } + return containsAccepted && !containsRejected + } + + let matchingItems = apiResponse.appnews.newsitems.filter { isValidTitle($0.title) } + guard let newsItem = matchingItems.max(by: { compareNewsItems($0, $1) < 0 }) else { + throw SteamServiceError.noNewsItems + } + + let item = SteamNewsItem( + gid: newsItem.gid, + title: newsItem.title, + url: newsItem.url, + contents: newsItem.contents, + date: newsItem.date, + feedLabel: newsItem.feedlabel, + appID: appIDInt + ) + + let releaseNotes = formatReleaseNotes(item: item, appID: appIDInt) + let embedJSON = formatter.format(releaseNotes: releaseNotes) + + return NewsInfo( + newsItem: item, + embedJSON: embedJSON, + rawDebug: "Steam API Response:\n\(rawJSON)", + releaseIdentifier: item.gid + ) + } + + private func compareNewsItems(_ lhs: SteamAPIResponse.NewsItem, _ rhs: SteamAPIResponse.NewsItem) -> Int { + if lhs.date != rhs.date { + return lhs.date < rhs.date ? -1 : 1 + } + + guard let leftGID = UInt64(lhs.gid), let rightGID = UInt64(rhs.gid), leftGID != rightGID else { + return 0 + } + return leftGID < rightGID ? -1 : 1 + } + + private func formatReleaseNotes(item: SteamNewsItem, appID: Int) -> ReleaseNotes { + let headerURL = "https://cdn.akamai.steamstatic.com/steam/apps/\(appID)/header.jpg" + let cleanedContents = removeFirstImageTag(from: item.contents) + let sections = parseContent(cleanedContents) + let appName = item.feedLabel.isEmpty ? "Steam App \(appID)" : item.feedLabel + + return ReleaseNotes( + title: item.title, + author: appName, + url: item.url, + version: item.dateFormatted, + date: item.dateFormatted, + sections: sections, + thumbnailURL: headerURL, + color: 0x1B2838 + ) + } + + private func parseContent(_ content: String) -> [ReleaseSection] { + var cleaned = content + let bbCodePatterns = [ + ("\\[/?b\\]", "**"), + ("\\[/?i\\]", "*"), + ("\\[/?u\\]", ""), + ("\\[url=[^\\]]+\\]", ""), + ("\\[/url\\]", ""), + ("\\[img\\][^\\[]+\\[/img\\]", ""), + ("\\[list\\]", ""), + ("\\[/list\\]", ""), + ("\\[\\*\\]", "• ") + ] + + for (pattern, replacement) in bbCodePatterns { + cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } + + let lines = cleaned + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + if lines.isEmpty { + return [ReleaseSection(title: "Patch Notes", bullets: [Bullet(text: "No details available.")])] + } + + let bullets = lines.prefix(10).map { line in + let truncated = line.count > 200 ? String(line.prefix(197)) + "..." : line + return Bullet(text: truncated) + } + + return [ReleaseSection(title: "Patch Notes", bullets: bullets)] + } + + private func removeFirstImageTag(from html: String) -> String { + let pattern = "(?is)]*>" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return html + } + + let range = NSRange(html.startIndex.. UpdateChangeResult { + let normalizedKey = CacheKeyBuilder.normalizeCacheKey(key) + guard let lastIdentifier = try await store.lastIdentifier(for: normalizedKey) else { + return .firstSeen(identifier: identifier) + } + + if lastIdentifier != identifier { + return .changed(old: lastIdentifier, new: identifier) + } + + return .unchanged(identifier: identifier) + } + + /// Convenience API that checks an UpdateItem using its source key by default. + public func check(item: any UpdateItem, for keyOverride: String? = nil) async throws -> UpdateChangeResult { + let key = keyOverride ?? item.sourceKey + return try await check(identifier: item.identifier, for: key) + } + + /// Persist an identifier for a cache key. + public func save(identifier: String, for key: String) async throws { + let normalizedKey = CacheKeyBuilder.normalizeCacheKey(key) + try await store.save(identifier: identifier, for: normalizedKey) + } + + /// Convenience API to persist an UpdateItem identifier. + public func save(item: any UpdateItem, for keyOverride: String? = nil) async throws { + let key = keyOverride ?? item.sourceKey + try await save(identifier: item.identifier, for: key) + } +} diff --git a/SwiftBotApp/UpdateEngine/UpdateItem.swift b/SwiftBotApp/UpdateEngine/UpdateItem.swift new file mode 100644 index 0000000..ea5fe9b --- /dev/null +++ b/SwiftBotApp/UpdateEngine/UpdateItem.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Represents a single fetched update entry from an upstream source. +public protocol UpdateItem: Sendable { + /// Stable source key used as the base cache partition. + var sourceKey: String { get } + + /// Stable identifier for the specific update item. + /// This should not be a display version unless no better identifier exists. + var identifier: String { get } + + /// Human-readable version shown to users. + var version: String { get } +} + +/// Generic update item for simple sources. +public struct BasicUpdateItem: UpdateItem, Sendable, Codable, Hashable { + public let sourceKey: String + public let identifier: String + public let version: String + + public init(sourceKey: String, identifier: String, version: String) { + self.sourceKey = sourceKey + self.identifier = identifier + self.version = version + } +} + +/// Rich update item used by driver vendors. +public struct DriverUpdateItem: UpdateItem, Sendable { + public let sourceKey: String + public let identifier: String + public let version: String + public let releaseNotes: ReleaseNotes + public let embedJSON: String + public let rawDebug: String + + public init( + sourceKey: String, + identifier: String, + version: String, + releaseNotes: ReleaseNotes, + embedJSON: String, + rawDebug: String + ) { + self.sourceKey = sourceKey + self.identifier = identifier + self.version = version + self.releaseNotes = releaseNotes + self.embedJSON = embedJSON + self.rawDebug = rawDebug + } +} + +/// Rich update item used by Steam news sources. +public struct SteamUpdateItem: UpdateItem, Sendable { + public let sourceKey: String + public let identifier: String + public let version: String + public let newsItem: SteamNewsItem + public let embedJSON: String + public let rawDebug: String + + public init( + sourceKey: String, + identifier: String, + version: String, + newsItem: SteamNewsItem, + embedJSON: String, + rawDebug: String + ) { + self.sourceKey = sourceKey + self.identifier = identifier + self.version = version + self.newsItem = newsItem + self.embedJSON = embedJSON + self.rawDebug = rawDebug + } +} diff --git a/SwiftBotApp/UpdateEngine/UpdateSource.swift b/SwiftBotApp/UpdateEngine/UpdateSource.swift new file mode 100644 index 0000000..30860eb --- /dev/null +++ b/SwiftBotApp/UpdateEngine/UpdateSource.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Fetches the latest item from an upstream update feed. +public protocol UpdateSource: Sendable { + /// Stable source key used for cache partitioning. + var sourceKey: String { get } + + /// Fetch the latest item from the source. + func fetchLatest() async throws -> any UpdateItem +} + +/// Type-erased update source for heterogeneous collections. +public struct AnyUpdateSource: UpdateSource, Sendable { + public let sourceKey: String + private let fetchClosure: @Sendable () async throws -> any UpdateItem + + public init( + sourceKey: String, + fetch: @escaping @Sendable () async throws -> any UpdateItem + ) { + self.sourceKey = sourceKey + self.fetchClosure = fetch + } + + public func fetchLatest() async throws -> any UpdateItem { + try await fetchClosure() + } +} diff --git a/SwiftBotApp/UpdateEngine/VersionStore.swift b/SwiftBotApp/UpdateEngine/VersionStore.swift new file mode 100644 index 0000000..16e34a6 --- /dev/null +++ b/SwiftBotApp/UpdateEngine/VersionStore.swift @@ -0,0 +1,87 @@ +import Foundation + +public protocol VersionStore: Sendable { + func lastIdentifier(for key: String) async throws -> String? + func save(identifier: String, for key: String) async throws +} + +public enum VersionStoreError: Error, LocalizedError, Sendable { + case invalidJSON(URL) + + public var errorDescription: String? { + switch self { + case .invalidJSON(let url): + return "Failed to decode identifier cache JSON at \(url.path)." + } + } +} + +/// File-backed identifier store. +public actor JSONVersionStore: VersionStore { + private let fileURL: URL + private let fileManager: FileManager + private var cache: [String: String] + + public init(fileURL: URL, fileManager: FileManager = .default) throws { + self.fileURL = fileURL + self.fileManager = fileManager + self.cache = try Self.loadCache(fileURL: fileURL, fileManager: fileManager) + } + + public func lastIdentifier(for key: String) async throws -> String? { + cache[key] + } + + public func save(identifier: String, for key: String) async throws { + cache[key] = identifier + try persistToDisk() + } + + public func snapshot() -> [String: String] { + cache + } + + private static func loadCache(fileURL: URL, fileManager: FileManager) throws -> [String: String] { + guard fileManager.fileExists(atPath: fileURL.path) else { + return [:] + } + + let data = try Data(contentsOf: fileURL) + do { + return try JSONDecoder().decode([String: String].self, from: data) + } catch { + throw VersionStoreError.invalidJSON(fileURL) + } + } + + private func persistToDisk() throws { + let parentDirectory = fileURL.deletingLastPathComponent() + if !fileManager.fileExists(atPath: parentDirectory.path) { + try fileManager.createDirectory(at: parentDirectory, withIntermediateDirectories: true) + } + + let data = try JSONEncoder().encode(cache) + try data.write(to: fileURL, options: .atomic) + } +} + +/// In-memory identifier store for tests and ephemeral runs. +public actor InMemoryVersionStore: VersionStore { + private var cache: [String: String] + + public init(seed: [String: String] = [:]) { + self.cache = seed + } + + public func lastIdentifier(for key: String) async throws -> String? { + cache[key] + } + + public func save(identifier: String, for key: String) async throws { + cache[key] = identifier + } + + public func clear() { + cache.removeAll() + } +} diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 6694b3b..2fd1895 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -735,8 +735,8 @@ struct RuleLibraryButton: View { let systemImage: String let accent: Color var isDisabled: Bool = false - var disabledReason: String? = nil - var dragItem: String? = nil + var disabledReason: String? + var dragItem: String? let action: () -> Void var body: some View { @@ -891,7 +891,7 @@ struct ConditionsSectionView: View { struct ConditionRowView: View { @Binding var condition: Condition var isIncompatible: Bool = false - var missingContext: String? = nil + var missingContext: String? let serverIds: [String] let serverName: (String) -> String @@ -906,7 +906,7 @@ struct ConditionRowView: View { Label(condition.type.rawValue, systemImage: condition.type.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(.cyan) - + if isIncompatible { Label(missingContext ?? "Incompatible with trigger", systemImage: "exclamationmark.triangle.fill") .font(.caption) @@ -915,7 +915,7 @@ struct ConditionRowView: View { } Spacer() - + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } @@ -1094,7 +1094,7 @@ struct ActionSectionView: View { let allModifiers: [Action] let currentTrigger: TriggerType? var isIncompatible: Bool = false - var missingContext: String? = nil + var missingContext: String? let serverIds: [String] let serverName: (String) -> String @@ -1156,7 +1156,7 @@ struct ActionSectionView: View { Label(action.type.rawValue, systemImage: action.type.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(category == .messaging ? .orange : .mint) - + if isIncompatible { Label(missingContext ?? "Incompatible with trigger", systemImage: "exclamationmark.triangle.fill") .font(.caption) @@ -1165,7 +1165,7 @@ struct ActionSectionView: View { } Spacer() - + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } @@ -1417,8 +1417,8 @@ struct EmptyRuleStateView: View { let icon: String let title: String let description: String - var onShowMe: (() -> Void)? = nil - var onContinue: (() -> Void)? = nil + var onShowMe: (() -> Void)? + var onContinue: (() -> Void)? var body: some View { VStack(spacing: 24) { @@ -1426,7 +1426,7 @@ struct EmptyRuleStateView: View { .font(.system(size: 48)) .foregroundStyle(.yellow) .symbolEffect(.bounce, value: true) - + VStack(spacing: 8) { Text(title) .font(.title2.weight(.bold)) @@ -1649,7 +1649,7 @@ struct SearchableIDPicker: View { .font(.caption) .foregroundStyle(.secondary) } - + Button { showPopover = true } label: { @@ -1683,9 +1683,9 @@ struct SearchableIDPicker: View { } .padding(10) .background(.white.opacity(0.05)) - + Divider() - + if filteredItems.isEmpty { Text("No results found") .font(.caption) diff --git a/SwiftBotApp/WebUIPreferencesView.swift b/SwiftBotApp/WebUIPreferencesView.swift index 6a99b66..458b946 100644 --- a/SwiftBotApp/WebUIPreferencesView.swift +++ b/SwiftBotApp/WebUIPreferencesView.swift @@ -106,7 +106,7 @@ struct InternetAccessConfigurationSection: View { @State private var isDisabling = false @State private var setupFeedback: InternetAccessFeedback? @State private var setupProgress: InternetAccessSetupProgress? - @State private var lastError: Error? = nil + @State private var lastError: Error? @State private var showingReRunSetupConfirmation = false @State private var showingNonPrimaryWarning = false @@ -114,7 +114,7 @@ struct InternetAccessConfigurationSection: View { @State private var availableZones: [CloudflareDNSProvider.ZoneSummary] = [] @State private var isVerifyingToken = false @State private var hasVerifiedToken = false - @State private var tokenVerificationTask: Task? = nil + @State private var tokenVerificationTask: Task? private var selectedZone: CloudflareDNSProvider.ZoneSummary? { availableZones.first(where: { $0.id == app.settings.adminWebUI.selectedZoneID }) @@ -124,7 +124,7 @@ struct InternetAccessConfigurationSection: View { if app.settings.adminWebUI.internetAccessEnabled && app.adminWebPublicAccessStatus.isEnabled { return app.adminWebPublicAccessURL()?.absoluteString ?? "" } - + let hostname = app.settings.adminWebUI.normalizedHostname return hostname.isEmpty ? "" : "https://\(hostname)" } @@ -237,7 +237,7 @@ struct InternetAccessConfigurationSection: View { VStack(alignment: .leading, spacing: 8) { Text("Cloudflare API Token") .font(.subheadline.weight(.medium)) - + HStack(spacing: 8) { SecureField("Token with DNS:Edit and Tunnel:Edit permissions", text: $app.settings.adminWebUI.cloudflareAPIToken) .textFieldStyle(.roundedBorder) @@ -247,7 +247,7 @@ struct InternetAccessConfigurationSection: View { availableZones = [] app.settings.adminWebUI.selectedZoneID = "" } - + if !hasVerifiedToken { Button { verifyToken() @@ -265,7 +265,7 @@ struct InternetAccessConfigurationSection: View { .foregroundStyle(.green) } } - + Text("Stored securely in your macOS Keychain.") .font(.caption) .foregroundStyle(.secondary) @@ -274,7 +274,7 @@ struct InternetAccessConfigurationSection: View { VStack(alignment: .leading, spacing: 8) { Text("Hostname") .font(.subheadline.weight(.medium)) - + // Inline hostname editor: [subdomain] . [zone ▼] HStack(spacing: 6) { TextField("swiftbot", text: $app.settings.adminWebUI.subdomain) @@ -287,11 +287,11 @@ struct InternetAccessConfigurationSection: View { app.settings.adminWebUI.subdomain = filtered } } - + Text(".") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) - + Picker("", selection: $app.settings.adminWebUI.selectedZoneID) { if availableZones.isEmpty { if !app.settings.adminWebUI.selectedZoneName.isEmpty { @@ -315,10 +315,10 @@ struct InternetAccessConfigurationSection: View { app.settings.adminWebUI.selectedZoneName = zone.name } } - + Spacer() } - + // Live URL preview HStack(spacing: 4) { Text("SwiftBot will be available at:") @@ -342,12 +342,12 @@ struct InternetAccessConfigurationSection: View { .font(.headline) .fontWeight(.semibold) } - + Text(publicURLString) .font(.subheadline) .foregroundStyle(.secondary) .textSelection(.enabled) - + HStack(spacing: 10) { Button { openURL() @@ -364,7 +364,7 @@ struct InternetAccessConfigurationSection: View { } .buttonStyle(.bordered) .controlSize(.regular) - + Button { showingReRunSetupConfirmation = true } label: { @@ -477,8 +477,8 @@ struct InternetAccessConfigurationSection: View { } } message: { let primaryHost = app.settings.clusterLeaderAddress - let message = "This SwiftBot instance is running as a Worker node in a SwiftMesh cluster.\n\nWeb UI configuration changes made here will NOT be synchronized to the Primary node.\n\nFor consistent configuration, it is recommended to access the Web UI through the Primary SwiftBot instance instead." - + let message = "This SwiftBot instance is running as a Worker node in a SwiftMesh cluster.\n\nWeb UI configuration changes made here will NOT be synchronized to the Primary node.\n\nFor consistent configuration, it is recommended to access the Web UI through the Primary SwiftBot instance instead." // swiftlint:disable:this line_length + if !primaryHost.isEmpty { Text("\(message)\n\nPrimary node detected at: \(primaryHost)") } else { @@ -489,20 +489,20 @@ struct InternetAccessConfigurationSection: View { private func verifyToken() { guard !isVerifyingToken else { return } - + isVerifyingToken = true setupFeedback = nil tokenVerificationTask?.cancel() - + tokenVerificationTask = Task { @MainActor in do { let zones = try await app.verifyCloudflareTokenAndListZones(token: app.settings.adminWebUI.cloudflareAPIToken) guard !Task.isCancelled else { return } - + self.availableZones = zones self.hasVerifiedToken = true self.isVerifyingToken = false - + // Auto-select if only one zone if zones.count == 1, let firstZone = zones.first { app.settings.adminWebUI.selectedZoneID = firstZone.id @@ -522,14 +522,14 @@ struct InternetAccessConfigurationSection: View { private func enable(forceReplaceDNS: Bool = false) { guard !isEnabling else { return } - + let fullHostname: String if !app.settings.adminWebUI.selectedZoneID.isEmpty, !app.settings.adminWebUI.subdomain.isEmpty, let zone = selectedZone { fullHostname = "\(app.settings.adminWebUI.subdomain.lowercased()).\(zone.name)" } else { fullHostname = app.settings.adminWebUI.normalizedHostname } - + guard !fullHostname.isEmpty else { setupFeedback = InternetAccessFeedback(status: .warning, message: "Configure a hostname first.") return @@ -714,7 +714,7 @@ private struct InternetAccessSetupProgress { setItem(id: "enable-access", status: .warning, detail: "Starting Cloudflare tunnel…") case .cloudflareTunnelStarted: setItem(id: "enable-access", status: .success, detail: "Internet Access enabled.") - case .internetAccessEnabled(_): + case .internetAccessEnabled: break } } @@ -809,9 +809,9 @@ struct AdminWebAuthenticationSection: View { // Handle existing path in base URL (e.g. proxy subpath) if !components.path.isEmpty && components.path != "/" { - let base_path = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path - let sub_path = path.hasPrefix("/") ? path : "/" + path - components.path = base_path + sub_path + let basePath = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path + let subPath = path.hasPrefix("/") ? path : "/" + path + components.path = basePath + subPath } else { components.path = path } @@ -1093,7 +1093,6 @@ struct AdminWebLaunchControls: View { } } - private extension CertificateManager.ValidationStatus { var color: Color { switch self { diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..d517597 --- /dev/null +++ b/project.yml @@ -0,0 +1,167 @@ +name: SwiftBot +options: + bundleIdPrefix: com.example + deploymentTarget: + macOS: "26.0" + xcodeVersion: "16.3" + developmentLanguage: en + groupSortPosition: top + generateEmptyDirectories: true + findCarthageFrameworks: false + +packages: + Sparkle: + url: https://github.com/sparkle-project/Sparkle + minorVersion: 2.8.0 + swift-certificates: + url: https://github.com/apple/swift-certificates.git + minorVersion: 1.9.0 + swift-crypto: + url: https://github.com/apple/swift-crypto.git + minorVersion: 3.12.3 + swift-asn1: + url: https://github.com/apple/swift-asn1.git + minorVersion: 1.1.0 + swift-nio: + url: https://github.com/apple/swift-nio.git + minorVersion: 2.80.0 + swift-nio-ssl: + url: https://github.com/apple/swift-nio-ssl.git + minorVersion: 2.30.0 + +targets: + SwiftBot: + type: application + platform: macOS + deploymentTarget: "26.0" + sources: + - path: SwiftBotApp + excludes: + - Resources/** + - TestSupport.swift + type: group + - path: SwiftBotApp/Resources + type: folder + buildPhase: resources + - path: SwiftBot.icon + type: file + buildPhase: resources + preBuildScripts: + - name: SwiftLint + script: "if which swiftlint > /dev/null; then swiftlint; fi" + basedOnDependencyAnalysis: false + dependencies: + - package: Sparkle + product: Sparkle + - package: swift-certificates + product: X509 + - package: swift-crypto + product: Crypto + - package: swift-asn1 + product: SwiftASN1 + - package: swift-nio + product: NIOCore + - package: swift-nio + product: NIOPosix + - package: swift-nio-ssl + product: NIOSSL + settings: + base: + PRODUCT_NAME: SwiftBot + PRODUCT_BUNDLE_IDENTIFIER: com.example.swiftbot + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "26.0" + SDKROOT: macosx + INFOPLIST_FILE: SwiftBot-Info.plist + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: SwiftBot + INFOPLIST_KEY_CFBundleName: SwiftBot + INFOPLIST_KEY_LSApplicationCategoryType: "" + INFOPLIST_KEY_LSMinimumSystemVersion: "26.0" + INFOPLIST_KEY_NSHighResolutionCapable: YES + INFOPLIST_KEY_NSLocalNetworkUsageDescription: "SwiftBot needs local network access to reach other leader/worker SwiftBots" + INFOPLIST_KEY_SUEnableAutomaticChecks: YES + INFOPLIST_KEY_SUFeedURL: "https://johnwatso.github.io/SwiftBot/appcast.xml" + INFOPLIST_KEY_SUPublicEDKey: "rxaJsfCpTKtpqRubSfkJwKnztT5S8RHsdAueuT+jKck=" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: NO + COMBINE_HIDPI_IMAGES: YES + CURRENT_PROJECT_VERSION: "196000" + MARKETING_VERSION: "1.9.6" + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Manual + PROVISIONING_PROFILE_SPECIFIER: "" + ENABLE_HARDENED_RUNTIME: YES + DEAD_CODE_STRIPPING: YES + AUTOMATION_APPLE_EVENTS: NO + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT: NO + ENABLE_RESOURCE_ACCESS_CALENDARS: NO + ENABLE_RESOURCE_ACCESS_CAMERA: NO + ENABLE_RESOURCE_ACCESS_CONTACTS: NO + ENABLE_RESOURCE_ACCESS_LOCATION: NO + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY: NO + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES: NO + RUNTIME_EXCEPTION_ALLOW_JIT: NO + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY: NO + RUNTIME_EXCEPTION_DEBUGGING_TOOL: NO + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION: NO + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION: NO + LD_RUNPATH_SEARCH_PATHS: + - "$(inherited)" + - "@executable_path/../Frameworks" + STRING_CATALOG_GENERATE_SYMBOLS: YES + ENABLE_USER_SCRIPT_SANDBOXING: YES + debug: + CODE_SIGNING_ALLOWED: NO + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + DEBUG_INFORMATION_FORMAT: dwarf + GCC_OPTIMIZATION_LEVEL: "0" + MTL_ENABLE_DEBUG_INFO: INCLUDE_SOURCE + ONLY_ACTIVE_ARCH: YES + release: + CODE_SIGNING_ALLOWED: YES + SWIFT_COMPILATION_MODE: wholemodule + SWIFT_OPTIMIZATION_LEVEL: "-O" + DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym" + ENABLE_NS_ASSERTIONS: NO + MTL_ENABLE_DEBUG_INFO: NO + + SparklePublisher: + type: tool + platform: macOS + deploymentTarget: "26.0" + sources: + - path: Tools/SparklePublisher + settings: + base: + PRODUCT_NAME: SparklePublisher + PRODUCT_BUNDLE_IDENTIFIER: com.example.swiftbot.sparklepublisher + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "26.0" + SDKROOT: macosx + +schemes: + SwiftBot: + build: + targets: + SwiftBot: all + run: + config: Debug + test: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release + + SparklePublisher: + build: + targets: + SparklePublisher: all + run: + config: Debug + archive: + config: Release