Skip to content

Commit 384e6e8

Browse files
authored
Merge pull request #24 from MillerTechnologyPeru/feature/plugins-only
Plugins-only decoding: drop native parsers, bundle-reference plugins, deletable imports, root-package SDK
2 parents 6bcf086 + c9db767 commit 384e6e8

10 files changed

Lines changed: 181 additions & 436 deletions

File tree

Package.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ let package = Package(
66
defaultLocalization: "en",
77
platforms: [.iOS(.v18), .macOS(.v15)],
88
products: [
9-
.library(name: "BluetoothExplorer", type: .dynamic, targets: ["BluetoothExplorer"])
9+
.library(name: "BluetoothExplorer", type: .dynamic, targets: ["BluetoothExplorer"]),
10+
// Guest-side SDK for authoring parser plugins, so consumers can depend on this package and
11+
// `import BLEPluginSDK` from their wasm plugin. It is not a dependency of the app, so the app
12+
// and the iOS archive never build it. Sources are shared with the standalone
13+
// `PluginSDK/BLEPluginSDK` package that the in-repo example plugins use.
14+
.library(name: "BLEPluginSDK", targets: ["BLEPluginSDK"])
1015
],
1116
dependencies: [
1217
.package(url: "https://github.com/PureSwift/GATT.git", branch: "master"),
@@ -87,6 +92,18 @@ let package = Package(
8792
],
8893
resources: [.process("Resources")]
8994
),
95+
// Embedded-Swift-safe helpers for plugin authors: envelope decoding, a payload cursor, a
96+
// CBOR field builder, and the allocation/return glue. Built in Embedded mode (it compiles
97+
// for the host too, though nothing runs it there).
98+
.target(
99+
name: "BLEPluginSDK",
100+
path: "PluginSDK/BLEPluginSDK/Sources/BLEPluginSDK",
101+
swiftSettings: [
102+
.enableExperimentalFeature("Embedded"),
103+
.enableExperimentalFeature("Extern"),
104+
.unsafeFlags(["-wmo"])
105+
]
106+
),
90107
.testTarget(
91108
name: "BluetoothExplorerPluginEngineTests",
92109
dependencies: [

PluginSDK/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ data, a characteristic or a descriptor value — into labelled fields the app di
88
- `Examples/BatteryLevel/` — a complete plugin (GATT Battery Level, `0x2A19`) you can copy.
99
- `../Documentation/PluginABI.md` — the normative wire contract.
1010

11+
## Depending on the SDK
12+
13+
`BLEPluginSDK` is a product of the root `bluetooth-explorer` package, so an external plugin author
14+
can consume it by adding this repository as a dependency and importing `BLEPluginSDK`:
15+
16+
```swift
17+
.package(url: "https://github.com/MillerTechnologyPeru/BluetoothExplorer.git", branch: "master"),
18+
// ... .product(name: "BLEPluginSDK", package: "bluetooth-explorer")
19+
```
20+
21+
> **Note:** consuming the root package this way currently fails to resolve, because the app's
22+
> `AndroidSwiftUI` dependency uses a local sub-package that SwiftPM forbids in a branch-pinned
23+
> dependency graph. Until that is fixed upstream, build against the standalone
24+
> `PluginSDK/BLEPluginSDK` package instead — which is exactly what the in-repo examples do
25+
> (`.package(path: "../../BLEPluginSDK")`). Both manifests build the same sources.
26+
1127
## Prerequisites
1228

1329
```sh

Sources/BluetoothExplorerModel/Model/PluginManager.swift

Lines changed: 39 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
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

913
import Foundation
1014
import 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)

Sources/BluetoothExplorerPluginEngine/NativeParsers.swift

Lines changed: 0 additions & 112 deletions
This file was deleted.

0 commit comments

Comments
 (0)