Skip to content

Commit a17188b

Browse files
authored
Merge pull request #21 from MillerTechnologyPeru/feature/plugins-tab
Add a Plugins tab with import and Documents-backed storage
2 parents 3e61b60 + d2b825b commit a17188b

6 files changed

Lines changed: 500 additions & 11 deletions

File tree

Sources/BluetoothExplorer/ContentView.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import SwiftUI
22
import BluetoothExplorerUI
33

44
enum ContentTab: String, Hashable {
5-
case welcome, home, settings
5+
case welcome, home, plugins, settings
66
}
77

88
struct ContentView: View {
@@ -18,6 +18,12 @@ struct ContentView: View {
1818
.tabItem { Label("Welcome", systemImage: "heart.fill") }
1919
.tag(ContentTab.welcome)
2020

21+
NavigationStack {
22+
PluginsView()
23+
}
24+
.tabItem { Label("Plugins", systemImage: "puzzlepiece.extension.fill") }
25+
.tag(ContentTab.plugins)
26+
2127
NavigationStack {
2228
SettingsView(appearance: $appearance, welcomeName: $welcomeName)
2329
.navigationTitle("Settings")
@@ -59,9 +65,6 @@ struct SettingsView : View {
5965
Text("Light").tag("light")
6066
Text("Dark").tag("dark")
6167
}
62-
NavigationLink("Plugins") {
63-
PluginsView()
64-
}
6568
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
6669
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
6770
Text("Version \(version) (\(buildNumber))")

Sources/BluetoothExplorerModel/Model/PluginManager.swift

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,59 @@ public final class PluginManager {
6262

6363
// MARK: Loading
6464

65-
/// Scan the engine bundle's `Plugins/` directory for `*.bleplugin.json` manifests.
66-
public func loadBundledPlugins() {
67-
loadBundledPlugins(from: PluginEngineResources.bundle)
65+
/// Where installed plugins live on disk, once `loadInstalledPlugins()` has run.
66+
public private(set) var directory: PluginDirectory?
67+
68+
/// Install bundled plugins into Documents on first launch, then load everything from there.
69+
///
70+
/// This is the normal startup path: bundled and imported plugins end up in the same directory,
71+
/// so there is a single load path and the user can see and manage every plugin in one place.
72+
public func loadInstalledPlugins() {
73+
do {
74+
let directory = try PluginDirectory.default()
75+
self.directory = directory
76+
let freshlyInstalled = Set(try directory.installBundledPlugins(from: PluginEngineResources.bundle))
77+
load(from: directory, bundledIdentifiers: freshlyInstalled)
78+
} catch {
79+
// Falling back to the read-only bundle keeps parsing working even if Documents is
80+
// unavailable; the user just cannot manage plugins this session.
81+
loadBundledPlugins(from: PluginEngineResources.bundle)
82+
}
83+
}
84+
85+
private func load(from directory: PluginDirectory, bundledIdentifiers: Set<String>) {
86+
for manifestURL in directory.installedManifestURLs() {
87+
do {
88+
let loaded = try PluginLoader.load(manifestURL: manifestURL, verifyHash: true)
89+
// Anything installed from the app bundle is "bundled", whether it landed this
90+
// launch or a previous one; the install record is the source of truth.
91+
let source: Source = bundledIdentifiers.contains(loaded.manifest.identifier)
92+
|| bundledManifestIdentifiers.contains(loaded.manifest.identifier)
93+
? .bundled : .imported
94+
register(loaded.plugin, manifest: loaded.manifest, source: source)
95+
} catch let failure as PluginLoadFailure {
96+
recordFailure(failure, source: .imported)
97+
} catch {
98+
recordFailure(PluginLoadFailure(manifestName: manifestURL.lastPathComponent,
99+
underlying: nil, message: "\(error)"), source: .imported)
100+
}
101+
}
102+
rebuild()
103+
}
104+
105+
/// Identifiers shipped inside the app bundle, used to label a plugin's origin in the UI.
106+
private var bundledManifestIdentifiers: Set<String> {
107+
let urls = PluginEngineResources.bundle.urls(
108+
forResourcesWithExtension: "json", subdirectory: "Plugins") ?? []
109+
var identifiers = Set<String>()
110+
for url in urls.map({ $0 as URL })
111+
where url.lastPathComponent.hasSuffix(PluginDirectory.manifestSuffix) {
112+
guard let data = try? Data(contentsOf: url),
113+
let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data)
114+
else { continue }
115+
identifiers.insert(manifest.identifier)
116+
}
117+
return identifiers
68118
}
69119

70120
/// Scan a bundle's `Plugins/` directory for `*.bleplugin.json` manifests and load each.
@@ -79,6 +129,42 @@ public final class PluginManager {
79129
rebuild()
80130
}
81131

132+
// MARK: Importing
133+
134+
/// Why an import failed, in a form the UI can show directly.
135+
public struct ImportError: Error, Sendable, CustomStringConvertible {
136+
public let message: String
137+
public var description: String { message }
138+
}
139+
140+
/// Import a plugin the user picked. The manifest and its module are validated, then copied
141+
/// into the plugin directory and loaded.
142+
@discardableResult
143+
public func importPlugin(manifestURL: URL) -> Result<PluginID, ImportError> {
144+
guard let directory else {
145+
return .failure(ImportError(message: "Plugin storage is unavailable."))
146+
}
147+
#if canImport(Darwin)
148+
// Files handed over by the document picker live outside the sandbox until claimed.
149+
let scoped = manifestURL.startAccessingSecurityScopedResource()
150+
defer { if scoped { manifestURL.stopAccessingSecurityScopedResource() } }
151+
#endif
152+
do {
153+
let manifest = try directory.importPlugin(manifestURL: manifestURL)
154+
let installed = directory.url
155+
.appendingPathComponent(PluginDirectory.folderName(for: manifest.identifier), isDirectory: true)
156+
.appendingPathComponent(manifestURL.lastPathComponent)
157+
let loaded = try PluginLoader.load(manifestURL: installed, verifyHash: true)
158+
register(loaded.plugin, manifest: loaded.manifest, source: .imported)
159+
rebuild()
160+
return .success(loaded.plugin.id)
161+
} catch let failure as PluginLoadFailure {
162+
return .failure(ImportError(message: failure.message))
163+
} catch {
164+
return .failure(ImportError(message: "\(error)"))
165+
}
166+
}
167+
82168
/// Load a single plugin from a manifest URL. `verifyHash` enforces the manifest `sha256`.
83169
@discardableResult
84170
public func loadPlugin(manifestURL: URL, source: Source, verifyHash: Bool) -> PluginID? {
@@ -112,7 +198,7 @@ public final class PluginManager {
112198
let id = plugin.id
113199
wasmPlugins[id] = plugin
114200
if order.contains(id) == false { order.append(id) }
115-
if enabled[id] == nil { enabled[id] = true }
201+
if enabled[id] == nil { enabled[id] = Self.storedEnabled(id) }
116202
sources[id] = source
117203
displayNames[id] = manifest.name
118204
versions[id] = manifest.version
@@ -123,20 +209,38 @@ public final class PluginManager {
123209

124210
public func setEnabled(_ isEnabled: Bool, id: PluginID) {
125211
enabled[id] = isEnabled
212+
UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey(id))
126213
rebuild()
127214
}
128215

216+
/// Remove a plugin from the registry and delete it from the plugin directory.
217+
///
218+
/// A deleted bundled plugin is not reinstalled on the next launch: the install record still
219+
/// lists it, so `installBundledPlugins` considers it already handled.
129220
public func removePlugin(id: PluginID) {
221+
if let directory {
222+
try? directory.remove(identifier: id.rawValue)
223+
}
130224
wasmPlugins[id] = nil
131225
order.removeAll { $0 == id }
132226
enabled[id] = nil
227+
UserDefaults.standard.removeObject(forKey: Self.enabledKey(id))
133228
sources[id] = nil
134229
displayNames[id] = nil
135230
versions[id] = nil
136231
loadErrors[id] = nil
137232
rebuild()
138233
}
139234

235+
private static func enabledKey(_ id: PluginID) -> String {
236+
"plugin.enabled." + id.rawValue
237+
}
238+
239+
/// Persisted enable state, defaulting to on for a plugin seen for the first time.
240+
private static func storedEnabled(_ id: PluginID) -> Bool {
241+
UserDefaults.standard.object(forKey: enabledKey(id)) as? Bool ?? true
242+
}
243+
140244
// MARK: Registry construction
141245

142246
private func rebuild() {

Sources/BluetoothExplorerModel/Model/Store.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public final class Store {
109109
self.pluginManager = pluginManager
110110
setupLog()
111111
observeValues()
112-
pluginManager.loadBundledPlugins()
112+
pluginManager.loadInstalledPlugins()
113113
}
114114

115115
/// The current parser registry snapshot.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//
2+
// PluginDirectory.swift
3+
// BluetoothExplorerPluginEngine
4+
//
5+
// On-disk plugin storage under the app's Documents directory.
6+
//
7+
// Every plugin — bundled or user-imported — lives here, so there is one code path for loading and
8+
// one place a user can inspect. Bundled plugins are copied in on first launch and refreshed when a
9+
// new app build ships a different module; a bundled plugin the user deletes stays deleted, because
10+
// the install record remembers that it was already handled once.
11+
//
12+
// Layout:
13+
// Documents/Plugins/
14+
// <plugin-id>/
15+
// <name>.bleplugin.json
16+
// <name>.wasm
17+
// .installed-bundled.json
18+
//
19+
20+
import Foundation
21+
22+
public struct PluginDirectory: Sendable {
23+
24+
/// Root of the plugin store, e.g. `Documents/Plugins`.
25+
public let url: URL
26+
27+
private static let recordName = ".installed-bundled.json"
28+
29+
public init(url: URL) {
30+
self.url = url
31+
}
32+
33+
/// `Documents/Plugins`, created if absent.
34+
public static func `default`() throws -> PluginDirectory {
35+
let documents = try FileManager.default.url(
36+
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
37+
let directory = PluginDirectory(url: documents.appendingPathComponent("Plugins", isDirectory: true))
38+
try directory.createIfNeeded()
39+
return directory
40+
}
41+
42+
public func createIfNeeded() throws {
43+
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
44+
}
45+
46+
// MARK: Listing
47+
48+
/// Manifest URLs for every installed plugin, one per plugin sub-directory.
49+
public func installedManifestURLs() -> [URL] {
50+
let contents = (try? FileManager.default.contentsOfDirectory(
51+
at: url, includingPropertiesForKeys: nil)) ?? []
52+
var manifests = [URL]()
53+
for entry in contents.map({ $0 as URL }) {
54+
// `contentsOfDirectory` on a plain file throws, which is a portable directory test —
55+
// `ObjCBool` out-parameters are awkward outside Darwin's Foundation.
56+
guard let inner = try? FileManager.default.contentsOfDirectory(
57+
at: entry, includingPropertiesForKeys: nil)
58+
else { continue }
59+
for file in inner.map({ $0 as URL })
60+
where file.lastPathComponent.hasSuffix(PluginDirectory.manifestSuffix) {
61+
manifests.append(file)
62+
}
63+
}
64+
return manifests.sorted { $0.path < $1.path }
65+
}
66+
67+
public static let manifestSuffix = ".bleplugin.json"
68+
69+
// MARK: Installing bundled plugins
70+
71+
/// Copy bundled plugins into the store.
72+
///
73+
/// A plugin is installed when it has never been installed before (first launch), or when the
74+
/// bundled module's hash differs from the one recorded (a new app build shipped an update).
75+
/// Plugins the user deleted are not resurrected.
76+
/// - Returns: the identifiers that were installed or refreshed.
77+
@discardableResult
78+
public func installBundledPlugins(from bundle: Bundle) throws -> [String] {
79+
try createIfNeeded()
80+
var record = installRecord()
81+
var installed = [String]()
82+
83+
let discovered = bundle.urls(forResourcesWithExtension: "json", subdirectory: "Plugins") ?? []
84+
for manifestURL in discovered.map({ $0 as URL })
85+
where manifestURL.lastPathComponent.hasSuffix(PluginDirectory.manifestSuffix) {
86+
guard let data = try? Data(contentsOf: manifestURL),
87+
let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data)
88+
else { continue }
89+
90+
// The manifest's sha256 identifies this build of the module. Absent one, fall back to
91+
// the version string so a plugin without a hash still refreshes across releases.
92+
let stamp = manifest.sha256 ?? manifest.version
93+
if record[manifest.identifier] == stamp { continue }
94+
95+
let moduleURL = manifestURL.deletingLastPathComponent()
96+
.appendingPathComponent(manifest.module)
97+
try install(manifestURL: manifestURL, moduleURL: moduleURL, identifier: manifest.identifier)
98+
record[manifest.identifier] = stamp
99+
installed.append(manifest.identifier)
100+
}
101+
102+
try write(record: record)
103+
return installed
104+
}
105+
106+
/// True when no bundled plugin has ever been installed — i.e. this is a first launch.
107+
public var isFirstLaunch: Bool {
108+
FileManager.default.fileExists(atPath: recordURL.path) == false
109+
}
110+
111+
// MARK: Importing
112+
113+
/// Import a plugin the user picked, copying its manifest and module into the store.
114+
///
115+
/// `manifestURL` must be a `*.bleplugin.json`; the module named by the manifest is resolved
116+
/// alongside it. The plugin is validated before anything is written, so a bad import cannot
117+
/// leave a broken plugin on disk.
118+
@discardableResult
119+
public func importPlugin(manifestURL: URL) throws -> PluginManifest {
120+
try createIfNeeded()
121+
// Validate first: this parses the module, checks the ABI marker, capability exports and,
122+
// when the manifest carries a hash, verifies it.
123+
let loaded = try PluginLoader.load(manifestURL: manifestURL, verifyHash: true)
124+
let moduleURL = manifestURL.deletingLastPathComponent()
125+
.appendingPathComponent(loaded.manifest.module)
126+
try install(manifestURL: manifestURL, moduleURL: moduleURL,
127+
identifier: loaded.manifest.identifier)
128+
return loaded.manifest
129+
}
130+
131+
/// Delete an installed plugin's directory.
132+
public func remove(identifier: String) throws {
133+
let directory = url.appendingPathComponent(Self.folderName(for: identifier), isDirectory: true)
134+
guard FileManager.default.fileExists(atPath: directory.path) else { return }
135+
try FileManager.default.removeItem(at: directory)
136+
}
137+
138+
// MARK: Internals
139+
140+
private func install(manifestURL: URL, moduleURL: URL, identifier: String) throws {
141+
let destination = url.appendingPathComponent(Self.folderName(for: identifier), isDirectory: true)
142+
if FileManager.default.fileExists(atPath: destination.path) {
143+
try FileManager.default.removeItem(at: destination)
144+
}
145+
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
146+
147+
let moduleData = try Data(contentsOf: moduleURL)
148+
let manifestData = try Data(contentsOf: manifestURL)
149+
try moduleData.write(to: destination.appendingPathComponent(moduleURL.lastPathComponent))
150+
try manifestData.write(to: destination.appendingPathComponent(manifestURL.lastPathComponent))
151+
}
152+
153+
/// A filesystem-safe directory name for a reverse-DNS plugin identifier.
154+
public static func folderName(for identifier: String) -> String {
155+
var name = ""
156+
for character in identifier {
157+
if character.isLetter || character.isNumber || character == "." || character == "-" {
158+
name.append(character)
159+
} else {
160+
name.append("_")
161+
}
162+
}
163+
return name.isEmpty ? "plugin" : name
164+
}
165+
166+
private var recordURL: URL { url.appendingPathComponent(PluginDirectory.recordName) }
167+
168+
private func installRecord() -> [String: String] {
169+
guard let data = try? Data(contentsOf: recordURL),
170+
let record = try? JSONDecoder().decode([String: String].self, from: data)
171+
else { return [:] }
172+
return record
173+
}
174+
175+
private func write(record: [String: String]) throws {
176+
let data = try JSONEncoder().encode(record)
177+
try data.write(to: recordURL)
178+
}
179+
}

0 commit comments

Comments
 (0)