-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathPluginManager.swift
More file actions
215 lines (189 loc) · 8.21 KB
/
Copy pathPluginManager.swift
File metadata and controls
215 lines (189 loc) · 8.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
//
// PluginManager.swift
// BluetoothExplorerPluginEngine
//
// Owns plugin discovery, enable/disable state, and the current routing registry. UI observes
// `plugins`; the Store reads `registry`.
//
// All parsing is done by WASM plugins — there are no built-in native decoders. Bundled plugins are
// loaded read-only from the app bundle; user-imported plugins live in the app's Documents directory
// and can be deleted.
//
import Foundation
import Observation
import BluetoothExplorerPluginEngine
@MainActor
@Observable
public final class PluginManager {
/// Where a plugin came from.
public enum Source: Equatable, Sendable {
/// Shipped inside the app bundle. Read-only: can be enabled or disabled, not deleted.
case bundled
/// Imported by the user into Documents. Can be enabled, disabled, or deleted.
case imported
}
/// UI-facing state for one plugin.
public struct PluginState: Identifiable, Equatable, Sendable {
public let id: PluginID
public let name: String
public let version: String?
public let source: Source
public var isEnabled: Bool
public var loadError: String?
/// Only user-imported plugins can be removed; bundled ones live in the read-only app bundle.
public var isRemovable: Bool { source == .imported }
}
public private(set) var plugins: [PluginState] = []
public private(set) var registry: ParserRegistry
private var wasmPlugins: [PluginID: WasmParserPlugin] = [:]
private var order: [PluginID] = []
private var enabled: [PluginID: Bool] = [:]
private var sources: [PluginID: Source] = [:]
private var loadErrors: [PluginID: String] = [:]
private var displayNames: [PluginID: String] = [:]
private var versions: [PluginID: String] = [:]
public init() {
self.registry = ParserRegistry(plugins: [])
}
// MARK: Loading
/// Where imported plugins live on disk, once `loadInstalledPlugins()` has run.
public private(set) var directory: PluginDirectory?
/// Load bundled plugins from the app bundle and imported plugins from Documents.
///
/// Bundled plugins are referenced directly from `Bundle.module` — they are not copied anywhere.
/// Only user-imported plugins are stored on disk, under `Documents/Plugins`.
public func loadInstalledPlugins() {
loadBundledPlugins(from: PluginEngineResources.bundle)
if let directory = try? PluginDirectory.default() {
self.directory = directory
loadImportedPlugins(from: directory)
}
rebuild()
}
/// Scan a bundle's `Plugins/` directory for `*.bleplugin.json` manifests and load each.
public func loadBundledPlugins(from bundle: Bundle) {
let result = PluginLoader.loadBundled(from: bundle)
for loaded in result.loaded {
register(loaded.plugin, manifest: loaded.manifest, source: .bundled)
}
for failure in result.failures {
recordFailure(failure, source: .bundled)
}
rebuild()
}
private func loadImportedPlugins(from directory: PluginDirectory) {
for manifestURL in directory.installedManifestURLs() {
do {
let loaded = try PluginLoader.load(manifestURL: manifestURL, verifyHash: true)
register(loaded.plugin, manifest: loaded.manifest, source: .imported)
} catch let failure as PluginLoadFailure {
recordFailure(failure, source: .imported)
} catch {
recordFailure(PluginLoadFailure(manifestName: manifestURL.lastPathComponent,
underlying: nil, message: "\(error)"), source: .imported)
}
}
}
// MARK: Importing
/// Why an import failed, in a form the UI can show directly.
public struct ImportError: Error, Sendable, CustomStringConvertible {
public let message: String
public var description: String { message }
}
/// Import a plugin the user picked. The manifest and its module are validated, then copied
/// into the plugin directory and loaded.
@discardableResult
public func importPlugin(manifestURL: URL) -> Result<PluginID, ImportError> {
guard let directory else {
return .failure(ImportError(message: "Plugin storage is unavailable."))
}
#if canImport(Darwin)
// Files handed over by the document picker live outside the sandbox until claimed.
let scoped = manifestURL.startAccessingSecurityScopedResource()
defer { if scoped { manifestURL.stopAccessingSecurityScopedResource() } }
#endif
do {
let manifest = try directory.importPlugin(manifestURL: manifestURL)
let installed = directory.url
.appendingPathComponent(PluginDirectory.folderName(for: manifest.identifier), isDirectory: true)
.appendingPathComponent(manifestURL.lastPathComponent)
let loaded = try PluginLoader.load(manifestURL: installed, verifyHash: true)
register(loaded.plugin, manifest: loaded.manifest, source: .imported)
rebuild()
return .success(loaded.plugin.id)
} catch let failure as PluginLoadFailure {
return .failure(ImportError(message: failure.message))
} catch {
return .failure(ImportError(message: "\(error)"))
}
}
private func recordFailure(_ failure: PluginLoadFailure, source: Source) {
let syntheticID = PluginID(failure.manifestName)
if order.contains(syntheticID) == false { order.append(syntheticID) }
sources[syntheticID] = source
enabled[syntheticID] = false
displayNames[syntheticID] = failure.manifestName
loadErrors[syntheticID] = failure.message
}
private func register(_ plugin: WasmParserPlugin, manifest: PluginManifest, source: Source) {
let id = plugin.id
wasmPlugins[id] = plugin
if order.contains(id) == false { order.append(id) }
if enabled[id] == nil { enabled[id] = Self.storedEnabled(id) }
sources[id] = source
displayNames[id] = manifest.name
versions[id] = manifest.version
loadErrors[id] = nil
}
// MARK: Enable / disable / delete
public func setEnabled(_ isEnabled: Bool, id: PluginID) {
enabled[id] = isEnabled
UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey(id))
rebuild()
}
/// Delete an imported plugin, removing it from the registry and from the Documents directory.
///
/// Bundled plugins cannot be deleted — they live in the read-only app bundle — so this is a
/// no-op for them. Use `setEnabled(false:)` to turn a bundled plugin off instead.
public func removePlugin(id: PluginID) {
guard sources[id] == .imported else { return }
if let directory {
try? directory.remove(identifier: id.rawValue)
}
wasmPlugins[id] = nil
order.removeAll { $0 == id }
enabled[id] = nil
UserDefaults.standard.removeObject(forKey: Self.enabledKey(id))
sources[id] = nil
displayNames[id] = nil
versions[id] = nil
loadErrors[id] = nil
rebuild()
}
private static func enabledKey(_ id: PluginID) -> String {
"plugin.enabled." + id.rawValue
}
/// Persisted enable state, defaulting to on for a plugin seen for the first time.
private static func storedEnabled(_ id: PluginID) -> Bool {
UserDefaults.standard.object(forKey: enabledKey(id)) as? Bool ?? true
}
// MARK: Registry construction
private func rebuild() {
var activePlugins = [any ParserPlugin]()
for id in order {
guard enabled[id] == true, let plugin = wasmPlugins[id] else { continue }
activePlugins.append(plugin)
}
registry = ParserRegistry(plugins: activePlugins)
plugins = order.map { id in
PluginState(
id: id,
name: displayNames[id] ?? id.rawValue,
version: versions[id],
source: sources[id] ?? .imported,
isEnabled: enabled[id] ?? false,
loadError: loadErrors[id]
)
}
}
}