From b778a53a4d803f3bdf88671e1fae7b219b9a8f89 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 16 Aug 2026 00:37:12 +0800 Subject: [PATCH 1/2] feat: add optional LINUX DO browsing plugin --- Plugins/Official/LinuxDoSupport/Info.plist | 15 + Plugins/Official/LinuxDoSupport/plugin.json | 49 + Resources/Info.plist | 17 +- .../Application/Composition/AppServices.swift | 3 + .../DiscourseCommunityFeatureModel.swift | 120 +++ ...ExternalAuthorizationCallbackRouting.swift | 7 + Sources/Lithe/Core/Rust/RustCoreBridge.swift | 232 +++++ Sources/Lithe/LitheApp.swift | 10 +- Sources/Lithe/Models/AppModel/AppModel.swift | 6 + .../Community/LinuxDoAnonymousWebView.swift | 277 ++++++ ...cExternalAuthorizationCallbackRouter.swift | 27 + .../Platform/MacOS/MacServiceContainer.swift | 15 +- .../Community/DiscourseCommunityService.swift | 183 ++++ Sources/Lithe/Theme/LitheTheme.swift | 22 +- Sources/Lithe/Views/App/SettingsView.swift | 9 +- .../LinuxDoCommunityFormatting.swift | 46 + .../Community/LinuxDoCommunityView.swift | 81 ++ .../Community/LinuxDoTopicDetailView.swift | 109 +++ .../Community/LinuxDoTopicListView.swift | 186 ++++ .../DatabaseSpecializedWorkspaceViews.swift | 6 +- .../Lithe/Views/Git/ChangesSidebarView.swift | 6 +- .../Run/JavaRunConfigurationEditorView.swift | 3 +- .../WorkbenchModuleUIComposition.swift | 30 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 110 ++- .../Module/LinuxDoSupportModule.swift | 19 + .../LinuxDoSupportPluginEntrypoint.swift | 17 + .../Catalog/BuiltInModuleCatalog.swift | 38 + .../Lifecycle/ModuleTypes.swift | 1 + .../LinuxDoAnonymousWebSessionTests.swift | 33 + .../LinuxDoCommunityFormattingTests.swift | 24 + Tests/LitheTests/LitheCoreLogicTests.swift | 13 + ...rnalAuthorizationCallbackRouterTests.swift | 30 + Tests/LitheTests/PluginManagerTests.swift | 11 +- rust/Cargo.lock | 8 + rust/lithe-core/Cargo.toml | 6 + rust/lithe-core/src/community/discourse.rs | 926 ++++++++++++++++++ rust/lithe-core/src/community/mod.rs | 10 + rust/lithe-core/src/lib.rs | 1 + rust/lithe-core/src/protocol/command.rs | 36 + rust/lithe-core/src/runtime/dispatcher.rs | 127 +++ scripts/preview.sh | 13 +- shared/contracts/application-boundary.md | 1 + shared/contracts/rust-core-api.md | 27 + .../fixtures/community/discourse-auth-v1.json | 34 + 44 files changed, 2911 insertions(+), 33 deletions(-) create mode 100644 Plugins/Official/LinuxDoSupport/Info.plist create mode 100644 Plugins/Official/LinuxDoSupport/plugin.json create mode 100644 Sources/Lithe/Application/Features/Community/DiscourseCommunityFeatureModel.swift create mode 100644 Sources/Lithe/Core/Ports/ExternalAuthorizationCallbackRouting.swift create mode 100644 Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift create mode 100644 Sources/Lithe/Platform/MacOS/Community/MacExternalAuthorizationCallbackRouter.swift create mode 100644 Sources/Lithe/Services/Community/DiscourseCommunityService.swift create mode 100644 Sources/Lithe/Views/Community/LinuxDoCommunityFormatting.swift create mode 100644 Sources/Lithe/Views/Community/LinuxDoCommunityView.swift create mode 100644 Sources/Lithe/Views/Community/LinuxDoTopicDetailView.swift create mode 100644 Sources/Lithe/Views/Community/LinuxDoTopicListView.swift create mode 100644 Sources/LitheLinuxDoSupportModule/Module/LinuxDoSupportModule.swift create mode 100644 Sources/LitheLinuxDoSupportModule/Plugin/LinuxDoSupportPluginEntrypoint.swift create mode 100644 Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift create mode 100644 Tests/LitheTests/LinuxDoCommunityFormattingTests.swift create mode 100644 Tests/LitheTests/MacExternalAuthorizationCallbackRouterTests.swift create mode 100644 rust/lithe-core/src/community/discourse.rs create mode 100644 rust/lithe-core/src/community/mod.rs create mode 100644 shared/fixtures/community/discourse-auth-v1.json diff --git a/Plugins/Official/LinuxDoSupport/Info.plist b/Plugins/Official/LinuxDoSupport/Info.plist new file mode 100644 index 00000000..7c296eb2 --- /dev/null +++ b/Plugins/Official/LinuxDoSupport/Info.plist @@ -0,0 +1,15 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableLitheLinuxDoSupportPlugin + CFBundleIdentifierdev.lithe.plugin.linux-do-support.bundle + CFBundleInfoDictionaryVersion6.0 + CFBundleNameLINUX DO Support + CFBundlePackageTypeBNDL + CFBundleShortVersionString0.3.0 + CFBundleVersion1 + NSPrincipalClassLitheLinuxDoSupportPluginEntrypoint + + diff --git a/Plugins/Official/LinuxDoSupport/plugin.json b/Plugins/Official/LinuxDoSupport/plugin.json new file mode 100644 index 00000000..a13f6096 --- /dev/null +++ b/Plugins/Official/LinuxDoSupport/plugin.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "id": "dev.lithe.plugin.linux-do-support", + "displayName": "LINUX DO Support", + "version": "0.3.0", + "apiVersion": 1, + "hostCompatibility": { + "minimum": "0.3.0", + "maximumExclusive": "0.4.0" + }, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "sameTeamAsHost" + }, + "entrypoint": { + "kind": "nativeBundle", + "bundleIdentifier": "dev.lithe.plugin.linux-do-support.bundle", + "principalClass": "LitheLinuxDoSupportPluginEntrypoint", + "bundlePath": "LinuxDoSupport.bundle" + }, + "modules": [ + { + "id": "dev.lithe.community.linux-do", + "displayName": "LINUX DO", + "scope": "application", + "defaultState": "disabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "never" }, + "moduleDependencies": [], + "capabilityDependencies": [], + "providedCapabilities": [], + "contributions": [ + { + "id": "community.linux-do", + "kind": "toolWindow", + "title": "LINUX DO", + "icon": "bubble.left.and.bubble.right", + "placement": "rightSidebar", + "order": 100, + "actionID": "community.linux-do.toggle", + "rendererID": "community.linux-do.browser", + "visibility": {} + } + ], + "required": false + } + ] +} diff --git a/Resources/Info.plist b/Resources/Info.plist index cad6a2bd..2b467189 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -4,8 +4,21 @@ CFBundleDevelopmentRegion en - CFBundleDisplayName - Lithe + CFBundleDisplayName + Lithe + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + app.lithe.desktop.authorization + CFBundleURLSchemes + + lithe + + + CFBundleExecutable Lithe CFBundleIconFile diff --git a/Sources/Lithe/Application/Composition/AppServices.swift b/Sources/Lithe/Application/Composition/AppServices.swift index df81ba18..5f8fc63a 100644 --- a/Sources/Lithe/Application/Composition/AppServices.swift +++ b/Sources/Lithe/Application/Composition/AppServices.swift @@ -33,6 +33,7 @@ final class AppServices { let githubService: GitHubService let secureStore: any SecureStore let databaseSecureStore: any SecureStore + let discourseCommunityService: DiscourseCommunityService let credentialResolver: any AIProviderCredentialResolver let aiConfigurationSources: [any AIConfigurationSource] let recentProjectsStore: RecentProjectsStore @@ -62,6 +63,7 @@ final class AppServices { githubService: GitHubService, secureStore: any SecureStore, databaseSecureStore: any SecureStore, + discourseCommunityService: DiscourseCommunityService, credentialResolver: any AIProviderCredentialResolver, aiConfigurationSources: [any AIConfigurationSource], recentProjectsStore: RecentProjectsStore, @@ -95,6 +97,7 @@ final class AppServices { self.githubService = githubService self.secureStore = secureStore self.databaseSecureStore = databaseSecureStore + self.discourseCommunityService = discourseCommunityService self.credentialResolver = credentialResolver self.aiConfigurationSources = aiConfigurationSources self.recentProjectsStore = recentProjectsStore diff --git a/Sources/Lithe/Application/Features/Community/DiscourseCommunityFeatureModel.swift b/Sources/Lithe/Application/Features/Community/DiscourseCommunityFeatureModel.swift new file mode 100644 index 00000000..147f643f --- /dev/null +++ b/Sources/Lithe/Application/Features/Community/DiscourseCommunityFeatureModel.swift @@ -0,0 +1,120 @@ +import Foundation + +@MainActor +final class DiscourseCommunityFeatureModel: ObservableObject { + enum State: Equatable { + case signedOut + case authorizing + case loading + case ready + case failed(String) + } + + enum Feed: String, CaseIterable, Identifiable { + case latest + case top + + var id: String { rawValue } + } + + @Published private(set) var state: State + @Published private(set) var topics: [RustCoreBridge.DiscourseTopicSummary] = [] + @Published private(set) var categories: [RustCoreBridge.DiscourseCategory] = [] + @Published private(set) var selectedTopic: RustCoreBridge.DiscourseTopicResponse? + @Published var selectedFeed: Feed = .latest + @Published var searchQuery = "" + + private let service: DiscourseCommunityService + + init(service: DiscourseCommunityService) { + self.service = service + state = service.isSignedIn ? .ready : .signedOut + service.authorizationDidComplete = { [weak self] result in + guard let self else { return } + switch result { + case .success: + Task { await self.refresh() } + case .failure(let error): + self.state = .failed(error.localizedDescription) + } + } + } + + func authorize() async { + state = .authorizing + do { + try await service.beginAuthorization() + } catch { + state = .failed(error.localizedDescription) + } + } + + func refresh() async { + state = .loading + do { + async let topicPage = service.topics(feed: selectedFeed.rawValue) + async let categoryPage = service.categories() + let (topicResult, categoryResult) = try await (topicPage, categoryPage) + topics = topicResult.topics + categories = categoryResult.categories + selectedTopic = nil + state = .ready + } catch { + state = .failed(error.localizedDescription) + } + } + + func selectTopic(_ topic: RustCoreBridge.DiscourseTopicSummary) async { + state = .loading + do { + selectedTopic = try await service.topic(id: topic.id) + state = .ready + } catch { + state = .failed(error.localizedDescription) + } + } + + func closeTopic() { + selectedTopic = nil + } + + func search() async { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + await refresh() + return + } + state = .loading + do { + let result = try await service.search(query: query) + topics = result.topics + selectedTopic = nil + state = .ready + } catch { + state = .failed(error.localizedDescription) + } + } + + func signOut() async { + do { + try await service.signOut() + topics = [] + categories = [] + selectedTopic = nil + state = .signedOut + } catch { + // The service clears Keychain after every remote revoke attempt. + topics = [] + selectedTopic = nil + state = .failed(error.localizedDescription) + } + } + + func topicURL(id: UInt64, slug: String) -> URL? { + URL(string: "\(DiscourseCommunityService.origin)/t/\(slug)/\(id)") + } + + func openTopic(id: UInt64, slug: String) { + service.openTopic(id: id, slug: slug) + } +} diff --git a/Sources/Lithe/Core/Ports/ExternalAuthorizationCallbackRouting.swift b/Sources/Lithe/Core/Ports/ExternalAuthorizationCallbackRouting.swift new file mode 100644 index 00000000..98a435e9 --- /dev/null +++ b/Sources/Lithe/Core/Ports/ExternalAuthorizationCallbackRouting.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Routes operating-system URL callbacks to an application authorization workflow. +@MainActor +protocol ExternalAuthorizationCallbackRouting: AnyObject { + func installHandler(_ handler: @escaping @MainActor (URL) -> Void) +} diff --git a/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 13888fda..c30aebfb 100644 --- a/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1555,6 +1555,119 @@ struct RustCoreBridge: Sendable { let path: String } + struct DiscourseAuthorizationStart: Decodable, Sendable { + let flowId: String + let authorizationUrl: String + let expiresAt: UInt64 + } + + struct DiscourseAuthorizationCredential: Decodable, Sendable { + let userApiKey: String + let apiVersion: UInt64 + } + + struct DiscourseTopicSummary: Decodable, Identifiable, Sendable { + let id: UInt64 + let slug: String + let title: String + let postsCount: UInt64 + let replyCount: UInt64 + let views: UInt64 + let likeCount: UInt64 + let categoryId: UInt64? + let createdAt: String? + let lastPostedAt: String? + let lastPosterUsername: String? + let pinned: Bool + let closed: Bool + let archived: Bool + } + + struct DiscoursePost: Decodable, Identifiable, Sendable { + let id: UInt64 + let postNumber: UInt64 + let username: String + let name: String? + let cooked: String + let createdAt: String? + let updatedAt: String? + let replyCount: UInt64 + let reads: UInt64 + } + + struct DiscourseTopicsResponse: Decodable, Sendable { + let topics: [DiscourseTopicSummary] + let moreTopicsUrl: String? + } + + struct DiscourseTopicResponse: Decodable, Sendable { + let id: UInt64 + let title: String + let slug: String + let posts: [DiscoursePost] + } + + struct DiscourseCategory: Decodable, Identifiable, Sendable { + let id: UInt64 + let name: String + let slug: String + let color: String? + let topicCount: UInt64 + let descriptionText: String? + } + + struct DiscourseCategoriesResponse: Decodable, Sendable { + let categories: [DiscourseCategory] + } + + struct DiscourseSearchResponse: Decodable, Sendable { + let topics: [DiscourseTopicSummary] + let posts: [DiscoursePost] + } + + private struct DiscourseAuthorizationBeginRequest: Encodable { + let origin: String + let clientId: String + let applicationName: String + let authRedirect: String + let scopes: [String] + } + + private struct DiscourseAuthorizationCompleteRequest: Encodable { + let flowId: String + let callbackUrl: String + } + + private struct DiscourseAPIRequest: Encodable { + let origin: String + let userApiKey: String + let clientId: String + } + + private struct DiscourseTopicsRequest: Encodable { + let origin: String + let userApiKey: String + let clientId: String + let feed: String + let period: String? + let page: UInt32? + } + + private struct DiscourseTopicRequest: Encodable { + let origin: String + let userApiKey: String + let clientId: String + let topicId: UInt64 + } + + private struct DiscourseSearchRequest: Encodable { + let origin: String + let userApiKey: String + let clientId: String + let query: String + let page: UInt32? + } + var isAvailable: Bool { String(cString: lithe_bridge_version()) != "unlinked" } @@ -1564,6 +1677,125 @@ struct RustCoreBridge: Sendable { return String(cString: lithe_bridge_version()) } + func beginDiscourseAuthorization( + origin: String, + clientID: String, + applicationName: String, + authRedirect: String, + scopes: [String] + ) -> Result { + executeResult( + command: "community.discourse.auth.begin", + payload: DiscourseAuthorizationBeginRequest( + origin: origin, + clientId: clientID, + applicationName: applicationName, + authRedirect: authRedirect, + scopes: scopes + ) + ) + } + + func completeDiscourseAuthorization( + flowID: String, + callbackURL: String + ) -> Result { + executeResult( + command: "community.discourse.auth.complete", + payload: DiscourseAuthorizationCompleteRequest( + flowId: flowID, + callbackUrl: callbackURL + ) + ) + } + + func discourseTopics( + origin: String, + userAPIKey: String, + clientID: String, + feed: String, + period: String? = nil, + page: UInt32? = nil + ) -> Result { + executeResult( + command: "community.discourse.topics", + payload: DiscourseTopicsRequest( + origin: origin, + userApiKey: userAPIKey, + clientId: clientID, + feed: feed, + period: period, + page: page + ) + ) + } + + func discourseTopic( + origin: String, + userAPIKey: String, + clientID: String, + topicID: UInt64 + ) -> Result { + executeResult( + command: "community.discourse.topic", + payload: DiscourseTopicRequest( + origin: origin, + userApiKey: userAPIKey, + clientId: clientID, + topicId: topicID + ) + ) + } + + func discourseCategories( + origin: String, + userAPIKey: String, + clientID: String + ) -> Result { + executeResult( + command: "community.discourse.categories", + payload: DiscourseAPIRequest( + origin: origin, + userApiKey: userAPIKey, + clientId: clientID + ) + ) + } + + func searchDiscourse( + origin: String, + userAPIKey: String, + clientID: String, + query: String, + page: UInt32? = nil + ) -> Result { + executeResult( + command: "community.discourse.search", + payload: DiscourseSearchRequest( + origin: origin, + userApiKey: userAPIKey, + clientId: clientID, + query: query, + page: page + ) + ) + } + + func revokeDiscourseAuthorization( + origin: String, + userAPIKey: String, + clientID: String + ) -> Result { + executeVoid( + command: "community.discourse.auth.revoke", + payload: DiscourseAPIRequest( + origin: origin, + userApiKey: userAPIKey, + clientId: clientID + ) + ) + } + func snapshot( at rootURL: URL, hiddenDirectoryNames: [String] = [], diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index d969c71f..e9d783d9 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -7,6 +7,7 @@ private let litheProcessLaunchDate = Date() final class LitheAppDelegate: NSObject, NSApplicationDelegate { weak var projectSessions: ProjectSessionManager? var recordCleanPluginShutdown: (() -> Void)? + var authorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true @@ -27,6 +28,10 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { Task { await projectSessions.resumeGitObservationAfterActivation() } } + func application(_ application: NSApplication, open urls: [URL]) { + urls.forEach { authorizationCallbackRouter?.route($0) } + } + static func confirmUnsavedDocuments(for projectSessions: ProjectSessionManager) -> Bool { guard projectSessions.hasUnsavedDocuments else { return true } @@ -63,6 +68,7 @@ struct LitheApp: App { let processRegistry = ManagedProcessRegistry() let moduleStore = MacModuleConfigurationStore(store: store) let pluginRuntimeRecovery = MacPluginRuntimeRecoveryCoordinator() + let authorizationCallbackRouter = MacExternalAuthorizationCallbackRouter() pluginRuntimeRecovery.recoverPreviousSession(using: moduleStore) _settings = StateObject(wrappedValue: settings) let projectSessions = ProjectSessionManager( @@ -78,7 +84,8 @@ struct LitheApp: App { ? .safeMode : .normal, moduleStore: moduleStore, - pluginRuntimeRecovery: pluginRuntimeRecovery + pluginRuntimeRecovery: pluginRuntimeRecovery, + authorizationCallbackRouter: authorizationCallbackRouter ).services ) }, @@ -99,6 +106,7 @@ struct LitheApp: App { memorySampler: MacProcessMemorySampler() )) appDelegate.projectSessions = projectSessions + appDelegate.authorizationCallbackRouter = authorizationCallbackRouter appDelegate.recordCleanPluginShutdown = { pluginRuntimeRecovery.recordCleanShutdown(using: moduleStore) } diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index f090d6a0..2e57553b 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -99,6 +99,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var isProblemsVisible = false @Published var isMavenVisible = false @Published var isDebugVisible = false + @Published var isDiscourseCommunityVisible = false @Published var isImplementationChooserVisible = false var languageProviderCatalog: LanguageProviderCatalog { languageToolingFeature.catalog } var languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot { languageToolingFeature.catalogSnapshot } @@ -133,6 +134,7 @@ final class AppModel: ObservableObject, Identifiable { let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver let workspaceFeature: WorkspaceFeatureModel let githubFeature: GitHubFeatureModel + let discourseCommunityFeature: DiscourseCommunityFeatureModel private struct CachedModuleCapability { let moduleID: ModuleID let value: AnyObject @@ -216,6 +218,9 @@ final class AppModel: ObservableObject, Identifiable { var activityBarContributions: [ModuleContribution] { activeModuleContributions.filter { $0.placement == .activityBar } } + var rightSidebarContributions: [ModuleContribution] { + activeModuleContributions.filter { $0.placement == .rightSidebar } + } var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations } func fileExists(at url: URL) -> Bool { services.fileStorage.fileExists(at: url) } var languageToolingSessionsIfActive: LanguageToolingSessionManager? { @@ -381,6 +386,7 @@ final class AppModel: ObservableObject, Identifiable { self.services = services platformUI = services.platformUI keyboardShortcutFeature = KeyboardShortcutFeatureModel(settings: settings) + discourseCommunityFeature = DiscourseCommunityFeatureModel(service: services.discourseCommunityService) workspaceFeature = WorkspaceFeatureModel( operations: services.workspaceOperations, fileOperations: services.fileOperations, diff --git a/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift b/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift new file mode 100644 index 00000000..b7ca4ac3 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift @@ -0,0 +1,277 @@ +import AppKit +import SwiftUI +import WebKit + +enum LinuxDoWebNavigationAction: Equatable { + case none + case home(UUID) + case back(UUID) + case forward(UUID) + case reload(UUID) +} + +/// Retains one guest browsing surface across short panel presentations and +/// releases it after a bounded idle period. Site cookies live in WebKit's data +/// store and outlast this in-memory view cache. +@MainActor +final class LinuxDoAnonymousWebSession: ObservableObject { + var webView: WKWebView? + private var releaseTask: Task? + private let idleLifetimeNanoseconds: UInt64 + + init(idleLifetimeNanoseconds: UInt64 = 10 * 60 * 1_000_000_000) { + self.idleLifetimeNanoseconds = idleLifetimeNanoseconds + } + + func resume() { + releaseTask?.cancel() + releaseTask = nil + } + + func releaseAfterInactivity() { + releaseTask?.cancel() + releaseTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: idleLifetimeNanoseconds) + guard !Task.isCancelled else { return } + webView?.stopLoading() + webView?.navigationDelegate = nil + webView?.uiDelegate = nil + webView = nil + releaseTask = nil + } + } + + deinit { + releaseTask?.cancel() + } + +} + +/// Hosts the public LINUX DO website in a read-only WebKit session. +/// Authentication and write-oriented routes are intentionally blocked. WebKit +/// storage remains available so Cloudflare can retain its device-verification +/// cookie instead of challenging every panel presentation. +struct LinuxDoAnonymousWebView: NSViewRepresentable { + let session: LinuxDoAnonymousWebSession + @Binding var title: String + @Binding var canGoBack: Bool + @Binding var canGoForward: Bool + @Binding var isLoading: Bool + @Binding var errorMessage: String? + let navigationAction: LinuxDoWebNavigationAction + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> WKWebView { + if let webView = session.webView { + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + context.coordinator.webView = webView + return webView + } + + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.userContentController.addUserScript(WKUserScript( + source: Self.compactReadOnlyStyle, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + )) + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsMagnification = true + webView.underPageBackgroundColor = .clear + context.coordinator.webView = webView + session.webView = webView + webView.load(URLRequest(url: Self.latestURL)) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.parent = self + context.coordinator.perform(navigationAction, in: webView) + } + + static let latestURL = URL(string: "https://linux.do/latest")! + + private static let compactReadOnlyStyle = #""" + (() => { + const style = document.createElement('style'); + style.id = 'lithe-linux-do-read-only'; + style.textContent = ` + .d-header, + .sidebar-wrapper, + .header-sidebar-toggle, + .topic-list .posters, + .topic-list .posts, + .topic-list .views, + .topic-list .activity, + .topic-list .num, + .topic-list .bulk-select, + .topic-list-header, + .topic-navigation, + .topic-map, + .timeline-container, + .post-menu-area, + .create-topic, + .reply-to-post, + .topic-footer-main-buttons, + .login-button, + .sign-up-button, + .chat-drawer-container, + .powered-by-discourse { display: none !important; } + + html, body { background: #17181c !important; } + #main-outlet-wrapper { grid-template-columns: minmax(0, 1fr) !important; } + #main-outlet { + width: auto !important; + max-width: none !important; + margin: 0 !important; + padding: 10px 12px 24px !important; + } + .topic-list { font-size: 13px !important; } + .topic-list .main-link { padding: 10px 4px !important; } + .topic-list .link-top-line { line-height: 1.35 !important; } + .topic-list .topic-excerpt { font-size: 12px !important; line-height: 1.45 !important; } + .topic-post { margin: 0 0 10px !important; } + .topic-body { width: auto !important; float: none !important; } + .cooked { font-size: 14px !important; line-height: 1.62 !important; } + img, video { max-width: 100% !important; height: auto !important; } + `; + document.getElementById(style.id)?.remove(); + document.head.appendChild(style); + })(); + """# + + @MainActor + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + var parent: LinuxDoAnonymousWebView + weak var webView: WKWebView? + private var handledAction: LinuxDoWebNavigationAction = .none + + init(parent: LinuxDoAnonymousWebView) { + self.parent = parent + } + + func perform(_ action: LinuxDoWebNavigationAction, in webView: WKWebView) { + guard action != handledAction else { return } + handledAction = action + switch action { + case .none: + break + case .home: + webView.load(URLRequest(url: LinuxDoAnonymousWebView.latestURL)) + case .back: + if webView.canGoBack { webView.goBack() } + case .forward: + if webView.canGoForward { webView.goForward() } + case .reload: + webView.reload() + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + parent.isLoading = true + parent.errorMessage = nil + publishNavigationState(webView) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + parent.isLoading = false + parent.title = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty ?? "LINUX DO" + publishNavigationState(webView) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { + publishFailure(error, in: webView) + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: any Error + ) { + publishFailure(error, in: webView) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + if url.scheme == "about" { + decisionHandler(.allow) + return + } + guard url.scheme?.lowercased() == "https", + url.host?.lowercased() == "linux.do" else { + if navigationAction.navigationType == .linkActivated { + NSWorkspace.shared.open(url) + } + decisionHandler(.cancel) + return + } + if Self.isAuthenticationOrWriteRoute(url.path) { + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if navigationAction.targetFrame == nil, + let url = navigationAction.request.url, + url.host?.lowercased() == "linux.do", + !Self.isAuthenticationOrWriteRoute(url.path) { + webView.load(navigationAction.request) + } + return nil + } + + private func publishFailure(_ error: any Error, in webView: WKWebView) { + parent.isLoading = false + if (error as NSError).code != NSURLErrorCancelled { + parent.errorMessage = error.localizedDescription + } + publishNavigationState(webView) + } + + private func publishNavigationState(_ webView: WKWebView) { + parent.canGoBack = webView.canGoBack + parent.canGoForward = webView.canGoForward + } + + private static func isAuthenticationOrWriteRoute(_ path: String) -> Bool { + let normalized = path.lowercased() + return normalized == "/login" + || normalized == "/signup" + || normalized.hasPrefix("/session") + || normalized.hasPrefix("/user-api-key") + || normalized.hasPrefix("/new-topic") + } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/Lithe/Platform/MacOS/Community/MacExternalAuthorizationCallbackRouter.swift b/Sources/Lithe/Platform/MacOS/Community/MacExternalAuthorizationCallbackRouter.swift new file mode 100644 index 00000000..a3bdb4bd --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Community/MacExternalAuthorizationCallbackRouter.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Receives the app's custom URL scheme and retains an early callback until a +/// community authorization workflow installs its handler. +@MainActor +final class MacExternalAuthorizationCallbackRouter: ExternalAuthorizationCallbackRouting { + private var handlers: [@MainActor (URL) -> Void] = [] + private var pendingURL: URL? + + func installHandler(_ handler: @escaping @MainActor (URL) -> Void) { + handlers.append(handler) + guard let pendingURL else { return } + self.pendingURL = nil + handler(pendingURL) + } + + func route(_ url: URL) { + guard url.scheme?.lowercased() == "lithe", + url.host?.lowercased() == "auth", + url.path == "/linux-do" else { return } + guard !handlers.isEmpty else { + pendingURL = url + return + } + handlers.forEach { $0(url) } + } +} diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 3f5f82db..a897138a 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -44,8 +44,11 @@ final class MacServiceContainer { processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(), moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, - pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil + pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, + authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { + let authorizationCallbackRouter = providedAuthorizationCallbackRouter + ?? MacExternalAuthorizationCallbackRouter() let rustCore = RustCoreBridge() let javaMavenOperations = RustJavaMavenOperations(core: rustCore) let fileStorage = MacFileStorage() @@ -69,6 +72,13 @@ final class MacServiceContainer { secureStore: MacKeychainSecureStore(service: "app.lithe.desktop.github"), git: MacGitHubGitOperations(core: rustCore) ) + let platformUI = MacPlatformUI() + let discourseCommunityService = DiscourseCommunityService( + core: rustCore, + credentialStore: MacKeychainSecureStore(service: "app.lithe.desktop.linux-do"), + platformUI: platformUI, + callbackRouter: authorizationCallbackRouter + ) let codexConfigurationSource = MacCodexConfigurationSource() let claudeConfigurationSource = MacClaudeConfigurationSource() let aiConfigurationSources: [any AIConfigurationSource] = [ @@ -425,13 +435,14 @@ final class MacServiceContainer { githubService: githubService, secureStore: secureStore, databaseSecureStore: databaseSecureStore, + discourseCommunityService: discourseCommunityService, credentialResolver: credentialResolver, aiConfigurationSources: aiConfigurationSources, recentProjectsStore: RecentProjectsStore(store: store), workspaceSessionStore: WorkspaceSessionStore(store: store), workbenchLayoutStore: WorkbenchLayoutStore(store: store), directoryWatcherFactory: MacDirectoryWatcherFactory(), - platformUI: MacPlatformUI(), + platformUI: platformUI, shortcutDetectorFactory: MacShortcutDetectorFactory() ) moduleLifecycleCoordinator.start() diff --git a/Sources/Lithe/Services/Community/DiscourseCommunityService.swift b/Sources/Lithe/Services/Community/DiscourseCommunityService.swift new file mode 100644 index 00000000..9b0378a2 --- /dev/null +++ b/Sources/Lithe/Services/Community/DiscourseCommunityService.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Orchestrates the Rust-owned Discourse protocol with native browser and +/// credential adapters. It never performs or decodes an HTTP request itself. +@MainActor +final class DiscourseCommunityService { + enum ServiceError: LocalizedError { + case missingCredential + case invalidAuthorizationURL + case core(RustCoreBridge.CoreCallError) + case credentialStore(String) + + var errorDescription: String? { + switch self { + case .missingCredential: + "Log in to LINUX DO before loading posts." + case .invalidAuthorizationURL: + "LINUX DO returned an invalid authorization URL." + case .core(let error): + error.userMessage + case .credentialStore(let message): + "Could not update the macOS Keychain: \(message)" + } + } + } + + static let origin = "https://linux.do" + static let clientID = "app.lithe.desktop.linux-do.v1" + static let authorizationRedirect = "lithe://auth/linux-do" + static let credentialKey = "user-api-key" + + private let core: RustCoreBridge + private let credentialStore: any SecureStore + private let platformUI: any PlatformUI + private let callbackRouter: any ExternalAuthorizationCallbackRouting + private var authorizationFlowID: String? + var authorizationDidComplete: ((Result) -> Void)? + + init( + core: RustCoreBridge, + credentialStore: any SecureStore, + platformUI: any PlatformUI, + callbackRouter: any ExternalAuthorizationCallbackRouting + ) { + self.core = core + self.credentialStore = credentialStore + self.platformUI = platformUI + self.callbackRouter = callbackRouter + callbackRouter.installHandler { [weak self] url in + guard let self else { return } + Task { await self.completeAuthorization(callbackURL: url) } + } + } + + var isSignedIn: Bool { + credentialStore.read(key: Self.credentialKey) != nil + } + + func beginAuthorization() async throws { + let origin = Self.origin + let clientID = Self.clientID + let redirect = Self.authorizationRedirect + let result = await Task.detached { [core] in + core.beginDiscourseAuthorization( + origin: origin, + clientID: clientID, + applicationName: "Lithe for LINUX DO", + authRedirect: redirect, + scopes: ["read", "session_info"] + ) + }.value + let start = try result.mapError(ServiceError.core).get() + guard let url = URL(string: start.authorizationUrl) else { + throw ServiceError.invalidAuthorizationURL + } + authorizationFlowID = start.flowId + platformUI.open(url) + } + + func topics(feed: String, period: String? = nil) async throws -> RustCoreBridge.DiscourseTopicsResponse { + let key = try credential() + let origin = Self.origin + let clientID = Self.clientID + return try await Task.detached { [core] in + core.discourseTopics( + origin: origin, + userAPIKey: key, + clientID: clientID, + feed: feed, + period: period + ) + }.value.mapError(ServiceError.core).get() + } + + func topic(id: UInt64) async throws -> RustCoreBridge.DiscourseTopicResponse { + let key = try credential() + let origin = Self.origin + let clientID = Self.clientID + return try await Task.detached { [core] in + core.discourseTopic( + origin: origin, + userAPIKey: key, + clientID: clientID, + topicID: id + ) + }.value.mapError(ServiceError.core).get() + } + + func categories() async throws -> RustCoreBridge.DiscourseCategoriesResponse { + let key = try credential() + let origin = Self.origin + let clientID = Self.clientID + return try await Task.detached { [core] in + core.discourseCategories( + origin: origin, + userAPIKey: key, + clientID: clientID + ) + }.value.mapError(ServiceError.core).get() + } + + func search(query: String) async throws -> RustCoreBridge.DiscourseSearchResponse { + let key = try credential() + let origin = Self.origin + let clientID = Self.clientID + return try await Task.detached { [core] in + core.searchDiscourse( + origin: origin, + userAPIKey: key, + clientID: clientID, + query: query + ) + }.value.mapError(ServiceError.core).get() + } + + func openTopic(id: UInt64, slug: String) { + guard let url = URL(string: "\(Self.origin)/t/\(slug)/\(id)") else { return } + platformUI.open(url) + } + + /// Local deletion is attempted regardless of the remote response so a + /// failed revoke never leaves the app silently authenticated. + func signOut() async throws { + let key = try credential() + let origin = Self.origin + let clientID = Self.clientID + let remoteResult = await Task.detached { [core] in + core.revokeDiscourseAuthorization( + origin: origin, + userAPIKey: key, + clientID: clientID + ) + }.value + do { + try credentialStore.delete(key: Self.credentialKey) + } catch { + throw ServiceError.credentialStore(error.localizedDescription) + } + try remoteResult.mapError(ServiceError.core).get() + } + + private func completeAuthorization(callbackURL: URL) async { + guard let flowID = authorizationFlowID else { return } + authorizationFlowID = nil + let result = await Task.detached { [core] in + core.completeDiscourseAuthorization(flowID: flowID, callbackURL: callbackURL.absoluteString) + }.value + do { + let credential = try result.mapError(ServiceError.core).get() + try credentialStore.write(credential.userApiKey, key: Self.credentialKey) + authorizationDidComplete?(.success(())) + } catch { + authorizationDidComplete?(.failure(error)) + } + } + + private func credential() throws -> String { + guard let value = credentialStore.read(key: Self.credentialKey), !value.isEmpty else { + throw ServiceError.missingCredential + } + return value + } +} diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift index c69d34aa..1a208846 100644 --- a/Sources/Lithe/Theme/LitheTheme.swift +++ b/Sources/Lithe/Theme/LitheTheme.swift @@ -533,11 +533,29 @@ extension View { modifier(LitheSearchFieldStyle(isFocused: isFocused, height: height)) } + /// Paints rounded control chrome without clipping AppKit-backed content. + /// + /// SwiftUI represents controls such as `TextEditor`, `TextField`, and the + /// macOS checkbox with native AppKit views. Applying a mask or clip to one + /// of their ancestors can replace those views with the yellow unavailable + /// placeholder. Keep rounding in the background and border layers instead. + func litheRoundedControlBackground( + _ color: Color, + cornerRadius: CGFloat = LitheTheme.Metrics.controlCornerRadius + ) -> some View { + background { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(color) + } + } + /// 浮层统一外观:圆角、背景、1pt 边框和投影。 func lithePopupChrome(cornerRadius: CGFloat = LitheTheme.Metrics.popupCornerRadius) -> some View { self - .background(LitheTheme.popupBackground) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .litheRoundedControlBackground( + LitheTheme.popupBackground, + cornerRadius: cornerRadius + ) .overlay { RoundedRectangle(cornerRadius: cornerRadius) .stroke(LitheTheme.panelBorder, lineWidth: 1) diff --git a/Sources/Lithe/Views/App/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift index 2ce38fa8..bddd826f 100644 --- a/Sources/Lithe/Views/App/SettingsView.swift +++ b/Sources/Lithe/Views/App/SettingsView.swift @@ -239,8 +239,7 @@ struct SettingsView: View { .font(.system(size: 12, design: .monospaced)) .frame(height: 66) .padding(5) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheRoundedControlBackground(LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) .stroke(LitheTheme.inputBorder, lineWidth: 1) @@ -252,8 +251,7 @@ struct SettingsView: View { .font(.system(size: 12, design: .monospaced)) .frame(height: 52) .padding(5) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheRoundedControlBackground(LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) .stroke(LitheTheme.inputBorder, lineWidth: 1) @@ -542,8 +540,7 @@ struct SettingsView: View { .font(.system(size: 12, design: .monospaced)) .frame(height: 92) .padding(5) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheRoundedControlBackground(LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) .stroke(LitheTheme.inputBorder, lineWidth: 1) diff --git a/Sources/Lithe/Views/Community/LinuxDoCommunityFormatting.swift b/Sources/Lithe/Views/Community/LinuxDoCommunityFormatting.swift new file mode 100644 index 00000000..7ad12ef5 --- /dev/null +++ b/Sources/Lithe/Views/Community/LinuxDoCommunityFormatting.swift @@ -0,0 +1,46 @@ +import AppKit +import Foundation + +enum LinuxDoCommunityFormatting { + static func compactNumber(_ value: UInt64) -> String { + switch value { + case 1_000_000...: + compact(Double(value) / 1_000_000, suffix: "M") + case 1_000...: + compact(Double(value) / 1_000, suffix: "K") + default: + String(value) + } + } + + static func relativeDate(_ value: String?) -> String? { + guard let value, let date = ISO8601DateFormatter().date(from: value) else { return nil } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + + static func initials(_ value: String) -> String { + let parts = value.split(whereSeparator: \.isWhitespace) + let characters = parts.prefix(2).compactMap(\.first) + if characters.isEmpty { + return String(value.prefix(1)).uppercased() + } + return String(characters).uppercased() + } + + static func plainText(_ sanitizedHTML: String) -> String { + guard let data = sanitizedHTML.data(using: .utf8), + let value = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil + ) else { return sanitizedHTML } + return value.string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func compact(_ value: Double, suffix: String) -> String { + let format = value >= 10 ? "%.0f%@" : "%.1f%@" + return String(format: format, value, suffix) + } +} diff --git a/Sources/Lithe/Views/Community/LinuxDoCommunityView.swift b/Sources/Lithe/Views/Community/LinuxDoCommunityView.swift new file mode 100644 index 00000000..7edaef2b --- /dev/null +++ b/Sources/Lithe/Views/Community/LinuxDoCommunityView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct LinuxDoCommunityView: View { + @EnvironmentObject private var webSession: LinuxDoAnonymousWebSession + @State private var pageTitle = "LINUX DO" + @State private var canGoBack = false + @State private var canGoForward = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var navigationAction: LinuxDoWebNavigationAction = .none + + var body: some View { + VStack(spacing: 0) { + header + if let errorMessage { + failureView(errorMessage) + } else { + LinuxDoAnonymousWebView( + session: webSession, + title: $pageTitle, + canGoBack: $canGoBack, + canGoForward: $canGoForward, + isLoading: $isLoading, + errorMessage: $errorMessage, + navigationAction: navigationAction + ) + } + } + .background(LitheTheme.sidebar) + .onAppear { webSession.resume() } + .onDisappear { webSession.releaseAfterInactivity() } + } + + private var header: some View { + LitheToolWindowHeader( + title: pageTitle, + systemImage: "bubble.left.and.bubble.right", + subtitle: "Guest" + ) { + Button { navigationAction = .home(UUID()) } label: { + Label("Topics", systemImage: "list.bullet").labelStyle(.iconOnly) + } + .litheIconButton() + .help("Latest topics") + + Button { navigationAction = .reload(UUID()) } label: { + if isLoading { + ProgressView().controlSize(.small) + } else { + Label("Reload", systemImage: "arrow.clockwise").labelStyle(.iconOnly) + } + } + .litheIconButton() + .help("Reload") + } + } + + private func failureView(_ message: String) -> some View { + VStack(spacing: 14) { + Spacer() + Image(systemName: "wifi.exclamationmark") + .font(.title) + .foregroundStyle(LitheTheme.warning) + Text("Couldn’t load LINUX DO") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .lineSpacing(3) + Button("Try Again") { + errorMessage = nil + navigationAction = .reload(UUID()) + } + .buttonStyle(.borderedProminent) + Spacer() + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Sources/Lithe/Views/Community/LinuxDoTopicDetailView.swift b/Sources/Lithe/Views/Community/LinuxDoTopicDetailView.swift new file mode 100644 index 00000000..86dcd73c --- /dev/null +++ b/Sources/Lithe/Views/Community/LinuxDoTopicDetailView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct LinuxDoTopicDetailView: View { + let topic: RustCoreBridge.DiscourseTopicResponse + @ObservedObject var feature: DiscourseCommunityFeatureModel + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + topicHeader + ForEach(topic.posts) { post in + LinuxDoPostView(post: post) + } + } + .padding(12) + } + .background(LitheTheme.sidebar) + } + + private var topicHeader: some View { + VStack(alignment: .leading, spacing: 11) { + Text(topic.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Label("\(topic.posts.count) posts", systemImage: "text.bubble") + .font(.caption) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + Button { + feature.openTopic(id: topic.id, slug: topic.slug) + } label: { + Label("Open in Browser", systemImage: "arrow.up.right") + } + .buttonStyle(.borderless) + .help("Open this topic on linux.do") + } + } + .padding(.bottom, 2) + } +} + +private struct LinuxDoPostView: View { + let post: RustCoreBridge.DiscoursePost + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 9) { + Text(LinuxDoCommunityFormatting.initials(post.name ?? post.username)) + .font(.caption2.weight(.bold)) + .foregroundStyle(LitheTheme.accent) + .frame(width: 28, height: 28) + .background(LitheTheme.subtleSelection) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 1) { + Text(post.name ?? post.username) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(LitheTheme.primaryText) + HStack(spacing: 4) { + Text("@\(post.username)") + if let date = LinuxDoCommunityFormatting.relativeDate(post.createdAt) { + Text("·") + Text(date) + } + } + .font(.caption2) + .foregroundStyle(LitheTheme.tertiaryText) + } + Spacer() + Text("#\(post.postNumber)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(LitheTheme.tertiaryText) + } + + Text(LinuxDoCommunityFormatting.plainText(post.cooked)) + .font(.body) + .foregroundStyle(LitheTheme.primaryText) + .lineSpacing(4) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + if post.replyCount > 0 || post.reads > 0 { + HStack(spacing: 12) { + if post.replyCount > 0 { + Label("\(post.replyCount)", systemImage: "arrowshape.turn.up.left") + .accessibilityLabel("\(post.replyCount) replies") + } + if post.reads > 0 { + Label(LinuxDoCommunityFormatting.compactNumber(post.reads), systemImage: "eye") + .accessibilityLabel("\(post.reads) reads") + } + } + .font(.caption2) + .foregroundStyle(LitheTheme.tertiaryText) + } + } + .padding(12) + .background(LitheTheme.editor) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(LitheTheme.panelBorder, lineWidth: 0.5) + } + } +} diff --git a/Sources/Lithe/Views/Community/LinuxDoTopicListView.swift b/Sources/Lithe/Views/Community/LinuxDoTopicListView.swift new file mode 100644 index 00000000..7f172439 --- /dev/null +++ b/Sources/Lithe/Views/Community/LinuxDoTopicListView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct LinuxDoTopicListView: View { + @ObservedObject var feature: DiscourseCommunityFeatureModel + @FocusState private var searchIsFocused: Bool + + var body: some View { + VStack(spacing: 0) { + controls + Rectangle().fill(LitheTheme.divider).frame(height: 1) + if feature.topics.isEmpty { + emptyState + } else { + topicList + } + } + } + + private var controls: some View { + VStack(spacing: 10) { + Picker("Topic feed", selection: $feature.selectedFeed) { + Label("Latest", systemImage: "clock").tag(DiscourseCommunityFeatureModel.Feed.latest) + Label("Top", systemImage: "flame").tag(DiscourseCommunityFeatureModel.Feed.top) + } + .pickerStyle(.segmented) + .labelsHidden() + .onChange(of: feature.selectedFeed) { _ in + Task { await feature.refresh() } + } + + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .foregroundStyle(searchIsFocused ? LitheTheme.accent : LitheTheme.tertiaryText) + TextField("Search discussions", text: $feature.searchQuery) + .textFieldStyle(.plain) + .focused($searchIsFocused) + .onSubmit { Task { await feature.search() } } + if !feature.searchQuery.isEmpty { + Button { + feature.searchQuery = "" + Task { await feature.refresh() } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(LitheTheme.tertiaryText) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(LitheTheme.inputBackground) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(searchIsFocused ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder) + } + } + .padding(10) + .background(LitheTheme.toolHeader) + } + + private var topicList: some View { + List(feature.topics) { topic in + Button { + Task { await feature.selectTopic(topic) } + } label: { + LinuxDoTopicRow( + topic: topic, + categoryName: categoryName(for: topic.categoryId) + ) + } + .buttonStyle(.plain) + .lithePointer() + .listRowInsets(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .accessibilityHint("Opens the topic") + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(LitheTheme.sidebar) + } + + private var emptyState: some View { + VStack(spacing: 10) { + Spacer() + Image(systemName: feature.searchQuery.isEmpty ? "tray" : "magnifyingglass") + .font(.title2) + .foregroundStyle(LitheTheme.tertiaryText) + Text(feature.searchQuery.isEmpty ? "No topics yet" : "No matching discussions") + .font(.headline) + Text(feature.searchQuery.isEmpty + ? "Refresh to check for new discussions." + : "Try a broader phrase or clear the search.") + .font(.subheadline) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + if !feature.searchQuery.isEmpty { + Button("Clear Search") { + feature.searchQuery = "" + Task { await feature.refresh() } + } + .buttonStyle(.bordered) + } + Spacer() + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func categoryName(for id: UInt64?) -> String? { + guard let id else { return nil } + return feature.categories.first(where: { $0.id == id })?.name + } +} + +private struct LinuxDoTopicRow: View { + let topic: RustCoreBridge.DiscourseTopicSummary + let categoryName: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if topic.pinned { + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(LitheTheme.accent) + .accessibilityLabel("Pinned") + } + Text(topic.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(LitheTheme.primaryText) + .multilineTextAlignment(.leading) + .lineLimit(2) + Spacer(minLength: 0) + } + + HStack(spacing: 7) { + if let categoryName { + Text(categoryName) + .font(.caption2.weight(.medium)) + .foregroundStyle(LitheTheme.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(LitheTheme.subtleSelection) + .clipShape(Capsule()) + .lineLimit(1) + } + if let date = LinuxDoCommunityFormatting.relativeDate(topic.lastPostedAt) { + if categoryName != nil { + Text("·") + .font(.caption) + .foregroundStyle(LitheTheme.tertiaryText) + } + Text(date) + .font(.caption) + .foregroundStyle(LitheTheme.tertiaryText) + } + Spacer(minLength: 0) + } + + HStack(spacing: 12) { + metadata("bubble.left", topic.replyCount, label: "replies") + metadata("eye", topic.views, label: "views") + Spacer(minLength: 0) + } + } + .padding(10) + .background(LitheTheme.editor) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay { + RoundedRectangle(cornerRadius: 9) + .stroke(LitheTheme.panelBorder, lineWidth: 0.5) + } + .litheRowHover(isActive: false, cornerRadius: 9) + } + + private func metadata(_ icon: String, _ value: UInt64, label: String) -> some View { + Label(LinuxDoCommunityFormatting.compactNumber(value), systemImage: icon) + .font(.caption2) + .foregroundStyle(LitheTheme.tertiaryText) + .labelStyle(.titleAndIcon) + .accessibilityLabel("\(value) \(label)") + } +} diff --git a/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift b/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift index 61a493e8..f9614871 100644 --- a/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift +++ b/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift @@ -262,8 +262,7 @@ struct RedisWorkspaceView: View { .font(.system(size: 12, design: .monospaced)) .scrollContentBackground(.hidden) .padding(7).frame(minHeight: 230) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheRoundedControlBackground(LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius).stroke(LitheTheme.panelBorder, lineWidth: 1) } HStack { Text("Saving preserves the existing TTL unless you set one above.") @@ -284,8 +283,7 @@ struct RedisWorkspaceView: View { .font(.system(size: 12, design: .monospaced)) .scrollContentBackground(.hidden) .padding(7).frame(minHeight: 230) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheRoundedControlBackground(LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius).stroke(LitheTheme.panelBorder, lineWidth: 1) } HStack { Spacer() diff --git a/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/Sources/Lithe/Views/Git/ChangesSidebarView.swift index f9bfede6..c5f27bf0 100644 --- a/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -214,8 +214,7 @@ struct ChangesSidebarView: View { .font(.system(size: 11.5)) .padding(.horizontal, 7) .frame(height: 27) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .litheRoundedControlBackground(LitheTheme.inputBackground, cornerRadius: 4) Toggle("Untracked", isOn: $includeUntracked) .toggleStyle(.checkbox) @@ -748,8 +747,7 @@ struct ChangesSidebarView: View { } } .frame(maxWidth: .infinity, minHeight: 50, maxHeight: .infinity, alignment: .topLeading) - .background(LitheTheme.editor) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .litheRoundedControlBackground(LitheTheme.editor, cornerRadius: 4) .overlay { RoundedRectangle(cornerRadius: 4) .stroke(LitheTheme.divider, lineWidth: 1) diff --git a/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift index b91c945a..b031f2c7 100644 --- a/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift +++ b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift @@ -213,8 +213,7 @@ struct RunConfigurationEditorView: View { .font(.system(size: 11.5, design: .monospaced)) .frame(minHeight: 72) .padding(5) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: 5)) + .litheRoundedControlBackground(LitheTheme.inputBackground, cornerRadius: 5) .overlay { RoundedRectangle(cornerRadius: 5) .stroke(LitheTheme.divider, lineWidth: 1) diff --git a/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 369921b4..f16c1314 100644 --- a/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheModuleAPI import LitheDebugModule import LitheExecutionModule import LitheGitModule @@ -14,7 +15,8 @@ enum WorkbenchModuleUIComposition { gitRegistration, languageRegistration, executionRegistration, - debugRegistration + debugRegistration, + communityRegistration ]) } catch { preconditionFailure("Invalid built-in module UI registration: \(error)") @@ -149,4 +151,30 @@ enum WorkbenchModuleUIComposition { ) ] ) + + private static let communityRegistration: WorkbenchModuleUIRegistry.Registration = { + let moduleID = OfficialPluginCatalog.linuxDoSupportModuleID + let contributions = OfficialPluginCatalog.manifest(forModule: moduleID)? + .modules.first(where: { $0.manifest.id == moduleID })? + .contributions ?? [] + return WorkbenchModuleUIRegistry.Registration( + contributions: contributions, + actions: [ + .init(id: "community.linux-do.toggle", perform: { + $0.isDiscourseCommunityVisible.toggle() + }) + ], + renderers: [ + .init( + id: "community.linux-do.browser", + ideaAssetPath: nil, + isVisible: { _ in true }, + isSelected: { $0.isDiscourseCommunityVisible }, + content: { _ in + AnyView(LinuxDoCommunityView()) + } + ) + ] + ) + }() } diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 3403e5e6..945fc4b7 100644 --- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -16,7 +16,13 @@ struct WorkbenchView: View { @EnvironmentObject private var projectSessions: ProjectSessionManager @EnvironmentObject private var settings: AppSettings @EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @StateObject private var linuxDoWebSession = LinuxDoAnonymousWebSession() @State private var sidebarWidth: CGFloat = 320 + @State private var rightSidebarWidth: CGFloat = 380 + @State private var hoveredRightSidebarContributionID: String? + @State private var isRightSidebarPanelHovered = false + @State private var rightSidebarDismissTask: Task? @State private var topPaneHeight: CGFloat? @State private var isBranchSwitcherPresented = false @State private var newBranchReference: GitReference? @@ -41,9 +47,12 @@ struct WorkbenchView: View { HStack(spacing: 0) { activityBar workspaceArea - pluginActivityBar + Color.clear.frame(width: ActivityBarMetrics.width) } .frame(maxHeight: .infinity) + .overlay(alignment: .trailing) { + rightHoverRegion + } Rectangle().fill(LitheTheme.divider).frame(height: 1) statusBar @@ -559,6 +568,37 @@ struct WorkbenchView: View { private var pluginActivityBar: some View { VStack { + ForEach(model.rightSidebarContributions) { contribution in + if let renderer = moduleUIRegistry.renderer(for: contribution), + renderer.isVisible(model) { + Button { moduleUIRegistry.perform(contribution, model: model) } label: { + Image(systemName: contribution.icon ?? "rectangle.rightthird.inset.filled") + .frame(width: ActivityBarMetrics.buttonWidth, height: ActivityBarMetrics.buttonHeight) + .litheRowHover( + isActive: renderer.isSelected(model), + cornerRadius: 4, + activeBackground: LitheTheme.subtleSelection + ) + } + .buttonStyle(.plain) + .lithePointer() + .foregroundStyle(renderer.isSelected(model) ? LitheTheme.primaryText : LitheTheme.secondaryText) + .help(contribution.title) + .accessibilityLabel(contribution.title) + .onHover { isHovering in + if isHovering { + rightSidebarDismissTask?.cancel() + hoveredRightSidebarContributionID = contribution.id + if !renderer.isSelected(model) { + moduleUIRegistry.perform(contribution, model: model) + } + } else { + hoveredRightSidebarContributionID = nil + scheduleRightSidebarDismissal() + } + } + } + } Button { isPluginPanelPresented.toggle() } label: { Image(systemName: "puzzlepiece.extension") .frame(width: ActivityBarMetrics.buttonWidth, height: ActivityBarMetrics.buttonHeight) @@ -575,6 +615,62 @@ struct WorkbenchView: View { .background(LitheTheme.titlebar) } + private var rightHoverRegion: some View { + HStack(spacing: 0) { + if isRightSidebarVisible { + moduleUIRegistry.selectedToolContent( + from: model.rightSidebarContributions, + model: model + ) + .environmentObject(linuxDoWebSession) + .frame(width: rightSidebarWidth) + .background(LitheTheme.sidebar) + .overlay(alignment: .leading) { + Rectangle().fill(LitheTheme.panelBorder).frame(width: 1) + } + .shadow(color: LitheTheme.popupShadow, radius: 14, x: -5, y: 0) + .transition( + reduceMotion + ? .opacity + : .move(edge: .trailing).combined(with: .opacity) + ) + .onHover { isHovering in + isRightSidebarPanelHovered = isHovering + if isHovering { + rightSidebarDismissTask?.cancel() + } else { + scheduleRightSidebarDismissal() + } + } + } + pluginActivityBar + } + .fixedSize(horizontal: true, vertical: false) + .animation( + reduceMotion ? nil : .easeOut(duration: 0.14), + value: isRightSidebarVisible + ) + } + + private func scheduleRightSidebarDismissal() { + rightSidebarDismissTask?.cancel() + rightSidebarDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 60_000_000) + guard !Task.isCancelled, + hoveredRightSidebarContributionID == nil, + !isRightSidebarPanelHovered else { return } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.10)) { + model.isDiscourseCommunityVisible = false + } + } + } + + private var isRightSidebarVisible: Bool { + model.rightSidebarContributions.contains { contribution in + moduleUIRegistry.renderer(for: contribution)?.isSelected(model) == true + } + } + private var runConfigurationSetupTitle: String { switch model.runFeatureIfActive?.configurationStatus ?? .missing { case .missing: @@ -713,8 +809,10 @@ struct WorkbenchView: View { } } } - .background(LitheTheme.sidebar) - .clipShape(RoundedRectangle(cornerRadius: 10)) + // Do not clip this container: Changes, Search, and Database sidebars + // contain AppKit-backed controls that SwiftUI cannot composite through + // a mask without showing its yellow unavailable placeholder. + .litheRoundedControlBackground(LitheTheme.sidebar, cornerRadius: 10) } private var isBottomToolVisible: Bool { @@ -1066,7 +1164,6 @@ private struct WorkbenchWorkspaceSplitView [ModuleCapabilityID: AnyObject] { [:] } + public func contributions() -> [ModuleContribution] { Self.declaration.contributions } +} diff --git a/Sources/LitheLinuxDoSupportModule/Plugin/LinuxDoSupportPluginEntrypoint.swift b/Sources/LitheLinuxDoSupportModule/Plugin/LinuxDoSupportPluginEntrypoint.swift new file mode 100644 index 00000000..093dbdfb --- /dev/null +++ b/Sources/LitheLinuxDoSupportModule/Plugin/LinuxDoSupportPluginEntrypoint.swift @@ -0,0 +1,17 @@ +import Foundation +import LitheModuleAPI + +@MainActor +@objc(LitheLinuxDoSupportPluginEntrypoint) +public final class LinuxDoSupportPluginEntrypoint: NSObject, LithePluginEntrypoint { + public override init() { super.init() } + + public func moduleFactories(context: PluginHostContext) throws -> [ModuleFactory] { + [ModuleFactory( + manifest: LinuxDoSupportModule.declaration.manifest, + contributions: LinuxDoSupportModule.declaration.contributions + ) { + LinuxDoSupportModule() + }] + } +} diff --git a/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 5ef7a1c2..23882e01 100644 --- a/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -196,6 +196,7 @@ public enum BuiltInPluginCatalog { /// module graph and become available only when their signed package exists. public enum OfficialPluginCatalog { private static let goLanguageID = "go" + public static let linuxDoSupportModuleID = ModuleID("dev.lithe.community.linux-do") public static let manifests: [PluginManifest] = [ PluginManifest( @@ -247,6 +248,43 @@ public enum OfficialPluginCatalog { executionModuleID: .languageExecutionExtension(goLanguageID), testingModuleID: .languageExecutionExtension(goLanguageID) )] + ), + PluginManifest( + id: PluginID("dev.lithe.plugin.linux-do-support"), + displayName: "LINUX DO Support", + version: BuiltInPluginCatalog.hostVersion, + hostCompatibility: PluginHostCompatibility( + minimum: BuiltInPluginCatalog.hostVersion, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: BuiltInPluginCatalog.vendor, + entrypoint: PluginEntrypoint( + kind: .nativeBundle, + bundleIdentifier: "dev.lithe.plugin.linux-do-support.bundle", + principalClass: "LitheLinuxDoSupportPluginEntrypoint", + bundlePath: "LinuxDoSupport.bundle" + ), + modules: [ + PluginModuleDeclaration( + manifest: ModuleManifest( + id: linuxDoSupportModuleID, + displayName: "LINUX DO", + scope: .application, + defaultState: .disabled, + activationPolicy: .onDemand + ), + contributions: [ModuleContribution( + id: "community.linux-do", + kind: .toolWindow, + title: "LINUX DO", + icon: "bubble.left.and.bubble.right", + placement: .rightSidebar, + order: 100, + actionID: "community.linux-do.toggle", + rendererID: "community.linux-do.browser" + )] + ) + ] ) ] diff --git a/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 7d1c8112..75fdd2fd 100644 --- a/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -182,6 +182,7 @@ public enum ModuleContributionKind: String, Codable, Sendable { public enum ModuleContributionPlacement: String, Codable, Sendable { case activityBar + case rightSidebar case toolWindow case commandPalette case settings diff --git a/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift b/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift new file mode 100644 index 00000000..f89dfcad --- /dev/null +++ b/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift @@ -0,0 +1,33 @@ +import WebKit +@testable import Lithe +import Testing + +@MainActor +struct LinuxDoAnonymousWebSessionTests { + @Test + func shortPanelAbsenceKeepsTheCurrentWebView() async throws { + let session = LinuxDoAnonymousWebSession(idleLifetimeNanoseconds: 50_000_000) + let webView = WKWebView() + session.webView = webView + + session.releaseAfterInactivity() + session.resume() + try await Task.sleep(nanoseconds: 80_000_000) + + #expect(session.webView === webView) + } + + @Test + func inactiveSessionReleasesItsWebView() async throws { + let session = LinuxDoAnonymousWebSession(idleLifetimeNanoseconds: 20_000_000) + session.webView = WKWebView() + + session.releaseAfterInactivity() + let deadline = ContinuousClock.now + .seconds(1) + while session.webView != nil, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(session.webView == nil) + } +} diff --git a/Tests/LitheTests/LinuxDoCommunityFormattingTests.swift b/Tests/LitheTests/LinuxDoCommunityFormattingTests.swift new file mode 100644 index 00000000..62291c62 --- /dev/null +++ b/Tests/LitheTests/LinuxDoCommunityFormattingTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import Lithe + +struct LinuxDoCommunityFormattingTests { + @Test + func formatsForumMetadataForCompactRows() { + #expect(LinuxDoCommunityFormatting.compactNumber(999) == "999") + #expect(LinuxDoCommunityFormatting.compactNumber(1_250) == "1.2K") + #expect(LinuxDoCommunityFormatting.compactNumber(12_500) == "12K") + #expect(LinuxDoCommunityFormatting.compactNumber(2_400_000) == "2.4M") + } + + @Test + func derivesReadableAvatarInitials() { + #expect(LinuxDoCommunityFormatting.initials("Ada Lovelace") == "AL") + #expect(LinuxDoCommunityFormatting.initials("linuxdo") == "L") + } + + @Test + func projectsSanitizedHTMLIntoReadableNativeText() { + let value = LinuxDoCommunityFormatting.plainText("

Hello community

") + #expect(value == "Hello community") + } +} diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 280ef9c1..12fb20ba 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -41,6 +41,19 @@ struct LitheCoreLogicTests { #expect(appDelegate.applicationShouldTerminateAfterLastWindowClosed(NSApplication.shared)) } + @Test + func workbenchKeepsAppKitBackedControlsOutOfDrawingGroups() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceURL = repositoryRoot + .appendingPathComponent("Sources/Lithe/Views/Workbench/WorkbenchView.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + #expect(!source.contains(".drawingGroup()")) + } + @Test @MainActor func welcomeAndWorkspaceUseDistinctWindowSizes() { diff --git a/Tests/LitheTests/MacExternalAuthorizationCallbackRouterTests.swift b/Tests/LitheTests/MacExternalAuthorizationCallbackRouterTests.swift new file mode 100644 index 00000000..9514d369 --- /dev/null +++ b/Tests/LitheTests/MacExternalAuthorizationCallbackRouterTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct MacExternalAuthorizationCallbackRouterTests { + @Test + func retainsValidEarlyCallbackUntilHandlerIsInstalled() throws { + let router = MacExternalAuthorizationCallbackRouter() + let callback = try #require(URL(string: "lithe://auth/linux-do?payload=fake")) + var received: URL? + + router.route(callback) + router.installHandler { received = $0 } + + #expect(received == callback) + } + + @Test + func rejectsCallbacksOutsideTheLinuxDoTarget() throws { + let router = MacExternalAuthorizationCallbackRouter() + var received: URL? + router.installHandler { received = $0 } + + router.route(try #require(URL(string: "lithe://auth/another-provider?payload=fake"))) + router.route(try #require(URL(string: "https://auth/linux-do?payload=fake"))) + + #expect(received == nil) + } +} diff --git a/Tests/LitheTests/PluginManagerTests.swift b/Tests/LitheTests/PluginManagerTests.swift index e97f1cd3..4d7d6477 100644 --- a/Tests/LitheTests/PluginManagerTests.swift +++ b/Tests/LitheTests/PluginManagerTests.swift @@ -20,7 +20,16 @@ struct PluginManagerTests { #expect(BundledLanguagePluginCatalog.manifests.flatMap(\.modules).allSatisfy { $0.manifest.defaultState == .disabled }) - #expect(OfficialPluginCatalog.manifests.flatMap(\.modules).allSatisfy { + let officialLanguagePlugins = OfficialPluginCatalog.manifests.filter { + !($0.languageSupports ?? []).isEmpty + } + #expect(officialLanguagePlugins.flatMap(\.modules).allSatisfy { + $0.manifest.defaultState == .disabled + }) + let linuxDoPlugin = try #require(OfficialPluginCatalog.manifest( + forModule: OfficialPluginCatalog.linuxDoSupportModuleID + )) + #expect(linuxDoPlugin.modules.allSatisfy { $0.manifest.defaultState == .disabled }) _ = try ValidatedPluginCatalog( diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1cea96ae..e2c33f16 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1526,14 +1526,20 @@ name = "lithe-core" version = "0.1.0" dependencies = [ "ammonia", + "base64 0.22.1", "comrak", "quick-xml", + "rand 0.8.7", "regex", + "reqwest", + "rsa", "serde", "serde_json", "serde_yaml_ng", + "sha1", "sha2", "toml", + "url", ] [[package]] @@ -2339,7 +2345,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 0635d2d1..8639b582 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -10,11 +10,17 @@ crate-type = ["rlib", "staticlib", "cdylib"] [dependencies] ammonia = "4.1.4" +base64 = "0.22" comrak = { version = "0.54.0", default-features = false, features = ["shortcodes"] } +rand = "0.8" regex = "1.11" quick-xml = "0.37" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +rsa = { version = "0.9", features = ["pem"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha1 = "0.10" sha2 = "0.10" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } serde_yaml_ng = "0.10.0" +url = "2.5" diff --git a/rust/lithe-core/src/community/discourse.rs b/rust/lithe-core/src/community/discourse.rs new file mode 100644 index 00000000..37cbf622 --- /dev/null +++ b/rust/lithe-core/src/community/discourse.rs @@ -0,0 +1,926 @@ +//! Discourse user API key authorization shared by every platform host. + +use crate::protocol::{CoreError, ErrorCode}; +use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD}; +use base64::Engine; +use rand::rngs::OsRng; +use rand::RngCore; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ACCEPT, CONTENT_LENGTH, USER_AGENT}; +use rsa::pkcs1::{EncodeRsaPublicKey, LineEnding}; +use rsa::{Oaep, RsaPrivateKey, RsaPublicKey}; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use std::collections::HashMap; +use std::io::Read; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use url::Url; + +const AUTHORIZATION_PATH: &str = "user-api-key/new"; +const AUTHORIZATION_LIFETIME: Duration = Duration::from_secs(10 * 60); +const RSA_BITS: usize = 2048; +const MAX_RESPONSE_BYTES: u64 = 5 * 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const CLIENT_USER_AGENT: &str = "Lithe/0.1 DiscourseClient"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Parameters needed to begin an external-browser Discourse authorization. +pub(crate) struct DiscourseAuthorizationBeginRequest { + /// HTTPS origin of the Discourse installation, without credentials. + origin: String, + /// Stable application identifier recorded by Discourse. + client_id: String, + /// User-visible application name shown on the approval screen. + application_name: String, + /// Platform callback URL that receives the encrypted payload. + auth_redirect: String, + /// Least-privilege user API key scopes requested from the user. + scopes: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Browser URL and opaque flow identifier returned to a platform host. +pub(crate) struct DiscourseAuthorizationBeginResponse { + /// Opaque identifier needed to complete this in-memory authorization flow. + flow_id: String, + /// Fully encoded URL that the platform must open in the default browser. + authorization_url: String, + /// Unix timestamp after which the callback is rejected. + expires_at: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Callback submitted by a platform after its URL scheme is invoked. +pub(crate) struct DiscourseAuthorizationCompleteRequest { + /// Opaque identifier returned by the begin command. + flow_id: String, + /// Complete callback URL received from the operating system. + callback_url: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Verified credential returned for storage in the platform credential vault. +pub(crate) struct DiscourseAuthorizationCredential { + /// Per-user API key issued and revocable by the Discourse installation. + user_api_key: String, + /// User API protocol version reported by the server payload. + api_version: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Shared authentication and site fields used by Discourse API commands. +struct DiscourseAPIContext { + /// HTTPS origin of the Discourse installation. + origin: String, + /// Per-user API key loaded from the platform credential vault. + user_api_key: String, + /// Stable client identifier used during authorization. + client_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for latest or top topic summaries. +pub(crate) struct DiscourseTopicsRequest { + #[serde(flatten)] + context: DiscourseAPIContext, + /// Feed kind: `latest` or `top`. + feed: String, + /// Optional top period such as `daily`, `weekly`, or `monthly`. + #[serde(default)] + period: Option, + /// Optional zero-based Discourse pagination index. + #[serde(default)] + page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for one topic and its currently returned post stream. +pub(crate) struct DiscourseTopicRequest { + #[serde(flatten)] + context: DiscourseAPIContext, + /// Positive Discourse topic identifier. + topic_id: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for the visible category catalog. +pub(crate) struct DiscourseCategoriesRequest { + #[serde(flatten)] + context: DiscourseAPIContext, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for a bounded Discourse search page. +pub(crate) struct DiscourseSearchRequest { + #[serde(flatten)] + context: DiscourseAPIContext, + /// Search syntax accepted by the Discourse installation. + query: String, + /// Optional one-based search result page. + #[serde(default)] + page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to revoke a user API key on its issuing site. +pub(crate) struct DiscourseRevokeRequest { + #[serde(flatten)] + context: DiscourseAPIContext, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable topic summary rendered by both platform clients. +pub(crate) struct DiscourseTopicSummary { + id: u64, + slug: String, + title: String, + #[serde(default)] + posts_count: u64, + #[serde(default)] + reply_count: u64, + #[serde(default)] + views: u64, + #[serde(default)] + like_count: u64, + #[serde(default)] + category_id: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + last_posted_at: Option, + #[serde(default)] + last_poster_username: Option, + #[serde(default)] + pinned: bool, + #[serde(default)] + closed: bool, + #[serde(default)] + archived: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Normalized topic page returned by latest and top feeds. +pub(crate) struct DiscourseTopicsResponse { + topics: Vec, + more_topics_url: Option, +} + +#[derive(Debug, Deserialize)] +struct TopicListEnvelope { + topic_list: TopicList, +} + +#[derive(Debug, Deserialize)] +struct TopicList { + #[serde(default)] + topics: Vec, + #[serde(default)] + more_topics_url: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable category metadata independent of Discourse theme fields. +pub(crate) struct DiscourseCategory { + id: u64, + name: String, + slug: String, + #[serde(default)] + color: Option, + #[serde(default)] + topic_count: u64, + #[serde(default)] + description_text: Option, +} + +#[derive(Debug, Deserialize)] +struct CategoryEnvelope { + category_list: CategoryList, +} + +#[derive(Debug, Deserialize)] +struct CategoryList { + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministically ordered visible category catalog. +pub(crate) struct DiscourseCategoriesResponse { + categories: Vec, +} + +#[derive(Debug, Deserialize)] +struct TopicEnvelope { + id: u64, + title: String, + slug: String, + post_stream: PostStream, +} + +#[derive(Debug, Deserialize)] +struct PostStream { + #[serde(default)] + posts: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Sanitized post content and attribution for a topic stream. +pub(crate) struct DiscoursePost { + id: u64, + post_number: u64, + username: String, + #[serde(default)] + name: Option, + cooked: String, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + reply_count: u64, + #[serde(default)] + reads: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// One topic with sanitized post HTML in ascending post order. +pub(crate) struct DiscourseTopicResponse { + id: u64, + title: String, + slug: String, + posts: Vec, +} + +#[derive(Debug, Deserialize)] +struct SearchEnvelope { + #[serde(default)] + topics: Vec, + #[serde(default)] + posts: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Normalized topic and sanitized post matches from a search page. +pub(crate) struct DiscourseSearchResponse { + topics: Vec, + posts: Vec, +} + +/// Lists latest or top topics through the Rust-owned HTTP client. +pub(crate) fn topics( + request: DiscourseTopicsRequest, +) -> Result { + let mut url = api_url( + &request.context, + match request.feed.as_str() { + "latest" => "latest.json", + "top" => "top.json", + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Topic feed must be latest or top", + )) + } + }, + )?; + if request.feed == "top" { + if let Some(period) = request.period { + const PERIODS: &[&str] = &["all", "yearly", "quarterly", "monthly", "weekly", "daily"]; + if !PERIODS.contains(&period.as_str()) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported top topic period", + )); + } + url.query_pairs_mut().append_pair("period", &period); + } + } + if let Some(page) = request.page { + url.query_pairs_mut().append_pair("page", &page.to_string()); + } + let envelope: TopicListEnvelope = get_json(&request.context, url)?; + Ok(DiscourseTopicsResponse { + topics: envelope.topic_list.topics, + more_topics_url: envelope.topic_list.more_topics_url, + }) +} + +/// Reads and sanitizes one topic stream through the Rust-owned HTTP client. +pub(crate) fn topic(request: DiscourseTopicRequest) -> Result { + if request.topic_id == 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Topic ID must be positive", + )); + } + let envelope: TopicEnvelope = get_json( + &request.context, + api_url(&request.context, &format!("t/{}.json", request.topic_id))?, + )?; + Ok(DiscourseTopicResponse { + id: envelope.id, + title: envelope.title, + slug: envelope.slug, + posts: sanitize_posts(envelope.post_stream.posts), + }) +} + +/// Lists visible categories through the Rust-owned HTTP client. +pub(crate) fn categories( + request: DiscourseCategoriesRequest, +) -> Result { + let envelope: CategoryEnvelope = get_json( + &request.context, + api_url(&request.context, "categories.json")?, + )?; + Ok(DiscourseCategoriesResponse { + categories: envelope.category_list.categories, + }) +} + +/// Searches topics and posts through the Rust-owned HTTP client. +pub(crate) fn search( + request: DiscourseSearchRequest, +) -> Result { + let query = required_single_line(&request.query, "query")?; + let mut url = api_url(&request.context, "search.json")?; + url.query_pairs_mut().append_pair("q", query); + if let Some(page) = request.page { + url.query_pairs_mut().append_pair("page", &page.to_string()); + } + let envelope: SearchEnvelope = get_json(&request.context, url)?; + Ok(DiscourseSearchResponse { + topics: envelope.topics, + posts: sanitize_posts(envelope.posts), + }) +} + +/// Revokes the credential on the issuing Discourse installation. +pub(crate) fn revoke(request: DiscourseRevokeRequest) -> Result { + let url = api_url(&request.context, "user-api-key/revoke")?; + let response = authenticated_request(&request.context, reqwest::Method::POST, url)?.send(); + checked_response(response)?; + Ok(serde_json::json!({})) +} + +struct PendingAuthorization { + private_key: RsaPrivateKey, + nonce: String, + auth_redirect: String, + expires_at: u64, +} + +#[derive(Deserialize)] +struct EncryptedAuthorizationPayload { + key: String, + nonce: String, + api: u64, +} + +fn pending_authorizations() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Creates an ephemeral RSA key and returns a browser authorization URL. +pub(crate) fn begin_authorization( + request: DiscourseAuthorizationBeginRequest, +) -> Result { + let origin = validated_origin(&request.origin)?; + let auth_redirect = validated_redirect(&request.auth_redirect)?; + let client_id = required_single_line(&request.client_id, "clientId")?; + let application_name = required_single_line(&request.application_name, "applicationName")?; + let scopes = validated_scopes(request.scopes)?; + let now = unix_timestamp()?; + + let private_key = RsaPrivateKey::new(&mut OsRng, RSA_BITS).map_err(|error| { + CoreError::new(ErrorCode::Unknown, "Could not create an authorization key") + .with_details(error.to_string()) + })?; + let public_key = RsaPublicKey::from(&private_key) + .to_pkcs1_pem(LineEnding::LF) + .map_err(|error| { + CoreError::new(ErrorCode::Unknown, "Could not encode the authorization key") + .with_details(error.to_string()) + })?; + let flow_id = random_identifier(16); + let nonce = random_identifier(32); + let expires_at = now + AUTHORIZATION_LIFETIME.as_secs(); + let authorization_url = build_authorization_url( + origin, + client_id, + application_name, + &auth_redirect, + &scopes, + &nonce, + public_key.as_str(), + ); + + let mut pending = pending_authorizations() + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Authorization state is unavailable"))?; + pending.retain(|_, authorization| authorization.expires_at > now); + pending.insert( + flow_id.clone(), + PendingAuthorization { + private_key, + nonce, + auth_redirect, + expires_at, + }, + ); + + Ok(DiscourseAuthorizationBeginResponse { + flow_id, + authorization_url, + expires_at, + }) +} + +/// Decrypts one callback and consumes its authorization flow to prevent replay. +pub(crate) fn complete_authorization( + request: DiscourseAuthorizationCompleteRequest, +) -> Result { + let callback = Url::parse(&request.callback_url).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid authorization callback URL", + ) + .with_details(error.to_string()) + })?; + let now = unix_timestamp()?; + let authorization = pending_authorizations() + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Authorization state is unavailable"))? + .remove(&request.flow_id) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Authorization session was not found or was already used", + ) + })?; + if authorization.expires_at <= now { + return Err(CoreError::new( + ErrorCode::TimedOut, + "Authorization session expired", + )); + } + if !same_callback_target(&callback, &authorization.auth_redirect) { + return Err(CoreError::new( + ErrorCode::PermissionDenied, + "Authorization callback target did not match the requested redirect", + )); + } + let encrypted_payload = callback + .query_pairs() + .find_map(|(name, value)| (name == "payload").then(|| value.into_owned())) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Authorization callback did not contain a payload", + ) + })?; + let encrypted_payload = decode_payload(&encrypted_payload)?; + let decrypted = authorization + .private_key + .decrypt(Oaep::new::(), &encrypted_payload) + .map_err(|_| { + CoreError::new( + ErrorCode::PermissionDenied, + "Authorization payload could not be decrypted", + ) + })?; + let payload: EncryptedAuthorizationPayload = + serde_json::from_slice(&decrypted).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Authorization payload was invalid") + .with_details(error.to_string()) + })?; + if payload.nonce != authorization.nonce { + return Err(CoreError::new( + ErrorCode::PermissionDenied, + "Authorization nonce did not match", + )); + } + if payload.key.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Authorization payload did not contain a user API key", + )); + } + + Ok(DiscourseAuthorizationCredential { + user_api_key: payload.key, + api_version: payload.api, + }) +} + +fn validated_origin(value: &str) -> Result { + let mut url = Url::parse(value).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Discourse origin") + .with_details(error.to_string()) + })?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Discourse origin must be an HTTPS origin without credentials", + )); + } + url.set_path("/"); + Ok(url) +} + +fn validated_redirect(value: &str) -> Result { + let url = Url::parse(value).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid authorization redirect URL", + ) + .with_details(error.to_string()) + })?; + if url.scheme().is_empty() + || url.host_str().is_none() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Authorization redirect must contain a scheme and host without query parameters", + )); + } + Ok(url.to_string()) +} + +fn required_single_line<'a>(value: &'a str, field: &str) -> Result<&'a str, CoreError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.contains(['\r', '\n']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("{field} must be a non-empty single-line value"), + )); + } + Ok(trimmed) +} + +fn validated_scopes(scopes: Vec) -> Result { + const ALLOWED: &[&str] = &[ + "bookmarks_calendar", + "message_bus", + "notifications", + "one_time_password", + "push", + "read", + "session_info", + "user_status", + "write", + ]; + let mut scopes = scopes + .into_iter() + .map(|scope| scope.trim().to_string()) + .collect::>(); + scopes.sort(); + scopes.dedup(); + if scopes.is_empty() + || scopes + .iter() + .any(|scope| !ALLOWED.contains(&scope.as_str())) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Authorization scopes contained an unsupported value", + )); + } + Ok(scopes.join(",")) +} + +fn build_authorization_url( + mut origin: Url, + client_id: &str, + application_name: &str, + auth_redirect: &str, + scopes: &str, + nonce: &str, + public_key: &str, +) -> String { + origin.set_path(AUTHORIZATION_PATH); + origin + .query_pairs_mut() + .append_pair("application_name", application_name) + .append_pair("client_id", client_id) + .append_pair("auth_redirect", auth_redirect) + .append_pair("scopes", scopes) + .append_pair("nonce", nonce) + .append_pair("public_key", public_key) + .append_pair("padding", "oaep"); + origin.into() +} + +fn same_callback_target(callback: &Url, expected: &str) -> bool { + Url::parse(expected).is_ok_and(|expected| { + callback.scheme() == expected.scheme() + && callback.host_str() == expected.host_str() + && callback.port_or_known_default() == expected.port_or_known_default() + && callback.path() == expected.path() + }) +} + +fn decode_payload(value: &str) -> Result, CoreError> { + let normalized = value.replace('-', "+").replace('_', "/"); + let padded = format!("{normalized}{}", "=".repeat((4 - normalized.len() % 4) % 4)); + STANDARD + .decode(&padded) + .or_else(|_| URL_SAFE.decode(value)) + .map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Authorization payload was not valid Base64", + ) + .with_details(error.to_string()) + }) +} + +fn random_identifier(byte_count: usize) -> String { + let mut bytes = vec![0_u8; byte_count]; + OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn unix_timestamp() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| { + CoreError::new(ErrorCode::Unknown, "System clock is before the Unix epoch") + .with_details(error.to_string()) + }) +} + +fn api_url(context: &DiscourseAPIContext, path: &str) -> Result { + if context.user_api_key.trim().is_empty() || context.client_id.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Discourse credential and client ID are required", + )); + } + validated_origin(&context.origin)? + .join(path) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Discourse API path") + .with_details(error.to_string()) + }) +} + +fn authenticated_request( + context: &DiscourseAPIContext, + method: reqwest::Method, + url: Url, +) -> Result { + let client = Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|error| network_error("Could not initialize the Discourse client", error))?; + Ok(client + .request(method, url) + .header(ACCEPT, "application/json") + .header(USER_AGENT, CLIENT_USER_AGENT) + .header("User-Api-Key", context.user_api_key.trim()) + .header("User-Api-Client-Id", context.client_id.trim())) +} + +fn get_json Deserialize<'de>>( + context: &DiscourseAPIContext, + url: Url, +) -> Result { + let response = authenticated_request(context, reqwest::Method::GET, url)?.send(); + let bytes = read_limited_response(checked_response(response)?)?; + serde_json::from_slice(&bytes).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Discourse returned invalid JSON") + .with_details(error.to_string()) + }) +} + +fn checked_response(response: Result) -> Result { + let response = response.map_err(|error| network_error("Discourse request failed", error))?; + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let (code, message) = match status.as_u16() { + 401 | 403 => ( + ErrorCode::PermissionDenied, + "LINUX DO authorization was rejected or has expired", + ), + 404 => ( + ErrorCode::InvalidRequest, + "Discourse resource was not found", + ), + 429 => (ErrorCode::TimedOut, "Discourse rate limit was reached"), + _ => (ErrorCode::Unknown, "Discourse request was unsuccessful"), + }; + Err(CoreError::new(code, message).with_details(status.as_u16().to_string())) +} + +fn read_limited_response(response: Response) -> Result, CoreError> { + if response + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > MAX_RESPONSE_BYTES) + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Discourse response exceeded the 5 MB limit", + )); + } + let mut bytes = Vec::new(); + response + .take(MAX_RESPONSE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + CoreError::new(ErrorCode::Unknown, "Could not read the Discourse response") + .with_details(error.to_string()) + })?; + if bytes.len() as u64 > MAX_RESPONSE_BYTES { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Discourse response exceeded the 5 MB limit", + )); + } + Ok(bytes) +} + +fn sanitize_posts(mut posts: Vec) -> Vec { + for post in &mut posts { + post.cooked = ammonia::clean(&post.cooked); + } + posts.sort_by_key(|post| post.post_number); + posts +} + +fn network_error(message: &str, error: reqwest::Error) -> CoreError { + let code = if error.is_timeout() { + ErrorCode::TimedOut + } else { + ErrorCode::Unknown + }; + // Library diagnostics can include the request URL but never request headers. + CoreError::new(code, message).with_details(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rsa::pkcs1::DecodeRsaPublicKey; + use serde_json::json; + + #[test] + fn begin_builds_a_least_privilege_oaep_authorization_url() { + let result = begin_authorization(DiscourseAuthorizationBeginRequest { + origin: "https://linux.do".into(), + client_id: "app.lithe.linux-do".into(), + application_name: "Lithe".into(), + auth_redirect: "lithe://auth/linux-do".into(), + scopes: vec!["session_info".into(), "read".into(), "read".into()], + }) + .unwrap(); + let url = Url::parse(&result.authorization_url).unwrap(); + let query = url.query_pairs().collect::>(); + + assert_eq!( + url.as_str().split('?').next().unwrap(), + "https://linux.do/user-api-key/new" + ); + assert_eq!(query["scopes"], "read,session_info"); + assert_eq!(query["padding"], "oaep"); + assert_eq!(query["auth_redirect"], "lithe://auth/linux-do"); + RsaPublicKey::from_pkcs1_pem(&query["public_key"]).unwrap(); + } + + #[test] + fn rejects_insecure_origins_and_unknown_scopes() { + for request in [ + DiscourseAuthorizationBeginRequest { + origin: "http://linux.do".into(), + client_id: "client".into(), + application_name: "Lithe".into(), + auth_redirect: "lithe://auth/linux-do".into(), + scopes: vec!["read".into()], + }, + DiscourseAuthorizationBeginRequest { + origin: "https://linux.do".into(), + client_id: "client".into(), + application_name: "Lithe".into(), + auth_redirect: "lithe://auth/linux-do".into(), + scopes: vec!["admin".into()], + }, + ] { + assert!(begin_authorization(request).is_err()); + } + } + + #[test] + fn completes_an_oaep_callback_once_and_verifies_the_nonce() { + let begun = begin_authorization(DiscourseAuthorizationBeginRequest { + origin: "https://linux.do".into(), + client_id: "app.lithe.linux-do".into(), + application_name: "Lithe".into(), + auth_redirect: "lithe://auth/linux-do".into(), + scopes: vec!["read".into()], + }) + .unwrap(); + let (public_key, nonce) = { + let pending = pending_authorizations().lock().unwrap(); + let authorization = pending.get(&begun.flow_id).unwrap(); + ( + RsaPublicKey::from(&authorization.private_key), + authorization.nonce.clone(), + ) + }; + let cleartext = serde_json::to_vec(&json!({ + "key": "test-user-api-key", + "nonce": nonce, + "api": 4 + })) + .unwrap(); + let encrypted = public_key + .encrypt(&mut OsRng, Oaep::new::(), &cleartext) + .unwrap(); + let mut callback = Url::parse("lithe://auth/linux-do").unwrap(); + callback + .query_pairs_mut() + .append_pair("payload", &URL_SAFE_NO_PAD.encode(encrypted)); + + let completed = complete_authorization(DiscourseAuthorizationCompleteRequest { + flow_id: begun.flow_id.clone(), + callback_url: callback.to_string(), + }) + .unwrap(); + + assert_eq!(completed.user_api_key, "test-user-api-key"); + assert_eq!(completed.api_version, 4); + assert!( + complete_authorization(DiscourseAuthorizationCompleteRequest { + flow_id: begun.flow_id, + callback_url: callback.to_string(), + }) + .is_err() + ); + } + + #[test] + fn sanitizes_and_orders_post_html() { + let posts = sanitize_posts(vec![ + DiscoursePost { + id: 2, + post_number: 2, + username: "second".into(), + name: None, + cooked: "

Safe

".into(), + created_at: None, + updated_at: None, + reply_count: 0, + reads: 0, + }, + DiscoursePost { + id: 1, + post_number: 1, + username: "first".into(), + name: None, + cooked: "

First

".into(), + created_at: None, + updated_at: None, + reply_count: 0, + reads: 0, + }, + ]); + + assert_eq!(posts[0].post_number, 1); + assert!(!posts[1].cooked.contains("script")); + assert!(posts[1].cooked.contains("Safe")); + } +} diff --git a/rust/lithe-core/src/community/mod.rs b/rust/lithe-core/src/community/mod.rs new file mode 100644 index 00000000..c2408ff3 --- /dev/null +++ b/rust/lithe-core/src/community/mod.rs @@ -0,0 +1,10 @@ +//! Shared community integrations and their cross-platform protocol boundaries. + +mod discourse; + +pub(crate) use discourse::{ + begin_authorization, categories, complete_authorization, revoke, search, topic, topics, + DiscourseAuthorizationBeginRequest, DiscourseAuthorizationCompleteRequest, + DiscourseCategoriesRequest, DiscourseRevokeRequest, DiscourseSearchRequest, + DiscourseTopicRequest, DiscourseTopicsRequest, +}; diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 88cf05f5..6a11fbda 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -1,5 +1,6 @@ //! Deterministic application services shared by the macOS and Windows hosts. +mod community; mod execution; mod git; mod github; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index c0db065a..0c8223d1 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -31,6 +31,20 @@ pub struct CoreRequest { pub enum CoreCommand { /// Reports the Core and protocol versions (`core.ping`). Ping, + /// Starts a Discourse user API key authorization (`community.discourse.auth.begin`). + CommunityDiscourseAuthBegin, + /// Decrypts and verifies a Discourse authorization callback (`community.discourse.auth.complete`). + CommunityDiscourseAuthComplete, + /// Lists normalized latest or top Discourse topics (`community.discourse.topics`). + CommunityDiscourseTopics, + /// Reads one normalized Discourse topic (`community.discourse.topic`). + CommunityDiscourseTopic, + /// Lists normalized Discourse categories (`community.discourse.categories`). + CommunityDiscourseCategories, + /// Searches Discourse topics and posts (`community.discourse.search`). + CommunityDiscourseSearch, + /// Revokes the current Discourse user API key (`community.discourse.auth.revoke`). + CommunityDiscourseAuthRevoke, /// Builds the visible project tree (`workspace.snapshot`). WorkspaceSnapshot, /// Builds or reuses the workspace search index (`workspace.searchIndex.warm`). @@ -167,6 +181,13 @@ impl CoreCommand { pub fn parse(value: &str) -> Option { match value { "core.ping" => Some(Self::Ping), + "community.discourse.auth.begin" => Some(Self::CommunityDiscourseAuthBegin), + "community.discourse.auth.complete" => Some(Self::CommunityDiscourseAuthComplete), + "community.discourse.topics" => Some(Self::CommunityDiscourseTopics), + "community.discourse.topic" => Some(Self::CommunityDiscourseTopic), + "community.discourse.categories" => Some(Self::CommunityDiscourseCategories), + "community.discourse.search" => Some(Self::CommunityDiscourseSearch), + "community.discourse.auth.revoke" => Some(Self::CommunityDiscourseAuthRevoke), "workspace.snapshot" => Some(Self::WorkspaceSnapshot), "workspace.searchIndex.warm" => Some(Self::WorkspaceSearchIndexWarm), "workspace.searchIndex.update" => Some(Self::WorkspaceSearchIndexUpdate), @@ -255,4 +276,19 @@ mod tests { assert!(CoreCommand::parse(command).is_some(), "missing {command}"); } } + + #[test] + fn parses_discourse_authorization_commands() { + for command in [ + "community.discourse.auth.begin", + "community.discourse.auth.complete", + "community.discourse.auth.revoke", + "community.discourse.categories", + "community.discourse.search", + "community.discourse.topic", + "community.discourse.topics", + ] { + assert!(CoreCommand::parse(command).is_some(), "missing {command}"); + } + } } diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 99b1c8c7..b8a79cf2 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1,5 +1,10 @@ //! Validation and routing from versioned command names to their owning domains. +use crate::community::{ + self, DiscourseAuthorizationBeginRequest, DiscourseAuthorizationCompleteRequest, + DiscourseCategoriesRequest, DiscourseRevokeRequest, DiscourseSearchRequest, + DiscourseTopicRequest, DiscourseTopicsRequest, +}; use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, @@ -72,6 +77,128 @@ fn execute(request: &str) -> CoreResponse { "coreVersion": env!("CARGO_PKG_VERSION") }), ), + CoreCommand::CommunityDiscourseAuthBegin => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Discourse authorization request", + ) + .with_details(error.to_string()) + }) + .and_then(community::begin_authorization) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Discourse authorization response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::CommunityDiscourseAuthComplete => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Discourse authorization callback", + ) + .with_details(error.to_string()) + }) + .and_then(community::complete_authorization) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Discourse authorization credential should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::CommunityDiscourseTopics => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Discourse topics request", + ) + .with_details(error.to_string()) + }) + .and_then(community::topics) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Discourse topics should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::CommunityDiscourseTopic => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Discourse topic request") + .with_details(error.to_string()) + }) + .and_then(community::topic) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Discourse topic should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::CommunityDiscourseCategories => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Discourse categories request", + ) + .with_details(error.to_string()) + }) + .and_then(community::categories) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Discourse categories should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::CommunityDiscourseSearch => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Discourse search request", + ) + .with_details(error.to_string()) + }) + .and_then(community::search) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Discourse search should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::CommunityDiscourseAuthRevoke => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Discourse revoke request", + ) + .with_details(error.to_string()) + }) + .and_then(community::revoke) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::WorkspaceSnapshot => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/scripts/preview.sh b/scripts/preview.sh index 4a995f77..71e24531 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -14,8 +14,14 @@ scripts/build-macos.sh --configuration debug --triple "$TRIPLE" # 必须打成 .app 再启动:裸可执行文件没有 Info.plist,macOS 不会把它当成 # 前台应用,窗口能收到鼠标点击但永远拿不到键盘焦点。 -APP_DIR="$ROOT_DIR/.build/preview/Lithe.app" -rm -rf "$APP_DIR" +PREVIEW_ROOT="$ROOT_DIR/.build/preview" +mkdir -p "$PREVIEW_ROOT" +# Every launch owns a separate bundle. AppKit decodes SVG resources lazily, so +# replacing a fixed bundle while an older preview is still running corrupts +# that process's cached images and can let missing-image placeholders cover the +# project tree and tool windows. +INSTANCE_DIR=$(mktemp -d "$PREVIEW_ROOT/instance.XXXXXX") +APP_DIR="$INSTANCE_DIR/Lithe.app" mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources/OfficialPlugins" "$APP_DIR/Contents/Helpers" cp ".build/$TRIPLE/debug/Lithe" "$APP_DIR/Contents/MacOS/Lithe" plugin_root=$(scripts/build-official-plugins.sh --configuration debug --triple "$TRIPLE") @@ -45,4 +51,5 @@ for localization in en.lproj zh-Hans.lproj; do done codesign --force --deep --sign - "$APP_DIR" -exec open -n -W "$APP_DIR" +open -n -W "$APP_DIR" +rm -rf "$INSTANCE_DIR" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index fe3e95c9..f55ba158 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -34,6 +34,7 @@ verification scripts are the executable source of boundary checks. | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Local History | revision metadata, text content, restore result | persistence location and file operations | | Modules | stable IDs, manifests, enabled state, lifecycle snapshots, dependencies, capabilities, and contributions | native factories, processes, timers, PTY/ConPTY, watchers, connections, and UI rendering | +| Community integrations | Discourse authorization sessions, RSA-OAEP callback verification, user API protocol models, and normalized community data | opening the system browser, receiving URL callbacks, and credential-vault persistence | ## Module Lifecycle Contract diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5c34853b..fb02e5ca 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -59,6 +59,13 @@ stable error code and a user-facing message: | Command | Purpose | | --- | --- | | `core.ping` | Verify the ABI and protocol version | +| `community.discourse.auth.begin` | Create an ephemeral RSA-OAEP authorization session and return the Discourse browser URL | +| `community.discourse.auth.complete` | Decrypt, validate, and consume one Discourse user API key callback | +| `community.discourse.auth.revoke` | Revoke the current Discourse user API key | +| `community.discourse.topics` | List normalized latest or top topic summaries | +| `community.discourse.topic` | Read one topic with ordered, sanitized post HTML | +| `community.discourse.categories` | List normalized visible categories | +| `community.discourse.search` | Search normalized topics and sanitized posts | | `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths | | `workspace.search` | Search visible file names and UTF-8 text files | | `workspace.searchEverywhere` | Search visible file names, Java types/methods, and UTF-8 text files | @@ -133,6 +140,26 @@ GitHub command shapes, authorization behavior, and supported pull-request operations are documented in [`github.md`](github.md). Rust Core performs no network or credential I/O for these commands. +`community.discourse.auth.begin` accepts an HTTPS `origin`, stable `clientId`, +user-visible `applicationName`, platform-owned `authRedirect`, and a non-empty +array of supported `scopes`. It returns an opaque `flowId`, an +`authorizationUrl` that requests RSA-OAEP padding, and an `expiresAt` Unix +timestamp. The private key and nonce remain in Rust memory and expire after ten +minutes. `community.discourse.auth.complete` accepts that `flowId` and the full +`callbackUrl`; it consumes the flow, verifies the callback target, decrypts the +payload, and checks the nonce before returning `userApiKey` and `apiVersion`. +Platform hosts open the browser, receive their registered URL scheme, and store +the returned credential in Keychain or Windows Credential Manager. They do not +implement Discourse cryptography or callback validation. + +The authenticated community commands accept `origin`, `userApiKey`, and +`clientId` plus their operation-specific fields. Rust owns HTTPS requests, +authentication headers, a 30-second request timeout, a 5 MB response limit, +Discourse JSON decoding, deterministic post ordering, and HTML sanitization. +Platform clients never issue a parallel Discourse request or parse a second +response shape. Credential vault reads and writes remain native adapters; the +credential is passed to Core only for the duration of one command. + `git.watchContext` accepts `{ "root": string }`. When `root` is not inside a Git repository, it returns `null`. Otherwise it returns `{ "repositoryRoot": string, "gitDirectory": string, "gitCommonDirectory": string }`; diff --git a/shared/fixtures/community/discourse-auth-v1.json b/shared/fixtures/community/discourse-auth-v1.json new file mode 100644 index 00000000..2dc92769 --- /dev/null +++ b/shared/fixtures/community/discourse-auth-v1.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "begin": { + "command": "community.discourse.auth.begin", + "payload": { + "origin": "https://linux.do", + "clientId": "app.lithe.linux-do", + "applicationName": "Lithe", + "authRedirect": "lithe://auth/linux-do", + "scopes": ["read", "session_info"] + }, + "expected": { + "authorizationPath": "/user-api-key/new", + "padding": "oaep", + "scopes": "read,session_info" + } + }, + "complete": { + "command": "community.discourse.auth.complete", + "payloadFields": ["flowId", "callbackUrl"], + "responseFields": ["apiVersion", "userApiKey"] + }, + "authenticatedCommands": [ + "community.discourse.auth.revoke", + "community.discourse.categories", + "community.discourse.search", + "community.discourse.topic", + "community.discourse.topics" + ], + "limits": { + "requestTimeoutSeconds": 30, + "responseBytes": 5242880 + } +} From 5be7536d61ccf5a54d413695095cc4ac03cc2152 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 16 Aug 2026 10:46:39 +0800 Subject: [PATCH 2/2] test(macOS): stabilize workspace recovery checks --- Tests/LitheTests/LitheCoreLogicTests.swift | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 12fb20ba..b3b4b821 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3062,8 +3062,9 @@ struct EditorDocumentTests { @Test @MainActor - func recoveryBatchRebuildsSnapshotReplacesRootsAndRefreshesOnlyGit() async { - let repository = URL(fileURLWithPath: "/tmp/lithe-recovery/repository") + func recoveryBatchRebuildsSnapshotReplacesRootsAndRefreshesOnlyGit() async throws { + let repository = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-recovery-\(UUID().uuidString)/repository") let gitDirectory = repository.appendingPathComponent(".git") let context = GitWatchContext( repositoryRoot: repository, @@ -3087,14 +3088,15 @@ struct EditorDocumentTests { ) defer { model.reset() } model.beginWorkspace(at: repository, visibilityRules: .default) - watcherFactory.source?.emit( + let source = try #require(watcherFactory.source) + source.emit( DirectoryChangeBatch( gitStateMayHaveChanged: true, requiresFullRescan: true, watchRootsChanged: true ) ) - let recovered = await waitForWorkspaceObservation { + let recovered = await waitForWorkspaceObservation(timeout: .seconds(15)) { model.rootNode != nil && refreshCount == 1 } @@ -3108,8 +3110,9 @@ struct EditorDocumentTests { @Test @MainActor - func watchRootsRecoveryRetainsWorkspacePathsAndRefreshesSnapshotAndDocuments() async { - let workspace = URL(fileURLWithPath: "/tmp/lithe-watch-roots-recovery/workspace") + func watchRootsRecoveryRetainsWorkspacePathsAndRefreshesSnapshotAndDocuments() async throws { + let workspace = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-watch-roots-recovery-\(UUID().uuidString)/workspace") let changedFile = workspace.appendingPathComponent("Sources/App.swift") let gitDirectory = workspace.appendingPathComponent(".git") let context = GitWatchContext( @@ -3133,7 +3136,8 @@ struct EditorDocumentTests { ) defer { model.reset() } model.beginWorkspace(at: workspace, visibilityRules: .default) - watcherFactory.source?.emit( + let source = try #require(watcherFactory.source) + source.emit( DirectoryChangeBatch( workspacePaths: [changedFile.path], gitStateMayHaveChanged: true, @@ -3141,7 +3145,7 @@ struct EditorDocumentTests { ) ) - let recovered = await waitForWorkspaceObservation { + let recovered = await waitForWorkspaceObservation(timeout: .seconds(15)) { model.rootNode != nil && processedPaths.map(\.path) == [changedFile.path] && refreshCount == 1 }