Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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: [
Expand Down
16 changes: 16 additions & 0 deletions PluginSDK/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 39 additions & 91 deletions Sources/BluetoothExplorerModel/Model/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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] = [:]
Expand All @@ -43,91 +50,54 @@ 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<String>) {
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<String> {
let urls = PluginEngineResources.bundle.urls(
forResourcesWithExtension: "json", subdirectory: "Plugins") ?? []
var identifiers = Set<String>()
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)
for loaded in result.loaded {
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.
Expand Down Expand Up @@ -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
Expand All @@ -204,19 +154,20 @@ 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
UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey(id))
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)
}
Expand Down Expand Up @@ -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)
Expand Down
112 changes: 0 additions & 112 deletions Sources/BluetoothExplorerPluginEngine/NativeParsers.swift

This file was deleted.

Loading
Loading