diff --git a/CHANGELOG.md b/CHANGELOG.md index 70247b96a..8797a6681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MySQL and MariaDB queries no longer fail after about a minute when a longer query timeout is set. The client read timeout now follows the configured query timeout, so a long query or stored procedure runs for the full time you allow. (#1921) - A dropped MySQL or MariaDB connection no longer silently re-runs a statement that changes data, so a lost connection can no longer run an insert, update, or stored procedure twice. (#1921) - The MongoDB, Oracle, Cassandra, and Elasticsearch plugins failed to install on 0.58 with "Bundle failed to load executable". (#1917) +- Plugin install and load failures now name the real cause (wrong architecture, missing dependency, or incompatibility with this version of TablePro) instead of a generic error. A plugin that fails to load on demand is now reported instead of silently disappearing. (#1915) ## [0.58.0] - 2026-07-18 diff --git a/TablePro/Core/Plugins/PluginBundleLoader.swift b/TablePro/Core/Plugins/PluginBundleLoader.swift new file mode 100644 index 000000000..c83149f94 --- /dev/null +++ b/TablePro/Core/Plugins/PluginBundleLoader.swift @@ -0,0 +1,44 @@ +// +// PluginBundleLoader.swift +// TablePro +// + +import Foundation +import os + +enum PluginBundleLoader { + private static let logger = Logger(subsystem: "com.TablePro", category: "PluginBundleLoader") + + static func load(_ bundle: Bundle) throws { + do { + try bundle.loadAndReturnError() + } catch { + let nsError = error as NSError + let reason = describeLoadFailure(nsError) + let detail = nsError.userInfo[NSDebugDescriptionErrorKey] as? String ?? nsError.localizedDescription + logger.error( + "Bundle load failed for \(bundle.bundleURL.lastPathComponent, privacy: .public) [\(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)]: \(reason, privacy: .public) [\(detail, privacy: .public)]" + ) + throw PluginError.invalidBundle(reason) + } + } + + static func describeLoadFailure(_ error: NSError) -> String { + switch error.code { + case NSFileNoSuchFileError: + return String(localized: "The plugin's executable file is missing.") + case NSExecutableNotLoadableError: + return String(localized: "The plugin's executable couldn't be loaded. It may be damaged or improperly signed.") + case NSExecutableArchitectureMismatchError: + return String(localized: "The plugin doesn't include a build for this Mac's processor architecture.") + case NSExecutableRuntimeMismatchError: + return String(localized: "The plugin was built for an incompatible runtime.") + case NSExecutableLoadError: + return String(localized: "The plugin depends on a component that's missing or incompatible with this Mac.") + case NSExecutableLinkError: + return String(localized: "The plugin isn't compatible with this version of TablePro. Update the app or reinstall the plugin.") + default: + return error.localizedFailureReason ?? error.localizedDescription + } + } +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 58205a26e..d49944c56 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -437,8 +437,11 @@ final class PluginManager { } } - guard bundle.load() else { - Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent)") + do { + try PluginBundleLoader.load(bundle) + } catch { + Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent): \(error.localizedDescription)") + recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) return } @@ -546,9 +549,7 @@ final class PluginManager { try validateBundleVersions(bundle) - guard bundle.load() else { - throw PluginError.invalidBundle("Bundle failed to load executable") - } + try PluginBundleLoader.load(bundle) return bundle } diff --git a/TableProTests/Core/Plugins/PluginBundleLoaderTests.swift b/TableProTests/Core/Plugins/PluginBundleLoaderTests.swift new file mode 100644 index 000000000..dea7a3a98 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginBundleLoaderTests.swift @@ -0,0 +1,63 @@ +// +// PluginBundleLoaderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("PluginBundleLoader.describeLoadFailure") +struct PluginBundleLoaderDescribeLoadFailureTests { + private func makeError(_ code: Int, debug: String? = nil, failureReason: String? = nil) -> NSError { + var userInfo: [String: Any] = [:] + if let debug { userInfo[NSDebugDescriptionErrorKey] = debug } + if let failureReason { userInfo[NSLocalizedFailureReasonErrorKey] = failureReason } + return NSError(domain: NSCocoaErrorDomain, code: code, userInfo: userInfo) + } + + @Test("missing executable file reports a missing-file reason") + func missingFile() { + let reason = PluginBundleLoader.describeLoadFailure(makeError(NSFileNoSuchFileError)) + #expect(reason.localizedCaseInsensitiveContains("missing")) + #expect(!reason.contains("Bundle failed to load executable")) + } + + @Test("not-loadable executable reports a damaged executable") + func notLoadable() { + let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableNotLoadableError)) + #expect(reason.localizedCaseInsensitiveContains("loaded") || reason.localizedCaseInsensitiveContains("damaged")) + } + + @Test("architecture mismatch names the processor architecture") + func architectureMismatch() { + let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableArchitectureMismatchError)) + #expect(reason.localizedCaseInsensitiveContains("architecture")) + } + + @Test("runtime mismatch reports an incompatible runtime") + func runtimeMismatch() { + let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableRuntimeMismatchError)) + #expect(reason.localizedCaseInsensitiveContains("runtime")) + } + + @Test("missing dependency reports a missing component") + func missingDependency() { + let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableLoadError)) + #expect(reason.localizedCaseInsensitiveContains("component")) + } + + @Test("link error points at app or plugin incompatibility") + func linkError() { + let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableLinkError)) + #expect(reason.contains("TablePro")) + } + + @Test("unknown code falls back to the OS-provided reason") + func unknownCodeFallsBack() { + let reason = PluginBundleLoader.describeLoadFailure( + makeError(999_999, failureReason: "Custom underlying reason") + ) + #expect(reason == "Custom underlying reason") + } +} diff --git a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift index 2cd9444f7..a30546c17 100644 --- a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift @@ -4,9 +4,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("Plugin lazy activation re-verification", .serialized) @MainActor @@ -71,4 +71,65 @@ struct PluginLazyActivationVerificationTests { #expect(manager.driverPlugins[typeId] == nil) #expect(manager.rejectedPlugins.filter { $0.url == bundleURL }.count == 1) } + + @Test("lazy bundle whose executable fails to load is rejected with a specific reason") + func rejectsBundleThatFailsToLoad() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("LazyLoadFail-\(UUID().uuidString)", isDirectory: true) + let userPluginsDir = root.appendingPathComponent("Plugins", isDirectory: true) + let bundleURL = userPluginsDir.appendingPathComponent("Broken.tableplugin", isDirectory: true) + let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) + let macosURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true) + try fm.createDirectory(at: macosURL, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let bundleId = "com.TablePro.test.broken.\(UUID().uuidString)" + let typeId = "broken-db-\(UUID().uuidString)" + let info: [String: Any] = [ + "CFBundleIdentifier": bundleId, + "CFBundleName": "Broken", + "CFBundleShortVersionString": "1.0.0", + "CFBundleExecutable": "Broken" + ] + let infoData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) + try Data("not a mach-o binary".utf8).write(to: macosURL.appendingPathComponent("Broken")) + + let suiteName = "LazyLoadFailTest.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir) + let bundle = try #require(Bundle(url: bundleURL)) + manager.plugins = [ + PluginEntry( + id: bundleId, + bundle: bundle, + url: bundleURL, + source: .builtIn, + name: "Broken", + version: "1.0.0", + pluginDescription: "", + capabilities: [.databaseDriver], + isEnabled: true, + databaseTypeId: typeId, + additionalTypeIds: [], + pluginIconName: "puzzlepiece", + defaultPort: nil, + exportFormatId: nil, + importFormatId: nil, + inspectorId: nil + ) + ] + + manager.activateLazyBundle(at: bundleURL) + + #expect(manager.driverPlugins[typeId] == nil) + let rejection = try #require(manager.rejectedPlugins.first { $0.url == bundleURL }) + #expect(!rejection.reason.isEmpty) + #expect(!rejection.reason.contains("Bundle failed to load executable")) + + manager.activateLazyBundle(at: bundleURL) + #expect(manager.rejectedPlugins.filter { $0.url == bundleURL }.count == 1) + } }