From 76914ac621998b3e016e68463ad3a35033e9c7d1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:42 -0400 Subject: [PATCH 01/10] Remove built-in native decoders; all parsing is via plugins --- .../NativeParsers.swift | 112 ------------------ 1 file changed, 112 deletions(-) delete mode 100644 Sources/BluetoothExplorerPluginEngine/NativeParsers.swift diff --git a/Sources/BluetoothExplorerPluginEngine/NativeParsers.swift b/Sources/BluetoothExplorerPluginEngine/NativeParsers.swift deleted file mode 100644 index 37ff0ce..0000000 --- a/Sources/BluetoothExplorerPluginEngine/NativeParsers.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// NativeParsers.swift -// BluetoothExplorerPluginEngine -// -// Built-in Swift parsers registered alongside WASM plugins. They cover what the app decoded -// before the plugin system existed (iBeacon manufacturer data, well-known characteristics) and -// serve as the conformance twins for the bundled WASM reference plugins. -// - -import Foundation -import Bluetooth - -// MARK: - iBeacon - -/// Decodes Apple iBeacon manufacturer data (company `0x004C`, type `0x02`, length `0x15`). -public struct NativeIBeaconParser: ParserPlugin { - - public let id = PluginID("org.pureswift.native.ibeacon") - public let name = "iBeacon" - - private static let appleCompany: UInt16 = 0x004C - private static let iBeaconType: UInt8 = 0x02 - private static let iBeaconLength: UInt8 = 0x15 - - public init() {} - - public var routingKeys: RoutingKeys { - RoutingKeys(companyIdentifiers: [Self.appleCompany]) - } - - public func parse(_ request: ParseRequest) async -> DecodedResult? { - guard request.kind == .manufacturerData, request.companyID == Self.appleCompany else { - return nil - } - let data = [UInt8](request.payload) - // [type, length, uuid(16), major(2 BE), minor(2 BE), rssi(1)] - guard data.count == 23, data[0] == Self.iBeaconType, data[1] == Self.iBeaconLength else { - return nil - } - - let uuidTuple = (data[2], data[3], data[4], data[5], data[6], data[7], data[8], data[9], - data[10], data[11], data[12], data[13], data[14], data[15], data[16], data[17]) - let uuid = UUID(uuid: uuidTuple) - let major = (UInt16(data[18]) << 8) | UInt16(data[19]) - let minor = (UInt16(data[20]) << 8) | UInt16(data[21]) - let rssi = Int8(bitPattern: data[22]) - - return DecodedResult( - pluginID: id, - title: "iBeacon", - fields: [ - DecodedField(key: "uuid", label: "Proximity UUID", value: .uuid(uuid)), - DecodedField(key: "major", label: "Major", value: .uint(UInt64(major))), - DecodedField(key: "minor", label: "Minor", value: .uint(UInt64(minor))), - DecodedField(key: "tx_power", label: "Measured Power", value: .int(Int64(rssi)), unit: "dBm") - ] - ) - } -} - -// MARK: - Well-known characteristics - -/// Decodes a small set of standard GATT characteristics that carry a trivial representation -/// (battery level percentage and the Device Information string characteristics). -public struct NativeWellKnownCharacteristicParser: ParserPlugin { - - public let id = PluginID("org.pureswift.native.wellknown") - public let name = "Well-Known Characteristics" - - // 16-bit assigned numbers, used directly so no build-time-generated constants are required. - private static let batteryLevel = BluetoothUUID.bit16(0x2A19) - private static let stringCharacteristics: [(uuid: BluetoothUUID, label: String)] = [ - (.bit16(0x2A00), "Device Name"), - (.bit16(0x2A29), "Manufacturer Name"), - (.bit16(0x2A24), "Model Number"), - (.bit16(0x2A25), "Serial Number"), - (.bit16(0x2A26), "Firmware Revision"), - (.bit16(0x2A27), "Hardware Revision"), - (.bit16(0x2A28), "Software Revision") - ] - - public init() {} - - public var routingKeys: RoutingKeys { - RoutingKeys(characteristicUUIDs: [Self.batteryLevel] + Self.stringCharacteristics.map(\.uuid)) - } - - public func parse(_ request: ParseRequest) async -> DecodedResult? { - guard request.kind == .characteristic, let uuid = request.uuid else { return nil } - guard request.payload.isEmpty == false else { return nil } - - if uuid == Self.batteryLevel { - guard let level = request.payload.first else { return nil } - return DecodedResult( - pluginID: id, - title: "Battery Level", - fields: [DecodedField(key: "level", label: "Battery Level", value: .uint(UInt64(level)), unit: "%")] - ) - } - - if let match = Self.stringCharacteristics.first(where: { $0.uuid == uuid }) { - guard let string = String(data: request.payload, encoding: .utf8) else { return nil } - return DecodedResult( - pluginID: id, - title: match.label, - fields: [DecodedField(key: "value", label: match.label, value: .string(string))] - ) - } - - return nil - } -} From f91e844e4764731d8a0d2d871aa807dc94e2a1b8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:38:54 -0400 Subject: [PATCH 02/10] Drop native parsers; reference bundled plugins from the bundle --- .../Model/PluginManager.swift | 130 ++++++------------ 1 file changed, 39 insertions(+), 91 deletions(-) diff --git a/Sources/BluetoothExplorerModel/Model/PluginManager.swift b/Sources/BluetoothExplorerModel/Model/PluginManager.swift index 8aab2c2..d9b1e14 100644 --- a/Sources/BluetoothExplorerModel/Model/PluginManager.swift +++ b/Sources/BluetoothExplorerModel/Model/PluginManager.swift @@ -5,6 +5,10 @@ // 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 @@ -16,8 +20,9 @@ public final class PluginManager { /// Where a plugin came from. public enum Source: Equatable, Sendable { - case native + /// 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 } @@ -29,12 +34,14 @@ public final class PluginManager { 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 let nativeParsers: [any ParserPlugin] private var wasmPlugins: [PluginID: WasmParserPlugin] = [:] private var order: [PluginID] = [] private var enabled: [PluginID: Bool] = [:] @@ -43,79 +50,28 @@ public final class PluginManager { private var displayNames: [PluginID: String] = [:] private var versions: [PluginID: String] = [:] - public static var defaultNativeParsers: [any ParserPlugin] { - [NativeIBeaconParser(), NativeWellKnownCharacteristicParser()] - } - - public init(nativeParsers: [any ParserPlugin] = PluginManager.defaultNativeParsers) { - self.nativeParsers = nativeParsers - self.registry = ParserRegistry(plugins: nativeParsers) - for parser in nativeParsers { - order.append(parser.id) - enabled[parser.id] = true - sources[parser.id] = .native - displayNames[parser.id] = parser.name - } - rebuild() + public init() { + self.registry = ParserRegistry(plugins: []) } // MARK: Loading - /// Where installed plugins live on disk, once `loadInstalledPlugins()` has run. + /// Where imported plugins live on disk, once `loadInstalledPlugins()` has run. public private(set) var directory: PluginDirectory? - /// Install bundled plugins into Documents on first launch, then load everything from there. + /// Load bundled plugins from the app bundle and imported plugins from Documents. /// - /// This is the normal startup path: bundled and imported plugins end up in the same directory, - /// so there is a single load path and the user can see and manage every plugin in one place. + /// 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() { - do { - let directory = try PluginDirectory.default() + loadBundledPlugins(from: PluginEngineResources.bundle) + if let directory = try? PluginDirectory.default() { self.directory = directory - let freshlyInstalled = Set(try directory.installBundledPlugins(from: PluginEngineResources.bundle)) - load(from: directory, bundledIdentifiers: freshlyInstalled) - } catch { - // Falling back to the read-only bundle keeps parsing working even if Documents is - // unavailable; the user just cannot manage plugins this session. - loadBundledPlugins(from: PluginEngineResources.bundle) - } - } - - private func load(from directory: PluginDirectory, bundledIdentifiers: Set) { - for manifestURL in directory.installedManifestURLs() { - do { - let loaded = try PluginLoader.load(manifestURL: manifestURL, verifyHash: true) - // Anything installed from the app bundle is "bundled", whether it landed this - // launch or a previous one; the install record is the source of truth. - let source: Source = bundledIdentifiers.contains(loaded.manifest.identifier) - || bundledManifestIdentifiers.contains(loaded.manifest.identifier) - ? .bundled : .imported - register(loaded.plugin, manifest: loaded.manifest, source: source) - } catch let failure as PluginLoadFailure { - recordFailure(failure, source: .imported) - } catch { - recordFailure(PluginLoadFailure(manifestName: manifestURL.lastPathComponent, - underlying: nil, message: "\(error)"), source: .imported) - } + loadImportedPlugins(from: directory) } rebuild() } - /// Identifiers shipped inside the app bundle, used to label a plugin's origin in the UI. - private var bundledManifestIdentifiers: Set { - let urls = PluginEngineResources.bundle.urls( - forResourcesWithExtension: "json", subdirectory: "Plugins") ?? [] - var identifiers = Set() - for url in urls.map({ $0 as URL }) - where url.lastPathComponent.hasSuffix(PluginDirectory.manifestSuffix) { - guard let data = try? Data(contentsOf: url), - let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data) - else { continue } - identifiers.insert(manifest.identifier) - } - return identifiers - } - /// 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) @@ -123,11 +79,25 @@ public final class PluginManager { register(loaded.plugin, manifest: loaded.manifest, source: .bundled) } for failure in result.failures { - recordFailure(failure) + 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. @@ -164,27 +134,7 @@ public final class PluginManager { } } - /// Load a single plugin from a manifest URL. `verifyHash` enforces the manifest `sha256`. - @discardableResult - public func loadPlugin(manifestURL: URL, source: Source, verifyHash: Bool) -> PluginID? { - do { - let loaded = try PluginLoader.load(manifestURL: manifestURL, verifyHash: verifyHash) - register(loaded.plugin, manifest: loaded.manifest, source: source) - rebuild() - return loaded.plugin.id - } catch let failure as PluginLoadFailure { - recordFailure(failure, source: source) - rebuild() - return nil - } catch { - recordFailure(PluginLoadFailure(manifestName: manifestURL.lastPathComponent, - underlying: nil, message: "\(error)"), source: source) - rebuild() - return nil - } - } - - private func recordFailure(_ failure: PluginLoadFailure, source: Source = .bundled) { + private func recordFailure(_ failure: PluginLoadFailure, source: Source) { let syntheticID = PluginID(failure.manifestName) if order.contains(syntheticID) == false { order.append(syntheticID) } sources[syntheticID] = source @@ -204,7 +154,7 @@ public final class PluginManager { loadErrors[id] = nil } - // MARK: Enable / disable / reload + // MARK: Enable / disable / delete public func setEnabled(_ isEnabled: Bool, id: PluginID) { enabled[id] = isEnabled @@ -212,11 +162,12 @@ public final class PluginManager { rebuild() } - /// Remove a plugin from the registry and delete it from the plugin directory. + /// Delete an imported plugin, removing it from the registry and from the Documents directory. /// - /// A deleted bundled plugin is not reinstalled on the next launch: the install record still - /// lists it, so `installBundledPlugins` considers it already handled. + /// 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) } @@ -244,9 +195,6 @@ public final class PluginManager { private func rebuild() { var activePlugins = [any ParserPlugin]() - for parser in nativeParsers where enabled[parser.id] == true { - activePlugins.append(parser) - } for id in order { guard enabled[id] == true, let plugin = wasmPlugins[id] else { continue } activePlugins.append(plugin) From 7aa586466f087ffa270778e9330fbd2d6a54f7ec Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:42 -0400 Subject: [PATCH 03/10] Store only imported plugins on disk, not bundled ones --- .../PluginDirectory.swift | 78 +++---------------- 1 file changed, 9 insertions(+), 69 deletions(-) diff --git a/Sources/BluetoothExplorerPluginEngine/PluginDirectory.swift b/Sources/BluetoothExplorerPluginEngine/PluginDirectory.swift index 691739e..9ce4b46 100644 --- a/Sources/BluetoothExplorerPluginEngine/PluginDirectory.swift +++ b/Sources/BluetoothExplorerPluginEngine/PluginDirectory.swift @@ -2,30 +2,26 @@ // PluginDirectory.swift // BluetoothExplorerPluginEngine // -// On-disk plugin storage under the app's Documents directory. +// On-disk storage for user-imported plugins, under the app's Documents directory. // -// Every plugin — bundled or user-imported — lives here, so there is one code path for loading and -// one place a user can inspect. Bundled plugins are copied in on first launch and refreshed when a -// new app build ships a different module; a bundled plugin the user deletes stays deleted, because -// the install record remembers that it was already handled once. +// Bundled plugins are NOT stored here — they are referenced read-only from the app bundle. This +// directory holds only plugins the user imported, one sub-directory per plugin, so they can be +// listed, loaded, and deleted. // // Layout: // Documents/Plugins/ // / // .bleplugin.json // .wasm -// .installed-bundled.json // import Foundation public struct PluginDirectory: Sendable { - /// Root of the plugin store, e.g. `Documents/Plugins`. + /// Root of the imported-plugin store, e.g. `Documents/Plugins`. public let url: URL - private static let recordName = ".installed-bundled.json" - public init(url: URL) { self.url = url } @@ -43,9 +39,11 @@ public struct PluginDirectory: Sendable { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } + public static let manifestSuffix = ".bleplugin.json" + // MARK: Listing - /// Manifest URLs for every installed plugin, one per plugin sub-directory. + /// Manifest URLs for every imported plugin, one per plugin sub-directory. public func installedManifestURLs() -> [URL] { let contents = (try? FileManager.default.contentsOfDirectory( at: url, includingPropertiesForKeys: nil)) ?? [] @@ -64,50 +62,6 @@ public struct PluginDirectory: Sendable { return manifests.sorted { $0.path < $1.path } } - public static let manifestSuffix = ".bleplugin.json" - - // MARK: Installing bundled plugins - - /// Copy bundled plugins into the store. - /// - /// A plugin is installed when it has never been installed before (first launch), or when the - /// bundled module's hash differs from the one recorded (a new app build shipped an update). - /// Plugins the user deleted are not resurrected. - /// - Returns: the identifiers that were installed or refreshed. - @discardableResult - public func installBundledPlugins(from bundle: Bundle) throws -> [String] { - try createIfNeeded() - var record = installRecord() - var installed = [String]() - - let discovered = bundle.urls(forResourcesWithExtension: "json", subdirectory: "Plugins") ?? [] - for manifestURL in discovered.map({ $0 as URL }) - where manifestURL.lastPathComponent.hasSuffix(PluginDirectory.manifestSuffix) { - guard let data = try? Data(contentsOf: manifestURL), - let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data) - else { continue } - - // The manifest's sha256 identifies this build of the module. Absent one, fall back to - // the version string so a plugin without a hash still refreshes across releases. - let stamp = manifest.sha256 ?? manifest.version - if record[manifest.identifier] == stamp { continue } - - let moduleURL = manifestURL.deletingLastPathComponent() - .appendingPathComponent(manifest.module) - try install(manifestURL: manifestURL, moduleURL: moduleURL, identifier: manifest.identifier) - record[manifest.identifier] = stamp - installed.append(manifest.identifier) - } - - try write(record: record) - return installed - } - - /// True when no bundled plugin has ever been installed — i.e. this is a first launch. - public var isFirstLaunch: Bool { - FileManager.default.fileExists(atPath: recordURL.path) == false - } - // MARK: Importing /// Import a plugin the user picked, copying its manifest and module into the store. @@ -128,7 +82,7 @@ public struct PluginDirectory: Sendable { return loaded.manifest } - /// Delete an installed plugin's directory. + /// Delete an imported plugin's directory. public func remove(identifier: String) throws { let directory = url.appendingPathComponent(Self.folderName(for: identifier), isDirectory: true) guard FileManager.default.fileExists(atPath: directory.path) else { return } @@ -162,18 +116,4 @@ public struct PluginDirectory: Sendable { } return name.isEmpty ? "plugin" : name } - - private var recordURL: URL { url.appendingPathComponent(PluginDirectory.recordName) } - - private func installRecord() -> [String: String] { - guard let data = try? Data(contentsOf: recordURL), - let record = try? JSONDecoder().decode([String: String].self, from: data) - else { return [:] } - return record - } - - private func write(record: [String: String]) throws { - let data = try JSONEncoder().encode(record) - try data.write(to: recordURL) - } } From 01c4e9b1a4713f4d144b30b17bc51799659cd723 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:43 -0400 Subject: [PATCH 04/10] Delete only imported plugins; update plugin copy --- Sources/BluetoothExplorerUI/PluginsView.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Sources/BluetoothExplorerUI/PluginsView.swift b/Sources/BluetoothExplorerUI/PluginsView.swift index 3aca61e..3cf1cad 100644 --- a/Sources/BluetoothExplorerUI/PluginsView.swift +++ b/Sources/BluetoothExplorerUI/PluginsView.swift @@ -2,9 +2,9 @@ // PluginsView.swift // BluetoothExplorerUI // -// Manages parser plugins: enable/disable, import from a file, delete, and surface load errors. -// Bundled plugins are copied into the app's Documents directory on first launch, so everything -// listed here lives in one place the user can inspect. +// Manages parser plugins: enable/disable, import from a file, delete imported ones, and surface +// load errors. Bundled plugins are shipped in the app bundle (read-only, disable to turn off); +// imported plugins live in the app's Documents directory and can be deleted. // #if canImport(SwiftUI) @@ -40,7 +40,7 @@ public struct PluginsView: View { } header: { Text("Parsers") } footer: { - Text("Plugins decode advertisement and characteristic values. Built-in parsers are native; others are WebAssembly modules stored in the app's Documents folder.") + Text("Plugins are WebAssembly modules that decode advertisement and characteristic values. Bundled plugins ship with the app and can be disabled; imported plugins are stored in the app's Documents folder and can be deleted.") } if let error = importError { @@ -94,8 +94,8 @@ public struct PluginsView: View { let plugins = store.pluginManager.plugins for index in offsets where index < plugins.count { let state = plugins[index] - // Native parsers are compiled in; there is nothing on disk to remove. - guard state.source != .native else { continue } + // Only imported plugins are on disk; bundled ones are read-only in the app bundle. + guard state.isRemovable else { continue } store.pluginManager.removePlugin(id: state.id) } } @@ -133,7 +133,6 @@ public struct PluginsView: View { private func sourceLabel(_ source: PluginManager.Source) -> String { switch source { - case .native: return "Built-in" case .bundled: return "Bundled" case .imported: return "Imported" } From 343e5dc62bbe13968e5b9a0f84c195fcf8b67076 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:43 -0400 Subject: [PATCH 05/10] Replace native-parser tests with a routing stub --- .../CoreTests.swift | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/Tests/BluetoothExplorerPluginEngineTests/CoreTests.swift b/Tests/BluetoothExplorerPluginEngineTests/CoreTests.swift index 26a0d3f..98defff 100644 --- a/Tests/BluetoothExplorerPluginEngineTests/CoreTests.swift +++ b/Tests/BluetoothExplorerPluginEngineTests/CoreTests.swift @@ -94,56 +94,42 @@ struct MicroCBORTests { } } -@Suite("Native parsers") -struct NativeParserTests { - - @Test("iBeacon decode") - func iBeacon() async throws { - // type 0x02, len 0x15, uuid(16), major 0x0001, minor 0x0002, rssi 0xC5 (-59) - var payload: [UInt8] = [0x02, 0x15] - payload += (0..<16).map { UInt8($0 + 1) } - payload += [0x00, 0x01, 0x00, 0x02, 0xC5] - let request = ParseRequest(kind: .manufacturerData, companyID: 0x004C, payload: Data(payload)) - let result = try #require(await NativeIBeaconParser().parse(request)) - #expect(result.title == "iBeacon") - #expect(result.fields.first(where: { $0.key == "major" })?.value == .uint(1)) - #expect(result.fields.first(where: { $0.key == "minor" })?.value == .uint(2)) - #expect(result.fields.first(where: { $0.key == "tx_power" })?.value == .int(-59)) - } - - @Test("Non-Apple company is ignored") - func iBeaconWrongCompany() async { - let request = ParseRequest(kind: .manufacturerData, companyID: 0x0075, payload: Data([0x02, 0x15])) - #expect(await NativeIBeaconParser().parse(request) == nil) - } - - @Test("Battery level and string characteristics") - func wellKnown() async throws { - let parser = NativeWellKnownCharacteristicParser() - let battery = try #require(await parser.parse( - ParseRequest(kind: .characteristic, uuid: .bit16(0x2A19), payload: Data([99])))) - #expect(battery.fields.first?.value == .uint(99)) - #expect(battery.fields.first?.unit == "%") - - let name = try #require(await parser.parse( - ParseRequest(kind: .characteristic, uuid: .bit16(0x2A29), payload: Data("Acme".utf8)))) - #expect(name.fields.first?.value == .string("Acme")) - } -} - @Suite("Registry routing") struct RegistryTests { + /// A trivial in-test parser so routing can be exercised without loading a wasm module. + private struct StubParser: ParserPlugin { + let id: PluginID + let name = "Stub" + let routingKeys: RoutingKeys + func parse(_ request: ParseRequest) async -> DecodedResult? { + DecodedResult(pluginID: id, title: "Stub", + fields: [DecodedField(key: "value", label: "Value", + value: .uint(UInt64(request.payload.first ?? 0)))]) + } + } + @Test("Routes to matching plugin only") func routing() async { - let registry = ParserRegistry(plugins: [NativeIBeaconParser(), NativeWellKnownCharacteristicParser()]) - // No plugin claims company 0x0075 → empty. - let none = await registry.decodeAll(ParseRequest(kind: .manufacturerData, companyID: 0x0075, payload: Data())) + let battery = StubParser( + id: PluginID("test.battery"), + routingKeys: RoutingKeys(characteristicUUIDs: [.bit16(0x2A19)])) + let registry = ParserRegistry(plugins: [battery]) + + // No plugin claims characteristic 0x2A37 → empty. + let none = await registry.decodeAll( + ParseRequest(kind: .characteristic, uuid: .bit16(0x2A37), payload: Data())) #expect(none.isEmpty) - // Battery routes to the well-known parser. - let battery = await registry.decodeFirst( + + // A different kind (manufacturer data) with the same number does not route here either. + let wrongKind = await registry.decodeAll( + ParseRequest(kind: .manufacturerData, companyID: 0x2A19, payload: Data())) + #expect(wrongKind.isEmpty) + + // Battery routes to the registered parser. + let hit = await registry.decodeFirst( ParseRequest(kind: .characteristic, uuid: .bit16(0x2A19), payload: Data([50]))) - #expect(battery?.fields.first?.value == .uint(50)) + #expect(hit?.fields.first?.value == .uint(50)) } } From eb99aebdaa490ebaf35000d059913b566764bb3e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:43 -0400 Subject: [PATCH 06/10] Drop native oracle from battery tests --- .../WasmTests.swift | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/Tests/BluetoothExplorerPluginEngineTests/WasmTests.swift b/Tests/BluetoothExplorerPluginEngineTests/WasmTests.swift index fe75284..af425c3 100644 --- a/Tests/BluetoothExplorerPluginEngineTests/WasmTests.swift +++ b/Tests/BluetoothExplorerPluginEngineTests/WasmTests.swift @@ -139,18 +139,13 @@ struct WasmRunnerTests { #expect(plugin.isQuarantined) } - @Test("WASM battery output matches the native parser") - func wasmNativeParity() async throws { + @Test("WASM battery output decodes each level") + func wasmBatteryDecode() async throws { let wasm = try [UInt8](wat2wasm(batteryWAT)) let wasmPlugin = try WasmParserPlugin(manifest: batteryManifest(), moduleBytes: wasm) - let native = NativeWellKnownCharacteristicParser() - // 0...100 only: the GATT plugin enforces the spec's valid range, unlike the legacy - // native parser, so 255 is deliberately excluded here and asserted separately below. - for level: UInt8 in [0, 1, 42, 99, 100] { + for level: UInt8 in [0, 1, 42, 99, 100, 255] { let request = ParseRequest(kind: .characteristic, uuid: .bit16(0x2A19), payload: Data([level])) let wasmResult = try #require(await wasmPlugin.parse(request)) - let nativeResult = try #require(await native.parse(request)) - #expect(wasmResult.fields.first?.value == nativeResult.fields.first?.value) #expect(wasmResult.fields.first?.value == .uint(UInt64(level))) } } @@ -173,34 +168,27 @@ struct PluginLoaderTests { #expect(decoded.fields.first?.value == .uint(88)) } - @Test("Bundled Embedded Swift plugin matches the native parser across values") - func bundledSwiftPluginParity() async throws { + @Test("Bundled battery plugin decodes valid levels and rejects out-of-range") + func bundledBatteryDecode() async throws { let result = PluginLoader.loadBundled(from: PluginEngineResources.bundle) let battery = try #require(result.loaded.first { $0.manifest.identifier == "org.pureswift.plugin.gatt-battery" }) - let native = NativeWellKnownCharacteristicParser() var failures = [UInt8]() - // 0...100 only: the GATT plugin enforces the spec's valid range, unlike the legacy - // native parser, so 255 is deliberately excluded here and asserted separately below. for level: UInt8 in [0, 1, 42, 99, 100] { let request = ParseRequest(kind: .characteristic, uuid: .bit16(0x2A19), payload: Data([level])) guard let wasmResult = await battery.plugin.parse(request) else { failures.append(level) continue } - let nativeResult = try #require(await native.parse(request)) #expect(wasmResult.fields.first?.value == .uint(UInt64(level)), "level \(level)") - #expect(wasmResult.fields.first?.value == nativeResult.fields.first?.value, "level \(level)") #expect(wasmResult.fields.first?.unit == "%", "level \(level)") } #expect(failures.isEmpty, "levels returning nil: \(failures)") - // Out-of-range battery level: the plugin rejects it (GATTBatteryPercentage is 0...100) - // where the legacy native parser happily returns 255. + // Out of range: the GATT plugin enforces GATTBatteryPercentage's 0...100 and rejects 255. let invalid = ParseRequest(kind: .characteristic, uuid: .bit16(0x2A19), payload: Data([255])) #expect(await battery.plugin.parse(invalid) == nil) - #expect(await native.parse(invalid) != nil) } @Test("Bundled plugin declines a payload it does not recognize") From 53ab451e15edc0540b2e75df283759a36a56b093 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:43 -0400 Subject: [PATCH 07/10] Check iBeacon fields against hand-computed values --- .../IBeaconPluginTests.swift | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/Tests/BluetoothExplorerPluginEngineTests/IBeaconPluginTests.swift b/Tests/BluetoothExplorerPluginEngineTests/IBeaconPluginTests.swift index 5c94075..6f3989d 100644 --- a/Tests/BluetoothExplorerPluginEngineTests/IBeaconPluginTests.swift +++ b/Tests/BluetoothExplorerPluginEngineTests/IBeaconPluginTests.swift @@ -3,8 +3,8 @@ // BluetoothExplorerPluginEngineTests // // Exercises the advertisement (manufacturer-data) path end to end: the bundled Embedded Swift -// iBeacon module runs under the interpreter and its output is diffed field-by-field against -// NativeIBeaconParser. This is the only coverage of `bleplug_parse_manufacturer`. +// iBeacon module runs under the interpreter and its decoded fields are checked against +// hand-computed values. This is the only coverage of `bleplug_parse_manufacturer`. // import Foundation @@ -57,10 +57,9 @@ struct IBeaconPluginTests { #expect(result.fields.first(where: { $0.key == "uuid" })?.value == .uuid(expectedUUID)) } - @Test("WASM output matches the native parser field for field") - func matchesNativeParser() async throws { + @Test("Decodes major, minor and measured power across values") + func decodesFields() async throws { let plugin = try loadPlugin() - let native = NativeIBeaconParser() let cases: [(UInt16, UInt16, Int8)] = [ (0, 0, 0), @@ -75,24 +74,18 @@ struct IBeaconPluginTests { companyID: 0x004C, payload: payload(major: major, minor: minor, measuredPower: power) ) - let wasmResult = try #require(await plugin.parse(request), "wasm declined \(major)/\(minor)") - let nativeResult = try #require(await native.parse(request)) - - #expect(wasmResult.title == nativeResult.title, "major \(major)") - #expect(wasmResult.fields.count == nativeResult.fields.count, "major \(major)") - for (wasmField, nativeField) in zip(wasmResult.fields, nativeResult.fields) { - #expect(wasmField.key == nativeField.key) - #expect(wasmField.label == nativeField.label) - #expect(wasmField.value == nativeField.value, "field \(wasmField.key), major \(major)") - #expect(wasmField.unit == nativeField.unit) - } + let result = try #require(await plugin.parse(request), "declined \(major)/\(minor)") + #expect(result.title == "iBeacon", "major \(major)") + #expect(result.fields.first { $0.key == "major" }?.value == .uint(UInt64(major)), "major \(major)") + #expect(result.fields.first { $0.key == "minor" }?.value == .uint(UInt64(minor)), "minor \(minor)") + #expect(result.fields.first { $0.key == "tx_power" }?.value == .int(Int64(power)), "power \(power)") + #expect(result.fields.first { $0.key == "tx_power" }?.unit == "dBm") } } - @Test("Non-iBeacon Apple payloads are declined, like the native parser") + @Test("Non-iBeacon Apple payloads are declined") func declinesNonBeacon() async throws { let plugin = try loadPlugin() - let native = NativeIBeaconParser() // Apple uses this company id for many formats; only 0x02/0x15 is an iBeacon. let notBeacons: [Data] = [ @@ -104,8 +97,7 @@ struct IBeaconPluginTests { ] for data in notBeacons { let request = ParseRequest(kind: .manufacturerData, companyID: 0x004C, payload: data) - #expect(await plugin.parse(request) == nil, "wasm accepted \(data as NSData)") - #expect(await native.parse(request) == nil, "native accepted \(data as NSData)") + #expect(await plugin.parse(request) == nil, "accepted \(data as NSData)") } } From 31235a159ff0dfda5371f7d8a4354e81113cf753 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:27:43 -0400 Subject: [PATCH 08/10] Test import, delete and validation for the imported-plugin store --- .../PluginDirectoryTests.swift | 121 +++++++----------- 1 file changed, 46 insertions(+), 75 deletions(-) diff --git a/Tests/BluetoothExplorerPluginEngineTests/PluginDirectoryTests.swift b/Tests/BluetoothExplorerPluginEngineTests/PluginDirectoryTests.swift index 78fe1f9..e2716e8 100644 --- a/Tests/BluetoothExplorerPluginEngineTests/PluginDirectoryTests.swift +++ b/Tests/BluetoothExplorerPluginEngineTests/PluginDirectoryTests.swift @@ -2,10 +2,12 @@ // PluginDirectoryTests.swift // BluetoothExplorerPluginEngineTests // -// Covers the Documents-backed plugin store: first-launch install of the bundled plugins, -// idempotence across launches, that a deleted bundled plugin stays deleted, and import validation. +// Covers the Documents-backed store for user-imported plugins: import validation, that a bad +// import writes nothing, deletion, and identifier-to-folder-name sanitising. // -// Every test runs against a temporary directory — never the real Documents folder. +// Bundled plugins are not stored here (they are referenced read-only from the app bundle), so +// these tests stage a bundled plugin's files into a scratch directory to stand in for a user's +// imported file. Every test runs against a temporary directory — never the real Documents folder. // import Foundation @@ -23,76 +25,33 @@ struct PluginDirectoryTests { return directory } - private func bundledManifestCount() -> Int { - let urls = PluginEngineResources.bundle.urls( - forResourcesWithExtension: "json", subdirectory: "Plugins") ?? [] - return urls.map { $0 as URL } + /// Copy one bundled plugin's manifest and module into a fresh scratch directory, standing in + /// for a file the user picked from outside the app. Returns the staged manifest URL. + private func stageBundledPlugin() throws -> URL { + let manifestURLs = (PluginEngineResources.bundle.urls( + forResourcesWithExtension: "json", subdirectory: "Plugins") ?? []) + .map { $0 as URL } .filter { $0.lastPathComponent.hasSuffix(PluginDirectory.manifestSuffix) } - .count + let manifestURL = try #require(manifestURLs.first) + let manifest = try JSONDecoder().decode( + PluginManifest.self, from: try Data(contentsOf: manifestURL)) + let moduleURL = manifestURL.deletingLastPathComponent().appendingPathComponent(manifest.module) + + let scratch = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("bleplug-stage-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + let stagedManifest = scratch.appendingPathComponent(manifestURL.lastPathComponent) + try Data(contentsOf: manifestURL).write(to: stagedManifest) + try Data(contentsOf: moduleURL).write(to: scratch.appendingPathComponent(manifest.module)) + return stagedManifest } - @Test("First launch installs every bundled plugin") - func firstLaunchInstalls() throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory.url) } - - #expect(directory.isFirstLaunch) - let installed = try directory.installBundledPlugins(from: PluginEngineResources.bundle) - #expect(installed.count == bundledManifestCount()) - #expect(installed.isEmpty == false) - #expect(directory.isFirstLaunch == false) - #expect(directory.installedManifestURLs().count == installed.count) - } - - @Test("Installed plugins load from the directory") - func installedPluginsLoad() throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory.url) } - try directory.installBundledPlugins(from: PluginEngineResources.bundle) - - for manifestURL in directory.installedManifestURLs() { - // Hash verification is on: a copy that lost bytes would fail here. - _ = try PluginLoader.load(manifestURL: manifestURL, verifyHash: true) - } - } - - @Test("A second launch installs nothing new") - func secondLaunchIsIdempotent() throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory.url) } - - let first = try directory.installBundledPlugins(from: PluginEngineResources.bundle) - let second = try directory.installBundledPlugins(from: PluginEngineResources.bundle) - #expect(second.isEmpty, "reinstalled on second launch: \(second)") - #expect(directory.installedManifestURLs().count == first.count) - } - - @Test("A deleted bundled plugin is not resurrected on the next launch") - func deletedPluginStaysDeleted() throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory.url) } - - let installed = try directory.installBundledPlugins(from: PluginEngineResources.bundle) - let victim = try #require(installed.first) - try directory.remove(identifier: victim) - #expect(directory.installedManifestURLs().count == installed.count - 1) - - let afterRelaunch = try directory.installBundledPlugins(from: PluginEngineResources.bundle) - #expect(afterRelaunch.isEmpty, "deleted plugin came back: \(afterRelaunch)") - #expect(directory.installedManifestURLs().count == installed.count - 1) - } - - @Test("Importing a valid plugin copies it into the store") + @Test("Importing a valid plugin copies it into the store and loads") func importValidPlugin() throws { - let source = try makeTemporaryDirectory() let destination = try makeTemporaryDirectory() - defer { - try? FileManager.default.removeItem(at: source.url) - try? FileManager.default.removeItem(at: destination.url) - } - // Stage a plugin outside the store, the way a user's file would arrive. - try source.installBundledPlugins(from: PluginEngineResources.bundle) - let staged = try #require(source.installedManifestURLs().first) + defer { try? FileManager.default.removeItem(at: destination.url) } + let staged = try stageBundledPlugin() + defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) } let manifest = try destination.importPlugin(manifestURL: staged) #expect(destination.installedManifestURLs().count == 1) @@ -104,14 +63,10 @@ struct PluginDirectoryTests { @Test("Importing a plugin with a wrong hash fails and writes nothing") func importRejectsBadHash() throws { - let source = try makeTemporaryDirectory() let destination = try makeTemporaryDirectory() - defer { - try? FileManager.default.removeItem(at: source.url) - try? FileManager.default.removeItem(at: destination.url) - } - try source.installBundledPlugins(from: PluginEngineResources.bundle) - let staged = try #require(source.installedManifestURLs().first) + defer { try? FileManager.default.removeItem(at: destination.url) } + let staged = try stageBundledPlugin() + defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) } // Corrupt the manifest's hash in place. let data = try Data(contentsOf: staged) @@ -126,6 +81,22 @@ struct PluginDirectoryTests { #expect(destination.installedManifestURLs().isEmpty) } + @Test("An imported plugin can be removed") + func removeImportedPlugin() throws { + let destination = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: destination.url) } + let staged = try stageBundledPlugin() + defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) } + + let manifest = try destination.importPlugin(manifestURL: staged) + #expect(destination.installedManifestURLs().count == 1) + + try destination.remove(identifier: manifest.identifier) + #expect(destination.installedManifestURLs().isEmpty) + // Removing something that is not there is a no-op, not an error. + try destination.remove(identifier: manifest.identifier) + } + @Test("Identifiers map to filesystem-safe folder names") func folderNames() { #expect(PluginDirectory.folderName(for: "org.pureswift.plugin.gatt-time") == "org.pureswift.plugin.gatt-time") From f640b92b48a3c25a1796578aa481fd5fd040dbff Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:43:03 -0400 Subject: [PATCH 09/10] Expose BLEPluginSDK as a product of the root package --- Package.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index fc5f88f..8ec7d73 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,12 @@ let package = Package( defaultLocalization: "en", platforms: [.iOS(.v18), .macOS(.v15)], products: [ - .library(name: "BluetoothExplorer", type: .dynamic, targets: ["BluetoothExplorer"]) + .library(name: "BluetoothExplorer", type: .dynamic, targets: ["BluetoothExplorer"]), + // Guest-side SDK for authoring parser plugins, so consumers can depend on this package and + // `import BLEPluginSDK` from their wasm plugin. It is not a dependency of the app, so the app + // and the iOS archive never build it. Sources are shared with the standalone + // `PluginSDK/BLEPluginSDK` package that the in-repo example plugins use. + .library(name: "BLEPluginSDK", targets: ["BLEPluginSDK"]) ], dependencies: [ .package(url: "https://github.com/PureSwift/GATT.git", branch: "master"), @@ -87,6 +92,18 @@ let package = Package( ], resources: [.process("Resources")] ), + // Embedded-Swift-safe helpers for plugin authors: envelope decoding, a payload cursor, a + // CBOR field builder, and the allocation/return glue. Built in Embedded mode (it compiles + // for the host too, though nothing runs it there). + .target( + name: "BLEPluginSDK", + path: "PluginSDK/BLEPluginSDK/Sources/BLEPluginSDK", + swiftSettings: [ + .enableExperimentalFeature("Embedded"), + .enableExperimentalFeature("Extern"), + .unsafeFlags(["-wmo"]) + ] + ), .testTarget( name: "BluetoothExplorerPluginEngineTests", dependencies: [ From c9db767bf9cef7f2a47d51f2e8108bf7593dd32c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 09:43:03 -0400 Subject: [PATCH 10/10] Document depending on BLEPluginSDK from the root package --- PluginSDK/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/PluginSDK/README.md b/PluginSDK/README.md index a9ed257..784d93c 100644 --- a/PluginSDK/README.md +++ b/PluginSDK/README.md @@ -8,6 +8,22 @@ data, a characteristic or a descriptor value — into labelled fields the app di - `Examples/BatteryLevel/` — a complete plugin (GATT Battery Level, `0x2A19`) you can copy. - `../Documentation/PluginABI.md` — the normative wire contract. +## Depending on the SDK + +`BLEPluginSDK` is a product of the root `bluetooth-explorer` package, so an external plugin author +can consume it by adding this repository as a dependency and importing `BLEPluginSDK`: + +```swift +.package(url: "https://github.com/MillerTechnologyPeru/BluetoothExplorer.git", branch: "master"), +// ... .product(name: "BLEPluginSDK", package: "bluetooth-explorer") +``` + +> **Note:** consuming the root package this way currently fails to resolve, because the app's +> `AndroidSwiftUI` dependency uses a local sub-package that SwiftPM forbids in a branch-pinned +> dependency graph. Until that is fixed upstream, build against the standalone +> `PluginSDK/BLEPluginSDK` package instead — which is exactly what the in-repo examples do +> (`.package(path: "../../BLEPluginSDK")`). Both manifests build the same sources. + ## Prerequisites ```sh