Skip to content

Commit 5d196bb

Browse files
committed
fix: remove settingsView() from TableProPlugin protocol to fix SIGILL crash
Adding settingsView() as a protocol requirement was ABI-breaking for dynamically loaded plugin bundles, causing witness table mismatch and SIGILL at protocol descriptor for PluginDatabaseDriver.
1 parent 884885d commit 5d196bb

8 files changed

Lines changed: 242 additions & 26 deletions

File tree

Plugins/CSVExportPlugin/CSVExportPlugin.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,6 @@ final class CSVExportPlugin: ExportFormatPlugin {
3636
AnyView(CSVExportOptionsView(plugin: self))
3737
}
3838

39-
func settingsView() -> AnyView? {
40-
optionsView()
41-
}
42-
4339
func export(
4440
tables: [PluginExportTable],
4541
dataSource: any PluginExportDataSource,

Plugins/JSONExportPlugin/JSONExportPlugin.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ final class JSONExportPlugin: ExportFormatPlugin {
3333
AnyView(JSONExportOptionsView(plugin: self))
3434
}
3535

36-
func settingsView() -> AnyView? {
37-
optionsView()
38-
}
39-
4036
func export(
4137
tables: [PluginExportTable],
4238
dataSource: any PluginExportDataSource,

Plugins/MQLExportPlugin/MQLExportPlugin.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,6 @@ final class MQLExportPlugin: ExportFormatPlugin {
4848
AnyView(MQLExportOptionsView(plugin: self))
4949
}
5050

51-
func settingsView() -> AnyView? {
52-
optionsView()
53-
}
54-
5551
func export(
5652
tables: [PluginExportTable],
5753
dataSource: any PluginExportDataSource,

Plugins/SQLExportPlugin/SQLExportPlugin.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ final class SQLExportPlugin: ExportFormatPlugin {
6363
AnyView(SQLExportOptionsView(plugin: self))
6464
}
6565

66-
func settingsView() -> AnyView? {
67-
optionsView()
68-
}
69-
7066
func export(
7167
tables: [PluginExportTable],
7268
dataSource: any PluginExportDataSource,
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import Foundation
2-
import SwiftUI
32

43
public protocol TableProPlugin: AnyObject {
54
static var pluginName: String { get }
65
static var pluginVersion: String { get }
76
static var pluginDescription: String { get }
87
static var capabilities: [PluginCapability] { get }
98
static var dependencies: [String] { get }
10-
func settingsView() -> AnyView?
119

1210
init()
1311
}
1412

1513
public extension TableProPlugin {
1614
static var dependencies: [String] { [] }
17-
func settingsView() -> AnyView? { nil }
1815
}

Plugins/XLSXExportPlugin/XLSXExportPlugin.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ final class XLSXExportPlugin: ExportFormatPlugin {
3333
AnyView(XLSXExportOptionsView(plugin: self))
3434
}
3535

36-
func settingsView() -> AnyView? {
37-
optionsView()
38-
}
39-
4036
func export(
4137
tables: [PluginExportTable],
4238
dataSource: any PluginExportDataSource,

TablePro/Views/Settings/Plugins/InstalledPluginsView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,10 @@ struct InstalledPluginsView: View {
177177
.foregroundStyle(.secondary)
178178
}
179179

180-
if let instance = pluginManager.pluginInstances[plugin.id],
181-
let settingsView = instance.settingsView() {
180+
if let exportPlugin = pluginManager.pluginInstances[plugin.id] as? any ExportFormatPlugin,
181+
let exportSettings = exportPlugin.optionsView() {
182182
Divider()
183-
settingsView
183+
exportSettings
184184
}
185185

186186
if plugin.source == .userInstalled {
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
//
2+
// PluginSettingsTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
import Testing
9+
@testable import TablePro
10+
11+
@Suite("PluginSettingsStorage")
12+
struct PluginSettingsStorageTests {
13+
14+
private let testPluginId = "test.settings.\(UUID().uuidString)"
15+
16+
private func cleanup(storage: PluginSettingsStorage) {
17+
storage.removeAll()
18+
}
19+
20+
@Test("save and load round-trips a Codable value")
21+
func saveAndLoad() {
22+
let storage = PluginSettingsStorage(pluginId: testPluginId)
23+
defer { cleanup(storage: storage) }
24+
25+
struct TestOptions: Codable, Equatable {
26+
var flag: Bool
27+
var count: Int
28+
}
29+
30+
let original = TestOptions(flag: true, count: 42)
31+
storage.save(original)
32+
let loaded = storage.load(TestOptions.self)
33+
34+
#expect(loaded == original)
35+
}
36+
37+
@Test("load returns nil when no data exists")
38+
func loadReturnsNilWhenEmpty() {
39+
let storage = PluginSettingsStorage(pluginId: testPluginId)
40+
defer { cleanup(storage: storage) }
41+
42+
struct EmptyOptions: Codable {
43+
var value: String
44+
}
45+
46+
let result = storage.load(EmptyOptions.self)
47+
#expect(result == nil)
48+
}
49+
50+
@Test("save overwrites previous value")
51+
func saveOverwritesPrevious() {
52+
let storage = PluginSettingsStorage(pluginId: testPluginId)
53+
defer { cleanup(storage: storage) }
54+
55+
storage.save(10)
56+
storage.save(20)
57+
let loaded = storage.load(Int.self)
58+
59+
#expect(loaded == 20)
60+
}
61+
62+
@Test("different keys store independently")
63+
func differentKeysIndependent() {
64+
let storage = PluginSettingsStorage(pluginId: testPluginId)
65+
defer { cleanup(storage: storage) }
66+
67+
storage.save("alpha", forKey: "keyA")
68+
storage.save("beta", forKey: "keyB")
69+
70+
#expect(storage.load(String.self, forKey: "keyA") == "alpha")
71+
#expect(storage.load(String.self, forKey: "keyB") == "beta")
72+
}
73+
74+
@Test("removeAll clears all keys for plugin")
75+
func removeAllClearsKeys() {
76+
let storage = PluginSettingsStorage(pluginId: testPluginId)
77+
78+
storage.save("value1", forKey: "key1")
79+
storage.save("value2", forKey: "key2")
80+
storage.removeAll()
81+
82+
#expect(storage.load(String.self, forKey: "key1") == nil)
83+
#expect(storage.load(String.self, forKey: "key2") == nil)
84+
}
85+
86+
@Test("removeAll does not affect other plugins")
87+
func removeAllIsolatedToPlugin() {
88+
let storageA = PluginSettingsStorage(pluginId: testPluginId)
89+
let otherPluginId = "test.settings.other.\(UUID().uuidString)"
90+
let storageB = PluginSettingsStorage(pluginId: otherPluginId)
91+
defer {
92+
cleanup(storage: storageA)
93+
cleanup(storage: storageB)
94+
}
95+
96+
storageA.save("fromA")
97+
storageB.save("fromB")
98+
storageA.removeAll()
99+
100+
#expect(storageA.load(String.self) == nil)
101+
#expect(storageB.load(String.self) == "fromB")
102+
}
103+
104+
@Test("keys are namespaced with com.TablePro.plugin prefix")
105+
func keysNamespaced() {
106+
let pluginId = "test.namespace.\(UUID().uuidString)"
107+
let storage = PluginSettingsStorage(pluginId: pluginId)
108+
defer { cleanup(storage: storage) }
109+
110+
storage.save(true)
111+
112+
let expectedKey = "com.TablePro.plugin.\(pluginId).settings"
113+
let value = UserDefaults.standard.data(forKey: expectedKey)
114+
#expect(value != nil)
115+
}
116+
117+
@Test("load returns nil for type mismatch")
118+
func loadTypeMismatch() {
119+
let storage = PluginSettingsStorage(pluginId: testPluginId)
120+
defer { cleanup(storage: storage) }
121+
122+
storage.save("a string value")
123+
124+
struct DifferentType: Codable {
125+
var number: Int
126+
}
127+
128+
let result = storage.load(DifferentType.self)
129+
#expect(result == nil)
130+
}
131+
}
132+
133+
@Suite("PluginCapability")
134+
struct PluginCapabilityTests {
135+
136+
@Test("only has 3 cases: databaseDriver, exportFormat, importFormat")
137+
func onlyThreeCases() {
138+
let allCases: [PluginCapability] = [.databaseDriver, .exportFormat, .importFormat]
139+
#expect(allCases.count == 3)
140+
}
141+
142+
@Test("raw values are stable integers")
143+
func rawValuesStable() {
144+
#expect(PluginCapability.databaseDriver.rawValue == 0)
145+
#expect(PluginCapability.exportFormat.rawValue == 1)
146+
#expect(PluginCapability.importFormat.rawValue == 2)
147+
}
148+
149+
@Test("Codable round-trip preserves value")
150+
func codableRoundTrip() throws {
151+
let original = PluginCapability.exportFormat
152+
let data = try JSONEncoder().encode(original)
153+
let decoded = try JSONDecoder().decode(PluginCapability.self, from: data)
154+
#expect(decoded == original)
155+
}
156+
157+
@Test("decoding removed raw value 3 fails gracefully")
158+
func decodingRemovedRawValueFails() {
159+
let json = Data("3".utf8)
160+
let decoded = try? JSONDecoder().decode(PluginCapability.self, from: json)
161+
#expect(decoded == nil)
162+
}
163+
}
164+
165+
@Suite("DisabledPlugins Key Migration", .serialized)
166+
struct DisabledPluginsMigrationTests {
167+
168+
@Test("migration moves legacy key to namespaced key")
169+
func migrationMovesKey() {
170+
let testKey = "disabledPlugins"
171+
let namespacedKey = "com.TablePro.disabledPlugins"
172+
let defaults = UserDefaults.standard
173+
174+
// Save current state
175+
let savedNamespaced = defaults.stringArray(forKey: namespacedKey)
176+
let savedLegacy = defaults.stringArray(forKey: testKey)
177+
178+
defer {
179+
// Restore original state
180+
if let saved = savedNamespaced {
181+
defaults.set(saved, forKey: namespacedKey)
182+
} else {
183+
defaults.removeObject(forKey: namespacedKey)
184+
}
185+
if let saved = savedLegacy {
186+
defaults.set(saved, forKey: testKey)
187+
} else {
188+
defaults.removeObject(forKey: testKey)
189+
}
190+
}
191+
192+
// Set up legacy key
193+
defaults.removeObject(forKey: namespacedKey)
194+
defaults.set(["plugin.a", "plugin.b"], forKey: testKey)
195+
196+
// Simulate what migrateDisabledPluginsKey does
197+
if let legacy = defaults.stringArray(forKey: testKey) {
198+
defaults.set(legacy, forKey: namespacedKey)
199+
defaults.removeObject(forKey: testKey)
200+
}
201+
202+
#expect(defaults.stringArray(forKey: namespacedKey) == ["plugin.a", "plugin.b"])
203+
#expect(defaults.stringArray(forKey: testKey) == nil)
204+
}
205+
206+
@Test("migration is no-op when legacy key absent")
207+
func migrationNoOpWhenAbsent() {
208+
let testKey = "disabledPlugins"
209+
let namespacedKey = "com.TablePro.disabledPlugins"
210+
let defaults = UserDefaults.standard
211+
212+
let savedNamespaced = defaults.stringArray(forKey: namespacedKey)
213+
let savedLegacy = defaults.stringArray(forKey: testKey)
214+
215+
defer {
216+
if let saved = savedNamespaced {
217+
defaults.set(saved, forKey: namespacedKey)
218+
} else {
219+
defaults.removeObject(forKey: namespacedKey)
220+
}
221+
if let saved = savedLegacy {
222+
defaults.set(saved, forKey: testKey)
223+
} else {
224+
defaults.removeObject(forKey: testKey)
225+
}
226+
}
227+
228+
defaults.removeObject(forKey: testKey)
229+
defaults.set(["existing.plugin"], forKey: namespacedKey)
230+
231+
// Simulate migration
232+
if let legacy = defaults.stringArray(forKey: testKey) {
233+
defaults.set(legacy, forKey: namespacedKey)
234+
defaults.removeObject(forKey: testKey)
235+
}
236+
237+
#expect(defaults.stringArray(forKey: namespacedKey) == ["existing.plugin"])
238+
}
239+
}

0 commit comments

Comments
 (0)