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
}