55// Owns plugin discovery, enable/disable state, and the current routing registry. UI observes
66// `plugins`; the Store reads `registry`.
77//
8+ // All parsing is done by WASM plugins — there are no built-in native decoders. Bundled plugins are
9+ // loaded read-only from the app bundle; user-imported plugins live in the app's Documents directory
10+ // and can be deleted.
11+ //
812
913import Foundation
1014import Observation
@@ -16,8 +20,9 @@ public final class PluginManager {
1620
1721 /// Where a plugin came from.
1822 public enum Source : Equatable , Sendable {
19- case native
23+ /// Shipped inside the app bundle. Read-only: can be enabled or disabled, not deleted.
2024 case bundled
25+ /// Imported by the user into Documents. Can be enabled, disabled, or deleted.
2126 case imported
2227 }
2328
@@ -29,12 +34,14 @@ public final class PluginManager {
2934 public let source : Source
3035 public var isEnabled : Bool
3136 public var loadError : String ?
37+
38+ /// Only user-imported plugins can be removed; bundled ones live in the read-only app bundle.
39+ public var isRemovable : Bool { source == . imported }
3240 }
3341
3442 public private( set) var plugins : [ PluginState ] = [ ]
3543 public private( set) var registry : ParserRegistry
3644
37- private let nativeParsers : [ any ParserPlugin ]
3845 private var wasmPlugins : [ PluginID : WasmParserPlugin ] = [ : ]
3946 private var order : [ PluginID ] = [ ]
4047 private var enabled : [ PluginID : Bool ] = [ : ]
@@ -43,91 +50,54 @@ public final class PluginManager {
4350 private var displayNames : [ PluginID : String ] = [ : ]
4451 private var versions : [ PluginID : String ] = [ : ]
4552
46- public static var defaultNativeParsers : [ any ParserPlugin ] {
47- [ NativeIBeaconParser ( ) , NativeWellKnownCharacteristicParser ( ) ]
48- }
49-
50- public init ( nativeParsers: [ any ParserPlugin ] = PluginManager . defaultNativeParsers) {
51- self . nativeParsers = nativeParsers
52- self . registry = ParserRegistry ( plugins: nativeParsers)
53- for parser in nativeParsers {
54- order. append ( parser. id)
55- enabled [ parser. id] = true
56- sources [ parser. id] = . native
57- displayNames [ parser. id] = parser. name
58- }
59- rebuild ( )
53+ public init ( ) {
54+ self . registry = ParserRegistry ( plugins: [ ] )
6055 }
6156
6257 // MARK: Loading
6358
64- /// Where installed plugins live on disk, once `loadInstalledPlugins()` has run.
59+ /// Where imported plugins live on disk, once `loadInstalledPlugins()` has run.
6560 public private( set) var directory : PluginDirectory ?
6661
67- /// Install bundled plugins into Documents on first launch, then load everything from there .
62+ /// Load bundled plugins from the app bundle and imported plugins from Documents .
6863 ///
69- /// This is the normal startup path: bundled and imported plugins end up in the same directory,
70- /// so there is a single load path and the user can see and manage every plugin in one place .
64+ /// Bundled plugins are referenced directly from `Bundle.module` — they are not copied anywhere.
65+ /// Only user-imported plugins are stored on disk, under `Documents/Plugins` .
7166 public func loadInstalledPlugins( ) {
72- do {
73- let directory = try PluginDirectory . default ( )
67+ loadBundledPlugins ( from : PluginEngineResources . bundle )
68+ if let directory = try ? PluginDirectory . default ( ) {
7469 self . directory = directory
75- let freshlyInstalled = Set ( try directory. installBundledPlugins ( from: PluginEngineResources . bundle) )
76- load ( from: directory, bundledIdentifiers: freshlyInstalled)
77- } catch {
78- // Falling back to the read-only bundle keeps parsing working even if Documents is
79- // unavailable; the user just cannot manage plugins this session.
80- loadBundledPlugins ( from: PluginEngineResources . bundle)
81- }
82- }
83-
84- private func load( from directory: PluginDirectory , bundledIdentifiers: Set < String > ) {
85- for manifestURL in directory. installedManifestURLs ( ) {
86- do {
87- let loaded = try PluginLoader . load ( manifestURL: manifestURL, verifyHash: true )
88- // Anything installed from the app bundle is "bundled", whether it landed this
89- // launch or a previous one; the install record is the source of truth.
90- let source : Source = bundledIdentifiers. contains ( loaded. manifest. identifier)
91- || bundledManifestIdentifiers. contains ( loaded. manifest. identifier)
92- ? . bundled : . imported
93- register ( loaded. plugin, manifest: loaded. manifest, source: source)
94- } catch let failure as PluginLoadFailure {
95- recordFailure ( failure, source: . imported)
96- } catch {
97- recordFailure ( PluginLoadFailure ( manifestName: manifestURL. lastPathComponent,
98- underlying: nil , message: " \( error) " ) , source: . imported)
99- }
70+ loadImportedPlugins ( from: directory)
10071 }
10172 rebuild ( )
10273 }
10374
104- /// Identifiers shipped inside the app bundle, used to label a plugin's origin in the UI.
105- private var bundledManifestIdentifiers : Set < String > {
106- let urls = PluginEngineResources . bundle. urls (
107- forResourcesWithExtension: " json " , subdirectory: " Plugins " ) ?? [ ]
108- var identifiers = Set < String > ( )
109- for url in urls. map ( { $0 as URL } )
110- where url. lastPathComponent. hasSuffix ( PluginDirectory . manifestSuffix) {
111- guard let data = try ? Data ( contentsOf: url) ,
112- let manifest = try ? JSONDecoder ( ) . decode ( PluginManifest . self, from: data)
113- else { continue }
114- identifiers. insert ( manifest. identifier)
115- }
116- return identifiers
117- }
118-
11975 /// Scan a bundle's `Plugins/` directory for `*.bleplugin.json` manifests and load each.
12076 public func loadBundledPlugins( from bundle: Bundle ) {
12177 let result = PluginLoader . loadBundled ( from: bundle)
12278 for loaded in result. loaded {
12379 register ( loaded. plugin, manifest: loaded. manifest, source: . bundled)
12480 }
12581 for failure in result. failures {
126- recordFailure ( failure)
82+ recordFailure ( failure, source : . bundled )
12783 }
12884 rebuild ( )
12985 }
13086
87+ private func loadImportedPlugins( from directory: PluginDirectory ) {
88+ for manifestURL in directory. installedManifestURLs ( ) {
89+ do {
90+ let loaded = try PluginLoader . load ( manifestURL: manifestURL, verifyHash: true )
91+ register ( loaded. plugin, manifest: loaded. manifest, source: . imported)
92+ } catch let failure as PluginLoadFailure {
93+ recordFailure ( failure, source: . imported)
94+ } catch {
95+ recordFailure ( PluginLoadFailure ( manifestName: manifestURL. lastPathComponent,
96+ underlying: nil , message: " \( error) " ) , source: . imported)
97+ }
98+ }
99+ }
100+
131101 // MARK: Importing
132102
133103 /// Why an import failed, in a form the UI can show directly.
@@ -164,27 +134,7 @@ public final class PluginManager {
164134 }
165135 }
166136
167- /// Load a single plugin from a manifest URL. `verifyHash` enforces the manifest `sha256`.
168- @discardableResult
169- public func loadPlugin( manifestURL: URL , source: Source , verifyHash: Bool ) -> PluginID ? {
170- do {
171- let loaded = try PluginLoader . load ( manifestURL: manifestURL, verifyHash: verifyHash)
172- register ( loaded. plugin, manifest: loaded. manifest, source: source)
173- rebuild ( )
174- return loaded. plugin. id
175- } catch let failure as PluginLoadFailure {
176- recordFailure ( failure, source: source)
177- rebuild ( )
178- return nil
179- } catch {
180- recordFailure ( PluginLoadFailure ( manifestName: manifestURL. lastPathComponent,
181- underlying: nil , message: " \( error) " ) , source: source)
182- rebuild ( )
183- return nil
184- }
185- }
186-
187- private func recordFailure( _ failure: PluginLoadFailure , source: Source = . bundled) {
137+ private func recordFailure( _ failure: PluginLoadFailure , source: Source ) {
188138 let syntheticID = PluginID ( failure. manifestName)
189139 if order. contains ( syntheticID) == false { order. append ( syntheticID) }
190140 sources [ syntheticID] = source
@@ -204,19 +154,20 @@ public final class PluginManager {
204154 loadErrors [ id] = nil
205155 }
206156
207- // MARK: Enable / disable / reload
157+ // MARK: Enable / disable / delete
208158
209159 public func setEnabled( _ isEnabled: Bool , id: PluginID ) {
210160 enabled [ id] = isEnabled
211161 UserDefaults . standard. set ( isEnabled, forKey: Self . enabledKey ( id) )
212162 rebuild ( )
213163 }
214164
215- /// Remove a plugin from the registry and delete it from the plugin directory.
165+ /// Delete an imported plugin, removing it from the registry and from the Documents directory.
216166 ///
217- /// A deleted bundled plugin is not reinstalled on the next launch: the install record still
218- /// lists it, so `installBundledPlugins` considers it already handled .
167+ /// Bundled plugins cannot be deleted — they live in the read-only app bundle — so this is a
168+ /// no-op for them. Use `setEnabled(false:)` to turn a bundled plugin off instead .
219169 public func removePlugin( id: PluginID ) {
170+ guard sources [ id] == . imported else { return }
220171 if let directory {
221172 try ? directory. remove ( identifier: id. rawValue)
222173 }
@@ -244,9 +195,6 @@ public final class PluginManager {
244195
245196 private func rebuild( ) {
246197 var activePlugins = [ any ParserPlugin ] ( )
247- for parser in nativeParsers where enabled [ parser. id] == true {
248- activePlugins. append ( parser)
249- }
250198 for id in order {
251199 guard enabled [ id] == true , let plugin = wasmPlugins [ id] else { continue }
252200 activePlugins. append ( plugin)
0 commit comments