mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
227 lines
8.0 KiB
Swift
227 lines
8.0 KiB
Swift
import Combine
|
|
import Foundation
|
|
|
|
/// Owns mapping intent, publication and asynchronous post-commit work.
|
|
/// File availability is captured anew for each derivation, not cached for the session.
|
|
final class PluginCatalog: ObservableObject, PluginExecutableResolving {
|
|
enum Change {
|
|
case save(UserPluginRecord)
|
|
case remove(UUID)
|
|
case managedArguments(program: String, arguments: [String])
|
|
case managedMode(program: String, mode: PluginTransportMode)
|
|
}
|
|
|
|
private struct State {
|
|
var configuration: PluginConfiguration
|
|
var generation = 0
|
|
var unreadable: Bool
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var state: State
|
|
private let store: any PluginMappingStoring
|
|
private let managed: BundleManagedPluginProvider
|
|
private let inspector: any PluginInspecting
|
|
@MainActor @Published private(set) var securityFacts: [String: PluginSecurityFacts] = [:]
|
|
@MainActor private var inspectionTasks: [String: Task<Void, Never>] = [:]
|
|
let didCommit = PassthroughSubject<Int, Never>()
|
|
@MainActor private var invalidateRuntime: (() -> Void)?
|
|
@MainActor private var revalidate: (() -> Void)?
|
|
@MainActor private var converge: (() async -> Void)?
|
|
|
|
/// Composition-only wiring. UI submits mapping intent rather than scheduling effects.
|
|
@MainActor
|
|
func connect(
|
|
invalidateRuntime: @escaping () -> Void,
|
|
revalidate: @escaping () -> Void,
|
|
converge: @escaping () async -> Void
|
|
) {
|
|
self.invalidateRuntime = invalidateRuntime
|
|
self.revalidate = revalidate
|
|
self.converge = converge
|
|
}
|
|
|
|
init(
|
|
store: any PluginMappingStoring = PluginMappingFileStore(),
|
|
managed: BundleManagedPluginProvider = BundleManagedPluginProvider(),
|
|
inspector: any PluginInspecting = MacOSPluginInspection()
|
|
) {
|
|
self.store = store
|
|
self.managed = managed
|
|
self.inspector = inspector
|
|
do {
|
|
state = State(configuration: try store.load(), unreadable: false)
|
|
} catch {
|
|
state = State(configuration: PluginConfiguration(), unreadable: true)
|
|
}
|
|
}
|
|
|
|
func catalogSnapshot() -> PluginCatalogSnapshot {
|
|
let captured = lock.withLock { state }
|
|
return PluginCatalogSnapshot(
|
|
configuration: captured.configuration, managed: managed, generation: captured.generation,
|
|
mappingsUnreadable: captured.unreadable)
|
|
}
|
|
|
|
func resolve(program: String) -> ResolvedPlugin? {
|
|
catalogSnapshot().resolve(program: program)
|
|
}
|
|
|
|
var userRecords: [UserPluginRecord] { lock.withLock { state.configuration.userPlugins } }
|
|
|
|
@MainActor
|
|
func commit(_ changes: [Change]) throws {
|
|
let captured = lock.withLock { state }
|
|
guard !captured.unreadable else { throw PluginMappingError.unreadable }
|
|
var proposed = captured.configuration
|
|
for change in changes { try apply(change, to: &proposed) }
|
|
try proposed.validate()
|
|
guard proposed != captured.configuration else { return }
|
|
try publishAfterSaving(proposed)
|
|
}
|
|
|
|
private func apply(_ change: Change, to configuration: inout PluginConfiguration) throws {
|
|
switch change {
|
|
case .save(let record): try save(record, to: &configuration)
|
|
case .remove(let id): try remove(id, from: &configuration)
|
|
case .managedMode(let program, let mode):
|
|
try set(mode, forManagedProgram: program, in: &configuration)
|
|
case .managedArguments(let program, let arguments):
|
|
try set(arguments, forManagedProgram: program, in: &configuration)
|
|
}
|
|
}
|
|
|
|
private func save(_ original: UserPluginRecord, to configuration: inout PluginConfiguration)
|
|
throws
|
|
{
|
|
var record = original
|
|
record.names = record.names.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
record.path = try validatedPath(record.path)
|
|
if let index = configuration.userPlugins.firstIndex(where: { $0.id == record.id }) {
|
|
configuration.userPlugins[index] = record
|
|
} else {
|
|
configuration.userPlugins.append(record)
|
|
}
|
|
}
|
|
|
|
private func remove(_ id: UUID, from configuration: inout PluginConfiguration) throws {
|
|
guard let index = configuration.userPlugins.firstIndex(where: { $0.id == id }) else {
|
|
throw PluginMappingError.nameMissing
|
|
}
|
|
configuration.userPlugins.remove(at: index)
|
|
}
|
|
|
|
private func set(
|
|
_ mode: PluginTransportMode, forManagedProgram program: String,
|
|
in configuration: inout PluginConfiguration
|
|
) throws {
|
|
try requireManagedProgram(program)
|
|
if mode == .tcpOnly {
|
|
configuration.managedModes.removeValue(forKey: program)
|
|
} else {
|
|
configuration.managedModes[program] = mode
|
|
}
|
|
}
|
|
|
|
private func set(
|
|
_ arguments: [String], forManagedProgram program: String,
|
|
in configuration: inout PluginConfiguration
|
|
) throws {
|
|
try requireManagedProgram(program)
|
|
if arguments.isEmpty {
|
|
configuration.managedArguments.removeValue(forKey: program)
|
|
} else {
|
|
configuration.managedArguments[program] = arguments
|
|
}
|
|
}
|
|
|
|
private func requireManagedProgram(_ program: String) throws {
|
|
guard ManagedPluginCatalog.plugins.contains(where: { $0.program == program }) else {
|
|
throw PluginMappingError.nameMissing
|
|
}
|
|
}
|
|
|
|
/// Explicit repair; a corrupt store never silently becomes empty user intent.
|
|
@MainActor
|
|
func replaceConfiguration(_ configuration: PluginConfiguration) throws {
|
|
try configuration.validate()
|
|
for record in configuration.userPlugins { _ = try validatedPath(record.path) }
|
|
try publishAfterSaving(configuration)
|
|
}
|
|
|
|
@MainActor
|
|
private func publishAfterSaving(_ proposed: PluginConfiguration) throws {
|
|
try store.save(proposed)
|
|
publish(proposed)
|
|
}
|
|
|
|
/// Retry a failed initial read without replacing or writing user intent.
|
|
@MainActor
|
|
func retryReadingMappings() throws {
|
|
guard lock.withLock({ state.unreadable }) else { return }
|
|
let mappings = try store.load()
|
|
publish(mappings)
|
|
}
|
|
|
|
@MainActor
|
|
private func publish(_ proposed: PluginConfiguration) {
|
|
objectWillChange.send()
|
|
let generation = lock.withLock {
|
|
state.configuration = proposed
|
|
state.unreadable = false
|
|
state.generation += 1
|
|
return state.generation
|
|
}
|
|
invalidateRuntime?()
|
|
revalidate?()
|
|
didCommit.send(generation)
|
|
refreshSecurityFacts()
|
|
if let converge {
|
|
Task { @MainActor [weak self] in
|
|
guard let self, self.catalogSnapshot().generation == generation else { return }
|
|
await converge()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Explicit refresh also serves activation and startup failure diagnostics.
|
|
@discardableResult
|
|
@MainActor
|
|
func refreshSecurityFacts() -> Task<Void, Never> {
|
|
for task in inspectionTasks.values { task.cancel() }
|
|
inspectionTasks.removeAll()
|
|
securityFacts.removeAll()
|
|
let snapshot = catalogSnapshot()
|
|
var inspected = Set<UUID>()
|
|
for entry in snapshot.entries where entry.source == .user {
|
|
guard let recordID = entry.recordID, inspected.insert(recordID).inserted else { continue }
|
|
let names = snapshot.entries.filter { $0.recordID == recordID }.map(\.program)
|
|
guard let path = entry.path, let identity = PluginFileIdentity(path: path) else { continue }
|
|
let inspector = inspector
|
|
inspectionTasks[entry.program] = Task { @MainActor [weak self] in
|
|
guard !Task.isCancelled else { return }
|
|
let facts = await inspector.inspect(URL(fileURLWithPath: path))
|
|
guard let self, !Task.isCancelled,
|
|
self.lock.withLock({ self.state.generation }) == snapshot.generation,
|
|
PluginFileIdentity(path: path) == identity
|
|
else { return }
|
|
for name in names { self.securityFacts[name] = facts }
|
|
self.inspectionTasks.removeValue(forKey: entry.program)
|
|
}
|
|
}
|
|
let tasks = Array(inspectionTasks.values)
|
|
return Task { @MainActor in
|
|
for task in tasks { await task.value }
|
|
}
|
|
}
|
|
|
|
private func validatedPath(_ path: String) throws -> String {
|
|
guard path.hasPrefix("/"), !path.contains("\0") else { throw PluginMappingError.invalidPath }
|
|
var isDirectory: ObjCBool = false
|
|
guard managed.fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
|
|
!isDirectory.boolValue, managed.fileManager.isExecutableFile(atPath: path)
|
|
else { throw PluginMappingError.unavailableFile }
|
|
return path
|
|
}
|
|
}
|