diff --git a/.swiftlint.yml b/.swiftlint.yml index 2dd9b2fdce..e00e761bbd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -558,7 +558,7 @@ custom_rules: plugin_principal_class_gate: name: "Principal Class Behind The Gate" included: ['.*/TablePro/Core/Plugins/[^/]+\.swift$'] - regex: '\bfunc\s+(?!(?:registerBundle|activateLazyBundle|validateDependencies)\b)\w+(?:(?!\bfunc\b)[\s\S])*?(\.principalClass\b)' + regex: '\bfunc\s+(?!(?:registerBundle|activateLazyBundle)\b)\w+(?:(?!\bfunc\b)[\s\S])*?(\.principalClass\b)' capture_group: 1 message: "Bundle.principalClass loads the executable. Read it only in a function that already called assertLoadable." severity: error diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index 8a7beaa450..b01632bf0e 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -1,9 +1,3 @@ -// -// PluginInstaller.swift -// TablePro -// - -import CryptoKit import Foundation import os import TableProUtilities @@ -22,14 +16,14 @@ actor PluginInstaller { _ registryPlugin: RegistryPlugin, binary: RegistryBinary, into userPluginsDir: URL, - progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndCommit( registryPlugin, binary: binary, into: userPluginsDir, - progressHandler: progressHandler + progress: progress ) } } @@ -39,7 +33,7 @@ actor PluginInstaller { binary: RegistryBinary, into userPluginsDir: URL, hasLiveConnections: Bool, - progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + progress: @escaping @Sendable (Double) async -> Void ) async throws -> PluginUpdateResult { if hasLiveConnections { let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) { @@ -47,11 +41,10 @@ actor PluginInstaller { registryPlugin, binary: binary, into: userPluginsDir, - progressHandler: progressHandler + progress: progress ) } stagedUpdates[registryPlugin.id] = stagedURL - await progressHandler(.staged(at: stagedURL)) return .staged(at: stagedURL) } @@ -60,7 +53,7 @@ actor PluginInstaller { registryPlugin, binary: binary, into: userPluginsDir, - progressHandler: progressHandler + progress: progress ) } return .installed(pluginURL: finalURL) @@ -122,13 +115,13 @@ actor PluginInstaller { _ registryPlugin: RegistryPlugin, binary: RegistryBinary, into userPluginsDir: URL, - progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { let extracted = try await downloadExtractVerify( registryPlugin, binary: binary, into: userPluginsDir, - progressHandler: progressHandler + progress: progress ) defer { @@ -136,9 +129,7 @@ actor PluginInstaller { } let destURL = userPluginsDir.appendingPathComponent(extracted.bundleURL.lastPathComponent, isDirectory: true) - let finalURL = try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL) - await progressHandler(.installed(pluginURL: finalURL)) - return finalURL + return try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL) } // MARK: - Download + stage (live connections) @@ -147,13 +138,13 @@ actor PluginInstaller { _ registryPlugin: RegistryPlugin, binary: RegistryBinary, into userPluginsDir: URL, - progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { let extracted = try await downloadExtractVerify( registryPlugin, binary: binary, into: userPluginsDir, - progressHandler: progressHandler + progress: progress ) return extracted.bundleURL } @@ -169,20 +160,14 @@ actor PluginInstaller { _ registryPlugin: RegistryPlugin, binary: RegistryBinary, into userPluginsDir: URL, - progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + progress: @escaping @Sendable (Double) async -> Void ) async throws -> ExtractedBundle { let stagingRoot = Self.stagingRoot(for: userPluginsDir) try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) let workingDir = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true) - let context = await MainActor.run { - ( - kit: PluginManager.currentPluginKitVersion, - minimumKit: PluginManager.minimumCompatiblePluginKitVersion, - session: RegistryClient.shared.session - ) - } + let session = await MainActor.run { RegistryClient.shared.session } // The registry manifest is fetched over the network, so the URL it names is untrusted // input. The code-signature check is the real gate, but nothing should be fetched over @@ -192,30 +177,16 @@ actor PluginInstaller { throw PluginError.downloadFailed("Invalid download URL") } - /// Zero here is "not started", not "nought per cent done", and nothing else arrives until - /// the whole file is down: measured on a 25.7 MB download with a known `Content-Length`, - /// `download(from:delegate:)` delivered no `URLSessionDownloadDelegate` byte callbacks at - /// all, because the async form routes only `URLSessionTaskDelegate` messages to a per-task - /// delegate. Anything drawing this has to stay indeterminate until a fraction moves. - await progressHandler(.downloading(fraction: 0)) - - let (tempDownloadURL, response) = try await context.session.download(from: downloadURL) + let (tempDownloadURL, response) = try await Self.download(downloadURL, with: session, progress: progress) guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { let code = (response as? HTTPURLResponse)?.statusCode ?? 0 throw PluginError.downloadFailed("HTTP \(code)") } - await progressHandler(.downloading(fraction: 0.5)) - - let payload = try Data(contentsOf: tempDownloadURL) - let digest = SHA256.hash(data: payload) - let hex = digest.hexEncoded - guard hex == binary.sha256.lowercased() else { + guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == binary.sha256.lowercased() else { throw PluginError.checksumMismatch } - await progressHandler(.downloading(fraction: 1.0)) - let zipURL = workingDir.appendingPathComponent("\(registryPlugin.id).zip") try FileManager.default.moveItem(at: tempDownloadURL, to: zipURL) @@ -227,12 +198,7 @@ actor PluginInstaller { } try PluginCodeSignatureVerifier.verify(bundle: stagedBundle) - - try Self.validateStagedABI( - bundleURL: bundleURL, - currentKit: context.kit, - minimumKit: context.minimumKit - ) + try PluginManager.validateBundleVersions(stagedBundle) Self.stripQuarantine(at: bundleURL) return ExtractedBundle(workingDir: workingDir, bundleURL: bundleURL) @@ -240,6 +206,29 @@ actor PluginInstaller { // MARK: - Helpers (nonisolated) + nonisolated static func download( + _ url: URL, + with session: URLSession, + progress: @escaping @Sendable (Double) async -> Void + ) async throws -> (URL, URLResponse) { + let observer = DownloadProgressObserver() + let forwarding = Task { + for await fraction in observer.fractions { + await progress(fraction) + } + } + do { + let result = try await session.download(from: url, delegate: observer) + observer.finish(isComplete: true) + await forwarding.value + return result + } catch { + observer.finish(isComplete: false) + forwarding.cancel() + throw error + } + } + nonisolated static func stagingRoot(for userPluginsDir: URL) -> URL { userPluginsDir.deletingLastPathComponent() .appendingPathComponent("PluginStaging", isDirectory: true) @@ -276,27 +265,6 @@ actor PluginInstaller { DownloadedBinary.stripQuarantine(at: url) } - nonisolated static func validateStagedABI( - bundleURL: URL, - currentKit: Int, - minimumKit: Int - ) throws { - guard let bundle = Bundle(url: bundleURL), - let info = bundle.infoDictionary - else { - throw PluginError.invalidBundle("Cannot read Info.plist") - } - guard let version = info["TableProPluginKitVersion"] as? Int else { - throw PluginError.pluginOutdated(pluginVersion: 0, requiredVersion: currentKit) - } - if version > currentKit { - throw PluginError.incompatibleVersion(required: version, current: currentKit) - } - if version < minimumKit { - throw PluginError.pluginOutdated(pluginVersion: version, requiredVersion: currentKit) - } - } - nonisolated static func atomicReplace(stagedBundleURL: URL, destURL: URL) throws -> URL { var resultURL: NSURL? let backupName = "\(destURL.lastPathComponent).bak" @@ -311,8 +279,6 @@ actor PluginInstaller { options: [], resultingItemURL: &resultURL ) - let backupURL = destDir.appendingPathComponent(backupName) - try? FileManager.default.removeItem(at: backupURL) } else { try FileManager.default.moveItem(at: stagedBundleURL, to: destURL) } @@ -321,11 +287,37 @@ actor PluginInstaller { } } -enum StagedInstallState: Sendable { - case downloading(fraction: Double) - case staged(at: URL) - case installed(pluginURL: URL) - case failed(any Error) +// The async download sends a task delegate no byte callbacks, but the task's own progress moves. +private final class DownloadProgressObserver: NSObject, URLSessionTaskDelegate, Sendable { + let fractions: AsyncStream + private let continuation: AsyncStream.Continuation + private let observation = OSAllocatedUnfairLock(uncheckedState: nil) + + override init() { + (fractions, continuation) = AsyncStream.makeStream(of: Double.self) + super.init() + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + let continuation = continuation + let observation = task.progress.observe(\.fractionCompleted) { progress, _ in + // Without a Content-Length the fraction creeps on a guess; the UI stays indeterminate. + guard task.countOfBytesExpectedToReceive > 0 else { return } + continuation.yield(progress.fractionCompleted) + } + self.observation.withLockUnchecked { $0 = observation } + } + + func finish(isComplete: Bool) { + observation.withLockUnchecked { observation in + observation?.invalidate() + observation = nil + } + if isComplete { + continuation.yield(1) + } + continuation.finish() + } } enum PluginUpdateResult: Sendable { diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 7cf4b0b224..a2b0d2d875 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -1,8 +1,3 @@ -// -// PluginManager+AutoUpdate.swift -// TablePro -// - import Combine import Foundation import os @@ -101,7 +96,6 @@ extension PluginManager { do { let outcome = try await updateFromRegistry( registryPlugin, - existingPluginLoaded: false, refreshManifest: false, progress: { _ in } ) @@ -271,10 +265,6 @@ extension PluginManager { rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } } - func outdatedReconcileReason(forTypeId typeId: String) -> String? { - rejectedPlugins.first { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }?.reason - } - func prepareForConnecting(to type: DatabaseType, registryClient: RegistryClient = .shared) async throws { let typeId = type.pluginTypeId if driverPlugin(for: type) == nil, !hasFinishedInitialLoad { diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index 365c5906e1..4cbd56dea2 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -1,8 +1,3 @@ -// -// PluginManager+Install.swift -// TablePro -// - import Foundation import os @@ -26,18 +21,11 @@ extension PluginManager { let registryPlugin = await registryClient.refreshedPlugin(matching: registryPlugin) let binary = try validateRegistryCompatibility(registryPlugin) - let userPluginsDir = self.userPluginsDir - let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in - if case .downloading(let fraction) = state { - await MainActor.run { progress(fraction) } - } - } - let finalURL = try await PluginInstaller.shared.install( registryPlugin, binary: binary, into: userPluginsDir, - progressHandler: stateHandler + progress: { fraction in await progress(fraction) } ) saveRegistryMetadata(pluginId: registryPlugin.id, pluginURL: finalURL) @@ -48,7 +36,6 @@ extension PluginManager { func updateFromRegistry( _ registryPlugin: RegistryPlugin, - existingPluginLoaded: Bool = true, refreshManifest: Bool = true, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginUpdateOutcome { @@ -70,20 +57,12 @@ extension PluginManager { : registryPlugin let binary = try validateRegistryCompatibility(registryPlugin) - let hasLive = pluginHasLiveConnections(registryPlugin) - let userPluginsDir = self.userPluginsDir - let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in - if case .downloading(let fraction) = state { - await MainActor.run { progress(fraction) } - } - } - let result = try await PluginInstaller.shared.update( registryPlugin, binary: binary, into: userPluginsDir, - hasLiveConnections: hasLive, - progressHandler: stateHandler + hasLiveConnections: pluginHasLiveConnections(registryPlugin), + progress: { fraction in await progress(fraction) } ) switch result { @@ -224,11 +203,7 @@ extension PluginManager { throw PluginError.invalidBundle("Cannot create bundle from \(bundleURL.lastPathComponent)") } try PluginCodeSignatureVerifier.verify(bundle: bundle) - try PluginInstaller.validateStagedABI( - bundleURL: bundleURL, - currentKit: Self.currentPluginKitVersion, - minimumKit: Self.minimumCompatiblePluginKitVersion - ) + try Self.validateBundleVersions(bundle) PluginInstaller.stripQuarantine(at: bundleURL) let bundleId = bundle.bundleIdentifier ?? bundleURL.lastPathComponent diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 30b482a8d1..fedfc28013 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -1,8 +1,3 @@ -// -// PluginManager+Registration.swift -// TablePro -// - import Foundation import os import Security @@ -141,36 +136,6 @@ extension PluginManager { } } - /// Warn-level connection field validation. Called lazily on first access via - /// `additionalConnectionFields(for:)`, not during plugin loading (protocol witness - /// tables may be unstable for dynamically loaded bundles during the loading path). - func validateConnectionFields(_ fields: [ConnectionField], pluginId: String) { - var seenIds = Set() - for field in fields { - if field.id.trimmingCharacters(in: .whitespaces).isEmpty { - Self.logger.warning("Plugin '\(pluginId)': connection field has empty id") - } - if field.label.trimmingCharacters(in: .whitespaces).isEmpty { - Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' has empty label") - } - if !seenIds.insert(field.id).inserted { - Self.logger.warning("Plugin '\(pluginId)': duplicate connection field id '\(field.id)'") - } - if case .dropdown(let options) = field.fieldType, options.isEmpty { - Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' is a dropdown with no options") - } - } - } - - func validateDialectDescriptor(_ dialect: SQLDialectDescriptor, pluginId: String) { - if dialect.identifierQuote.trimmingCharacters(in: .whitespaces).isEmpty { - Self.logger.warning("Plugin '\(pluginId)': sqlDialect.identifierQuote is empty") - } - if dialect.keywords.isEmpty { - Self.logger.warning("Plugin '\(pluginId)': sqlDialect.keywords is empty") - } - } - // MARK: - Available Database Types /// All database types with loaded plugins, ordered by display name. diff --git a/TablePro/Core/Plugins/PluginManager+Validation.swift b/TablePro/Core/Plugins/PluginManager+Validation.swift index 61a75d1199..c9a4b1d246 100644 --- a/TablePro/Core/Plugins/PluginManager+Validation.swift +++ b/TablePro/Core/Plugins/PluginManager+Validation.swift @@ -1,43 +1,15 @@ -// -// PluginManager+Validation.swift -// TablePro -// - import Foundation import os -import TableProPluginKit extension PluginManager { - func validateDependencies() { - let loadedIds = Set(plugins.map(\.id)) - for plugin in plugins { - guard plugin.bundle.isLoaded else { continue } - guard let principalClass = plugin.bundle.principalClass as? any TableProPlugin.Type else { continue } - let deps = principalClass.dependencies - for dep in deps { - if !loadedIds.contains(dep) { - Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is not installed") - } - } - } - } - - /// `Bundle.principalClass` loads the executable too, so enabling a plugin needs this gate as - /// well. A built-in bundle is sealed inside the app's own signature. func assertLoadable(_ bundle: Bundle, source: PluginSource) throws { try Self.validateBundleVersions(bundle) + // A built-in bundle is sealed inside the app's own signature. guard source != .builtIn else { return } try PluginCodeSignatureVerifier.verify(bundle: bundle) } - /// Checks the signature of every discovered user plugin, off the main actor. - /// - /// This is not the gate. A bundle's code is loaded through `validateAndLoadBundle` when it is - /// eager and `activateLazyBundle` when it is lazy, and both verify immediately before - /// `PluginBundleLoader.load` whatever this pass concluded. What it adds is that the Plugins - /// pane lists a bad bundle before the person tries to use it, which is all the check on the - /// launch thread ever bought, at a measured 13ms per installed plugin and linear in how many - /// there are. + // Not the load gate: it lets the Plugins pane list a bad bundle before anyone uses it. func sweepPluginSignatures() async { let urls = pendingSignatureChecks pendingSignatureChecks.removeAll() diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index de92bb268c..f0bad69a1c 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -1,8 +1,3 @@ -// -// PluginManager.swift -// TablePro -// - import Combine import Foundation import Network @@ -14,144 +9,13 @@ import TableProPluginKit @MainActor final class PluginManager: ObservableObject { static let shared = PluginManager(userDefaults: AppStorageEnvironment.shared.defaults) - /// Raised to 29 for `maintenanceOperations` on `PluginDatabaseDriver`, plus the - /// `PluginMaintenanceOperation`, `PluginMaintenanceOption`, `PluginMaintenanceScope` and - /// `PluginObjectKind` it answers with. Together they say which object kinds a maintenance - /// operation may name, whether its statement names an object at all, and which options it reads, - /// none of which the older list of bare names could. - /// - /// Raised to 28 before that for `fetchCommentDDL` on `PluginDatabaseDriver` and `PluginExportDataSource`, - /// which is what lets a dump reattach a relation's own comment and its column comments instead - /// of leaving whether they appear at all to each driver's `fetchTableDDL`. - /// - /// Raised to 27 before that for `hasLostConnection`, `unsupportedStructureColumnFields`, - /// `unsupportedIndexTypes` and `schemaOperationRefusal` on `PluginDatabaseDriver`, plus the - /// `PluginSchemaOperation` the last one answers about. - /// - /// Raised to 26 before that for `objectCommentStatement`, `refreshMaterializedViewStatement` and - /// `concurrentRefreshAvailability` on `PluginDatabaseDriver`, plus the - /// `PluginConcurrentRefreshAvailability` the last one answers with. - /// - /// Raised to 23 for `releasableResourceCommandTitle` and `releaseIdleResource` on - /// `PluginDatabaseDriver`, plus the `PluginResourceRelease` they answer with. Together they let - /// a driver hand back a resource its session is holding without ending the session. DuckDB is - /// the first to answer them: it takes a whole-file write lock for the life of its handle, so an - /// idle connection stops every other process from opening the same database. - /// - /// Raised to 30 for `ExportFormatResult.notes` and its `init(warnings:notes:)`, which is what lets - /// an export report a fact about what it wrote without the summary alert reading it as a problem. - /// The old `init(warnings:)` is kept verbatim as `@_disfavoredOverload`, so every already-built - /// export plugin keeps loading and only a plugin rebuilt against the new initializer needs a host - /// that has it. - /// - /// Raised to 22 before that for `fetchIndexDDL` on `PluginDatabaseDriver` and `PluginExportDataSource`, - /// which is what lets a dump write a table's indexes after its rows instead of leaving whether - /// they appear at all to each driver's `fetchTableDDL`. - /// - /// Raised to 21 before that for two additions: `tableDDLIncludesForeignKeys` on `PluginDatabaseDriver` and - /// `PluginExportDataSource`, and `PluginQueryTiming` with the `PluginQueryResult` initializer - /// that carries it. Raised to 20 before that for the whole-schema index and table metadata - /// requirements. - /// - /// Every one of these is safe in the direction Library Evolution covers, so an already-built - /// v19 or v20 plugin keeps loading here. The break is the other way round: a plugin compiled - /// against the new API emits undefined references to symbols an older host does not have. - /// Measured on a rebuilt ClickHouseDriver, whose `nm -u` lists - /// `PluginQueryTiming.init(total:firstRow:server:)` and that type's metadata accessor, and on a - /// rebuilt CassandraDriver for the v20 requirements it implements none of. Left at 20, such a - /// plugin passes `validateBundleVersions` in a shipped v20 app and then fails - /// `Bundle.loadAndReturnError`; at 21 that app refuses it and says to update. - /// - /// 31 adds `aliasType`, `tableType` and `clrType` to `PluginUserDefinedTypeKind` and - /// `adoptingSchema` to `PluginUserDefinedTypeInfo`. The enum is not `@frozen` and every app-side - /// switch over it already carries `@unknown default`, so an already-built plugin keeps loading; - /// the minimum stays where it is and no bulk re-release is needed. - /// - /// 32 adds the spellings a catalog read carries for a DDL writer: `ddlSpelling`, `ddlDefault`, - /// `ddlGenerationExpression` and `ddlCollation` on `PluginColumnInfo`, and `expressions`, - /// `includedColumns`, `ddlMethodAndKeys` and `ddlWhereClause` on `PluginIndexInfo`, plus the open - /// `IndexType`. Each arrives through an added initializer while every published one stays - /// byte-identical and disfavoured. - /// - /// 33 adds `classificationTypeName` to `PluginColumnInfo`, the name the app classifies a column - /// by where its declared spelling names no kind: a PostgreSQL enum, a domain and a PostGIS - /// geometry all classify as text otherwise, which takes the value picker off an enum and the - /// spatial rendering off a geometry. - /// - /// 33 also adds `checkConstraintRefusal`, which reports why the connected server has no check - /// constraints even though the engine does, and `sessionTransactionState()`, which reports what - /// the session already has open so nothing the app owns wraps a transaction the user opened. - /// Both have defaults (nil and `.unknown`), so an already-built plugin keeps loading and - /// answers them; the minimum stays where it is and no bulk re-release is needed. - /// - /// 33 also adds `isValid` to `PluginIndexInfo`, through an added initializer with the previous - /// full one disfavoured; nil means the driver does not report it. - /// - /// 33 also adds `generateModifyIndexSQL(table:oldIndexName:newIndex:)`, which replaces an index - /// in one statement where the engine's DDL is not transactional, the public `SQLiteIndexCatalog` - /// that SQLite, libSQL and Cloudflare D1 read and write indexes through, and the public - /// `SQLIndexKeyList` it and DuckDB read a stored `CREATE INDEX` with. The requirement defaults - /// to nil, so an already-built plugin keeps loading and the app splits the change into a drop - /// and an add as before. - /// - /// 33 also adds `createTableFormSpec(schema:)` and `createTableStatements(for:schema:)`, the - /// Create Table form a driver describes for tables that are not a list of typed columns. The - /// defaults answer nil and throw, so an already-built plugin keeps the column grid. It adds the - /// `modifyIndex` and `dropIndex` cases to the non-frozen `PluginSchemaOperation`, which an - /// already-built plugin answers through its `@unknown default`. - /// - /// 33 also adds the `modifyColumn` and `dropColumn` cases to `PluginSchemaOperation`, the - /// `PluginSchemaChangeReview` value, and `reviewSchemaChange(table:schema:operations:)`, - /// `schemaChangeRefusalBeforeWriting(table:schema:operations:review:)` and - /// `schemaChangeShortfallAfterWriting(table:schema:operations:review:)`, the save-level - /// questions a document store needs the server to answer, and - /// `tableDefinitionDidChange(table:schema:)`, which tells the session's driver to drop what it - /// learned about a table another connection changed. The defaults approve every save, find - /// every save finished and keep nothing, so an already-built plugin keeps loading and saves as - /// before. - /// - /// 34 adds `PluginRowWriteContext` and `generateRowWrites(...context:)`, which hands a driver - /// that writes its own statements the columns the server owns and how a keyless row match has to - /// compare each column. The default forwards to the requirement without the context, so an - /// already-built plugin keeps loading and writes what it wrote before; the minimum stays where it - /// is and no bulk re-release is needed. - /// - /// 35 adds `PluginRowWriteContext.columnTypeNames`, the type each column was read as. A plugin - /// built against it reads a property a 34 host does not have, so the number moves; an - /// already-built plugin never reads it, so the minimum stays and no bulk re-release is needed. - /// - /// 36 adds `PluginStatementContext` and the `context:` variants of `execute`, - /// `executeParameterized`, `executeUserQuery` and `streamRows`, which tell a driver the host ran - /// a statement as a proven read. The defaults forward to the requirements without the context, - /// so an already-built plugin keeps loading and runs what it ran before; the minimum stays and no - /// bulk re-release is needed. - /// - /// 37 adds `GoogleCloudSQLAuthFields`, the Cloud SQL IAM choices on the Authentication picker, - /// and `DriverConnectionConfig.refreshPassword`, which mints a token password again for a - /// sign-in the driver makes on its own. The old initializer keeps its symbol, so the minimum - /// stays and no bulk re-release is needed. - /// - /// 38 adds `PluginDatabaseDriver.connectionWarning`, the warning a server sent with a login that - /// succeeded, such as an Oracle password in its grace period. The default is `nil`, so an - /// already-built plugin keeps loading and reports none; the minimum stays and no bulk re-release - /// is needed. - /// - /// 39 adds `PluginRowChange.expressionColumns` and the `cellExpressions` and `uuidCellText` bits of - /// `PluginCapabilities`. An already-built plugin declares neither bit, so the host hands it no - /// expression and offers no generated UUID through it; the minimum stays and no bulk re-release is - /// needed. nonisolated static let currentPluginKitVersion = 39 - /// Still 19, so every plugin already published for the previous release keeps loading. nonisolated static let minimumCompatiblePluginKitVersion = 19 private let builtInPluginsURL: URL? internal let userPluginsDir: URL - /// Every plugin collection here is published. The class was `@Observable` until #2874, which - /// tracked these without a word, and Settings > Plugins and the rejected-plugin banner are - /// written against that: without it an install, an update or a rejection changed nothing on - /// screen until the pane was reopened. @Published internal(set) var plugins: [PluginEntry] = [] @Published internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:] @@ -245,8 +109,6 @@ final class PluginManager: ObservableObject { internal var lastNetworkSatisfied = false internal var installsInFlight: Set = [] - /// User-installed bundles discovered but not yet signature-checked. `sweepPluginSignatures()` - /// drains it after the first frame. internal var pendingSignatureChecks: [URL] = [] @Published var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] @@ -354,7 +216,6 @@ final class PluginManager: ObservableObject { self.recordEagerLoadFailures(loaded.failures) let validated = loaded.validated self.registerValidatedBundles(validated) - self.validateDependencies() self.hasFinishedInitialLoad = true let eagerCount = validated.count Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(lazyCount) lazy + \(eagerCount) eager (\(self.driverPlugins.count) driver(s) active, \(self.exportPlugins.count) export(s) active, \(self.importPlugins.count) import(s) active)") @@ -408,23 +269,6 @@ final class PluginManager: ObservableObject { private func registerLazyManifest(at url: URL, source: PluginSource, manifest: PluginManifest) { guard let bundle = Bundle(url: url) else { return } - do { - try Self.validateBundleVersions(bundle) - } catch { - Self.logger.error("Lazy plugin '\(manifest.bundleId)' failed version check: \(error.localizedDescription)") - if source == .userInstalled { - rejectedPlugins.append(RejectedPlugin( - url: url, - bundleId: manifest.bundleId, - registryId: Self.readRegistryMetadata(for: url)?.pluginId, - name: manifest.bundleId, - reason: error.localizedDescription, - isOutdated: (error as? PluginError)?.isOutdated ?? false, - providedDatabaseTypeIds: manifest.providedDatabaseTypeIds - )) - } - return - } let bundleId = manifest.bundleId let primaryTypeId = manifest.providedDatabaseTypeIds.first let additionalTypeIds = Array(manifest.providedDatabaseTypeIds.dropFirst()) @@ -676,9 +520,7 @@ final class PluginManager: ObservableObject { private struct EagerLoadFailure { let url: URL - let source: PluginSource let reason: String - let isOutdated: Bool } nonisolated private static func validateAndLoadBundles( @@ -692,12 +534,7 @@ final class PluginManager: ObservableObject { results.append(ValidatedBundle(url: entry.url, source: entry.source, bundle: bundle)) } catch { logger.error("Failed to load plugin at \(entry.url.lastPathComponent): \(error.localizedDescription)") - failures.append(EagerLoadFailure( - url: entry.url, - source: entry.source, - reason: error.localizedDescription, - isOutdated: (error as? PluginError)?.isOutdated ?? false - )) + failures.append(EagerLoadFailure(url: entry.url, reason: error.localizedDescription)) } } return (results, failures) @@ -713,7 +550,7 @@ final class PluginManager: ObservableObject { registryId: registryId, name: failure.url.deletingPathExtension().lastPathComponent, reason: failure.reason, - isOutdated: failure.source == .userInstalled && failure.isOutdated, + isOutdated: false, providedDatabaseTypeIds: Self.databaseTypeIds(of: bundle, registryId: registryId) )) } @@ -907,22 +744,6 @@ final class PluginManager: ObservableObject { } } - func loadPendingPluginsAsync(clearRestartFlag: Bool = false) async { - if clearRestartFlag { - needsRestart = false - } - guard !pendingPluginURLs.isEmpty else { return } - let pending = pendingPluginURLs - pendingPluginURLs.removeAll() - - let loaded = await Self.validateAndLoadBundles(pending) - recordEagerLoadFailures(loaded.failures) - registerValidatedBundles(loaded.validated) - hasFinishedInitialLoad = true - validateDependencies() - Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)") - } - private func discoverPlugin(at url: URL, source: PluginSource) throws { guard let bundle = Bundle(url: url) else { throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") @@ -930,12 +751,7 @@ final class PluginManager: ObservableObject { try Self.validateBundleVersions(bundle) - /// The signature is not checked here. `SecStaticCodeCheckValidity` hashes the whole bundle, - /// measured at 13ms per user-installed plugin and linear in how many are installed, and - /// discovery loads nothing: it only records the URL. The two gates that decide whether a - /// bundle's code runs both stay where they are, `validateAndLoadBundle` for an eager plugin - /// and `activateLazyBundle` for a lazy one, and `sweepPluginSignatures()` re-checks these - /// off the main actor once the first window is up so the Plugins pane still lists a bad one. + // Discovery loads no code; the signature is checked at load and by sweepPluginSignatures(). if source == .userInstalled { pendingSignatureChecks.append(url) } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 09a2952a0c..fb6fa15ec1 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -1,8 +1,3 @@ -// -// PluginMetadataRegistry.swift -// TablePro -// - import Foundation import os import TableProPluginKit @@ -801,10 +796,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: additionalConnectionFields, - category: existingSnapshot?.connection.category - ?? Self.fallbackCategory(forTypeId: driverType.databaseTypeId), - tagline: existingSnapshot?.connection.tagline - ?? Self.fallbackTagline(forTypeId: driverType.databaseTypeId), + category: existingSnapshot?.connection.category ?? .other, + tagline: existingSnapshot?.connection.tagline ?? "", hidesBuiltInPassword: existingSnapshot?.connection.hidesBuiltInPassword ?? false, hidesBuiltInDatabase: existingSnapshot?.connection.hidesBuiltInDatabase ?? false, defaultUnixSocketPath: existingSnapshot?.connection.defaultUnixSocketPath, @@ -814,68 +807,6 @@ final class PluginMetadataRegistry: @unchecked Sendable { ) } - // MARK: - Category / Tagline Fallback Table - - /// Seed table for plugin types that don't have a built-in snapshot yet (separately distributed plugins). - /// Keyed by `databaseTypeId`. Stale plugins from the registry inherit these on registration. - static func fallbackCategory(forTypeId typeId: String) -> DatabaseCategory { - switch typeId { - case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL": - return .relational - case "Redshift", "ClickHouse", "DuckDB", "BigQuery": - return .analytical - case "Spanner": - return .relational - case "MongoDB", "Elasticsearch", "SurrealDB", "Typesense", "Weaviate", "Firestore", "Firebase Realtime Database": - return .document - case "Redis": - return .keyValue - case "Cassandra", "ScyllaDB", "Bigtable": - return .wideColumn - case "etcd", "Etcd": - return .coordination - case "Cloudflare D1", "libSQL", "DynamoDB": - return .cloud - case "Kafka": - return .streaming - default: - return .other - } - } - - static func fallbackTagline(forTypeId typeId: String) -> String { - switch typeId { - case "MySQL": return String(localized: "Most popular open-source SQL database") - case "MariaDB": return String(localized: "Open-source fork of MySQL") - case "PostgreSQL": return String(localized: "Advanced object-relational SQL") - case "Redshift": return String(localized: "Amazon's columnar warehouse on Postgres") - case "SQLite": return String(localized: "Embedded zero-config SQL database") - case "MSSQL": return String(localized: "Microsoft's enterprise SQL database") - case "Oracle": return String(localized: "Enterprise SQL with PL/SQL") - case "MongoDB": return String(localized: "JSON-style document database") - case "Elasticsearch": return String(localized: "Search and analytics engine") - case "Redis": return String(localized: "In-memory data store and cache") - case "ClickHouse": return String(localized: "Column-oriented OLAP for big data") - case "DuckDB": return String(localized: "Embedded analytical SQL") - case "Cassandra": return String(localized: "Distributed wide-column store") - case "ScyllaDB": return String(localized: "C++ rewrite of Cassandra, faster") - case "etcd", "Etcd": return String(localized: "Distributed key-value store for service discovery") - case "Cloudflare D1": return String(localized: "Serverless SQLite at the edge") - case "libSQL": return String(localized: "Distributed SQLite by Turso") - case "DynamoDB": return String(localized: "AWS managed key-value/document store") - case "BigQuery": return String(localized: "Google Cloud serverless data warehouse") - case "Spanner": return String(localized: "Google Cloud globally distributed SQL") - case "SurrealDB": return String(localized: "Multi-model database with SurrealQL") - case "Kafka": return String(localized: "Event streaming platform") - case "Typesense": return String(localized: "Typo-tolerant open-source search engine") - case "Weaviate": return String(localized: "Open-source vector database") - case "Firestore": return String(localized: "Serverless document database from Google") - case "Bigtable": return String(localized: "Google Cloud wide-column store for large data") - case "Firebase Realtime Database": return String(localized: "JSON tree database that syncs in real time") - default: return "" - } - } - func allFileExtensions() -> [String: String] { lock.lock() defer { lock.unlock() } diff --git a/TablePro/Core/Plugins/PluginUpdateCoordinator.swift b/TablePro/Core/Plugins/PluginUpdateCoordinator.swift index ad38f6e552..9658d7f77a 100644 --- a/TablePro/Core/Plugins/PluginUpdateCoordinator.swift +++ b/TablePro/Core/Plugins/PluginUpdateCoordinator.swift @@ -1,8 +1,3 @@ -// -// PluginUpdateCoordinator.swift -// TablePro -// - import Foundation extension PluginManager { @@ -21,7 +16,6 @@ extension PluginManager { tracker.completeInstall(pluginId: registryPlugin.id) return .succeeded(entry: entry) case .staged: - tracker.markStaged(pluginId: registryPlugin.id, newVersion: registryPlugin.version) return .staged } } catch { @@ -29,24 +23,6 @@ extension PluginManager { return .failed(error: error) } } - - func performRegistryInstall(_ registryPlugin: RegistryPlugin) async -> PluginActionResult { - let tracker = PluginInstallTracker.shared - tracker.beginInstall(pluginId: registryPlugin.id) - do { - let entry = try await installFromRegistry(registryPlugin) { fraction in - tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) - if fraction >= 1.0 { - tracker.markInstalling(pluginId: registryPlugin.id) - } - } - tracker.completeInstall(pluginId: registryPlugin.id) - return .succeeded(entry: entry) - } catch { - tracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) - return .failed(error: error) - } - } } enum PluginActionResult: Sendable { diff --git a/TablePro/Core/Services/Notifications/PluginNotificationService.swift b/TablePro/Core/Services/Notifications/PluginNotificationService.swift index b8884e2277..ce8033f5a6 100644 --- a/TablePro/Core/Services/Notifications/PluginNotificationService.swift +++ b/TablePro/Core/Services/Notifications/PluginNotificationService.swift @@ -1,15 +1,10 @@ -// -// PluginNotificationService.swift -// TablePro -// - import Combine import Foundation import os import UserNotifications @MainActor -final class PluginNotificationService: ObservableObject { +final class PluginNotificationService { static let shared = PluginNotificationService() static let identifierPrefix = "com.TablePro.plugin." @@ -18,8 +13,6 @@ final class PluginNotificationService: ObservableObject { private static let failedIdentifierPrefix = identifierPrefix + "failed." nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PluginNotifications") - @Published private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined - private var cancellables: Set = [] private var deliveredFailureIdentifiers: Set = [] @@ -29,22 +22,11 @@ final class PluginNotificationService: ObservableObject { registerCategories() NotificationRouter.shared.register(self, forCategory: Self.updateFailedCategoryId) subscribeToEvents() - Task { await refreshAuthorizationStatus() } - } - - /// Routed through the one requester in the app. The options a grant carries are frozen by the - /// first request ("Subsequent authorization requests don't prompt the person"), so two services - /// asking separately would mean whichever asked first silently decided what the other could - /// ever do. - func requestPermissionIfNeeded() async { - _ = await NotificationAuthorization.shared.ensureAuthorized() - await refreshAuthorizationStatus() } func notifyAutoUpdateFailed(plugins: [RejectedPlugin]) async { guard !plugins.isEmpty else { return } guard await NotificationAuthorization.shared.ensureAuthorized() else { return } - await refreshAuthorizationStatus() let center = UNUserNotificationCenter.current() let incomingIdentifiers = Set(plugins.map(Self.failureIdentifier)) @@ -113,10 +95,6 @@ final class PluginNotificationService: ObservableObject { } .store(in: &cancellables) } - - private func refreshAuthorizationStatus() async { - authorizationStatus = await NotificationAuthorization.shared.refresh() - } } extension PluginNotificationService: NotificationHandling { diff --git a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift deleted file mode 100644 index 94520e4b6f..0000000000 --- a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Testing -@testable import TablePro - -@Suite("PluginManager needsRestart State", .serialized) -@MainActor -struct NeedsRestartPersistenceTests { - @Test("loadPendingPlugins with no pending plugins does not set needsRestart") - func loadPendingPluginsKeepsFalse() async { - await PluginManager.shared.loadPendingPluginsAsync() - #expect(PluginManager.shared.needsRestart == false) - } -} diff --git a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift index f9703a9c18..4f2b5801b0 100644 --- a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift +++ b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift @@ -1,13 +1,38 @@ -// -// PluginInstallerHelpersTests.swift -// TableProTests -// - import Darwin import Foundation import Testing @testable import TablePro +private final class ChunkedDownloadProtocol: URLProtocol, @unchecked Sendable { + static let payload = Data(repeating: 7, count: 256 * 1024) + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + guard let url = request.url else { return } + let headers = url.lastPathComponent == "sized.zip" ? ["Content-Length": "\(Self.payload.count)"] : [:] + guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers) else { + return + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + let chunk = Self.payload.count / 8 + for offset in stride(from: 0, to: Self.payload.count, by: chunk) { + client?.urlProtocol(self, didLoad: Self.payload.subdata(in: offset.. URL { @@ -82,36 +107,103 @@ struct PluginInstallerHelpersTests { PluginInstaller.stripQuarantine(at: bundle) } - @Test("validateStagedABI rejects bundle missing TableProPluginKitVersion") - func validateStagedABIRejectsMissingKey() throws { - let dir = try makeTempDir() - defer { try? FileManager.default.removeItem(at: dir) } - - let bundle = dir.appendingPathComponent("Bad.tableplugin", isDirectory: true) - try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) - let contents = bundle.appendingPathComponent("Contents", isDirectory: true) + private func makeBundle(in directory: URL, kitVersion: Int?) throws -> Bundle { + let bundleURL = directory.appendingPathComponent("Kit.tableplugin", isDirectory: true) + let contents = bundleURL.appendingPathComponent("Contents", isDirectory: true) try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) - let emptyPlist = """ + let kitEntry = kitVersion.map { "TableProPluginKitVersion\($0)" } ?? "" + let payload = """ - + + CFBundleIdentifiercom.example.Kit + \(kitEntry) + """ - try emptyPlist.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + try payload.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + return try #require(Bundle(url: bundleURL)) + } - #expect(throws: PluginError.self) { - try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, minimumKit: 13) + @Test("validateBundleVersions refuses a bundle with no PluginKit version") + func bundleVersionsRejectsMissingKey() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let bundle = try makeBundle(in: dir, kitVersion: nil) + let error = #expect(throws: PluginError.self) { try PluginManager.validateBundleVersions(bundle) } + #expect(error?.isOutdated == true) + } + + @Test("validateBundleVersions refuses a bundle built for a newer PluginKit") + func bundleVersionsRejectsNewerKit() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let bundle = try makeBundle(in: dir, kitVersion: PluginManager.currentPluginKitVersion + 1) + let error = #expect(throws: PluginError.self) { try PluginManager.validateBundleVersions(bundle) } + guard case .incompatibleVersion(let required, let current) = error else { + Issue.record("Expected incompatibleVersion, got \(String(describing: error))") + return } + #expect(required == PluginManager.currentPluginKitVersion + 1) + #expect(current == PluginManager.currentPluginKitVersion) } - @Test("validateStagedABI passes when plist matches current kit version") - func validateStagedABIPassesMatch() throws { + @Test("validateBundleVersions refuses a bundle below the PluginKit floor") + func bundleVersionsRejectsKitBelowFloor() throws { let dir = try makeTempDir() defer { try? FileManager.default.removeItem(at: dir) } - let bundle = try makeFakeBundle(at: dir, name: "Driver") - try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, minimumKit: 13) + let bundle = try makeBundle(in: dir, kitVersion: PluginManager.minimumCompatiblePluginKitVersion - 1) + let error = #expect(throws: PluginError.self) { try PluginManager.validateBundleVersions(bundle) } + #expect(error?.isOutdated == true) + } + + @Test("validateBundleVersions accepts the current PluginKit and the floor", arguments: [ + PluginManager.currentPluginKitVersion, + PluginManager.minimumCompatiblePluginKitVersion + ]) + func bundleVersionsAcceptsSupportedKit(kitVersion: Int) throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let bundle = try makeBundle(in: dir, kitVersion: kitVersion) + try PluginManager.validateBundleVersions(bundle) + } + + private func download(_ name: String) async throws -> (data: Data, fractions: [Double]) { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ChunkedDownloadProtocol.self] + let session = URLSession(configuration: config) + defer { session.finishTasksAndInvalidate() } + let recorder = FractionRecorder() + let url = try #require(URL(string: "https://plugins.example.invalid/\(name)")) + let (file, _) = try await PluginInstaller.download(url, with: session) { fraction in + await recorder.record(fraction) + } + defer { try? FileManager.default.removeItem(at: file) } + return (try Data(contentsOf: file), await recorder.fractions) + } + + @Test("download reports the task's own progress and ends at 1") + func downloadReportsTaskProgress() async throws { + let (data, fractions) = try await download("sized.zip") + + #expect(data == ChunkedDownloadProtocol.payload) + #expect(fractions.contains { $0 < 1 }) + #expect(fractions.last == 1) + #expect(fractions.allSatisfy { $0 > 0 && $0 <= 1 }) + #expect(zip(fractions, fractions.dropFirst()).allSatisfy { $0 <= $1 }) + } + + @Test("download reports no fraction until the end when the length is unknown") + func downloadWithoutLengthStaysIndeterminate() async throws { + let (data, fractions) = try await download("unsized.zip") + + #expect(data == ChunkedDownloadProtocol.payload) + #expect(fractions == [1]) } @Test("findBundle returns the single .tableplugin in a directory") diff --git a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift deleted file mode 100644 index 8ebddaef93..0000000000 --- a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// PluginLazyLoadingTests.swift -// TableProTests -// -// Tests for lazy plugin loading behavior -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -@Suite("Plugin Lazy Loading", .serialized) -@MainActor -struct PluginLazyLoadingTests { - @Test("loadPendingPlugins is idempotent when called multiple times") - func loadPendingPluginsIdempotent() async { - // loadPendingPlugins should not crash or duplicate when called multiple times - let manager = PluginManager.shared - await manager.loadPendingPluginsAsync() - let countAfterFirst = manager.plugins.count - await manager.loadPendingPluginsAsync() - let countAfterSecond = manager.plugins.count - #expect(countAfterFirst == countAfterSecond) - } - - @Test("loadPendingPlugins populates driverPlugins") - func loadPendingPopulatesDrivers() async { - let manager = PluginManager.shared - await manager.loadPendingPluginsAsync() - // After loading, at least some driver plugins should be registered - // (the built-in plugins are always available in the test bundle) - #expect(manager.driverPlugins.isEmpty == false || manager.plugins.isEmpty) - } - - @Test("loadPendingPlugins with no pending is no-op") - func loadPendingNoPendingIsNoOp() async { - let manager = PluginManager.shared - // Ensure all pending are loaded first - await manager.loadPendingPluginsAsync() - let driverCount = manager.driverPlugins.count - let pluginCount = manager.plugins.count - // Call again - should be no-op - await manager.loadPendingPluginsAsync() - #expect(manager.driverPlugins.count == driverCount) - #expect(manager.plugins.count == pluginCount) - } -} diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index 467198c063..68f652b25a 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -216,19 +216,6 @@ struct PluginManagerReconciliationTests { #expect(!pm.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) } - @Test("outdatedReconcileReason surfaces the rejected plugin's reason for the type") - func outdatedReconcileReasonReturnsReason() { - let pm = PluginManager.shared - let rejected = makeRejected( - reason: "A newer version of TablePro is required for this plugin.", - providedDatabaseTypeIds: ["TestDriverType"] - ) - pm.rejectedPlugins.append(rejected) - defer { pm.removeFromRejected(url: rejected.url) } - #expect(pm.outdatedReconcileReason(forTypeId: "TestDriverType") == "A newer version of TablePro is required for this plugin.") - #expect(pm.outdatedReconcileReason(forTypeId: "OtherDriverType") == nil) - } - @Test("incompatible-build errors are permanent reconciliation failures") func permanentFailuresClassified() { #expect(PluginError.noCompatibleBinary.isPermanentReconciliationFailure) @@ -251,11 +238,4 @@ struct PluginManagerReconciliationTests { #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: false, retryRemaining: true)) #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: false, retryRemaining: false)) } - - @Test("forceRefresh manifest request bypasses the local cache and sends no If-None-Match") - func forceRefreshRequestBypassesCache() { - let request = RegistryClient.shared.makeManifestRequest(forceRefresh: true) - #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) - #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) - } } diff --git a/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift b/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift deleted file mode 100644 index ffeb8a5797..0000000000 --- a/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// PluginManagerStagingCleanupTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -struct PluginManagerStagingCleanupTests { - - private func makeTempPluginsDir() throws -> URL { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("StagingTests-\(UUID().uuidString)/Plugins", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - @Test("stagingRoot derived path is a sibling of userPluginsDir") - func stagingRootSibling() throws { - let userPluginsDir = try makeTempPluginsDir() - defer { try? FileManager.default.removeItem(at: userPluginsDir.deletingLastPathComponent()) } - - let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) - #expect(stagingRoot.lastPathComponent == "PluginStaging") - #expect(stagingRoot.deletingLastPathComponent() == userPluginsDir.deletingLastPathComponent()) - } - - @Test("staging root can be created and populated then enumerated") - func stagingRootEnumerable() throws { - let userPluginsDir = try makeTempPluginsDir() - defer { try? FileManager.default.removeItem(at: userPluginsDir.deletingLastPathComponent()) } - - let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) - try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) - - let leftover = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: leftover, withIntermediateDirectories: true) - try Data("partial".utf8).write(to: leftover.appendingPathComponent("scratch.zip")) - - let contents = try FileManager.default.contentsOfDirectory( - at: stagingRoot, - includingPropertiesForKeys: nil - ) - #expect(!contents.isEmpty) - - for item in contents { - try FileManager.default.removeItem(at: item) - } - - let afterCleanup = try FileManager.default.contentsOfDirectory( - at: stagingRoot, - includingPropertiesForKeys: nil - ) - #expect(afterCleanup.isEmpty) - } -} diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index c5b464367b..a406054b63 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -1,27 +1,8 @@ -// -// PluginMetadataRegistryTypeCountTests.swift -// TableProTests -// - import Foundation @testable import TablePro import Testing -/// The number of engines TablePro offers is published in three places that cannot see each other: -/// this registry, `snippets/driver-counts.mdx` in TableProApp/docs, and the marketing site. Nothing at runtime -/// reconciles them, and by August 2026 they read 28, 27 and 25 at once. -/// -/// The answer is 40, and the reason it once read 28 is worth keeping. Turso is served by -/// the libSQL plugin and was the only alias in `reverseTypeIndex` with no curated entry of its -/// own, so it was the only type the picker could not offer before its plugin was installed. -/// ScyllaDB is the shape every other alias already had: an alias of Cassandra with a curated -/// entry all the same. Turso now matches it, so the count falls out of the registry rather than -/// being asserted on top of it. Do not "correct" it downwards by deleting that entry. -/// -/// A failure here means a driver was added or removed, which is a deliberate act. Update -/// `expectedTypeIds`, then, in TableProApp/docs, `snippets/driver-counts.mdx` and the `and N more` -/// count in the `index.mdx` frontmatter, and the engine count on tablepro.app. The docs repository's -/// `scripts/check-docs-against-source.py` reads this registry and holds the docs half. +// A change here also changes the engine counts in TableProApp/docs and on tablepro.app. @MainActor struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ diff --git a/TableProTests/Core/Plugins/PluginModelsTests.swift b/TableProTests/Core/Plugins/PluginModelsTests.swift deleted file mode 100644 index 7c430f0c24..0000000000 --- a/TableProTests/Core/Plugins/PluginModelsTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// PluginModelsTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -struct PluginEntryTests { - - private func makeEntry( - databaseTypeId: String? = nil, - additionalTypeIds: [String] = [], - pluginIconName: String = "puzzlepiece", - defaultPort: Int? = nil - ) -> PluginEntry { - PluginEntry( - id: "test.plugin", - bundle: Bundle.main, - url: Bundle.main.bundleURL, - source: .builtIn, - name: "Test Plugin", - version: "1.0.0", - pluginDescription: "A test plugin", - capabilities: [.databaseDriver], - databaseTypeId: databaseTypeId, - additionalTypeIds: additionalTypeIds, - pluginIconName: pluginIconName, - defaultPort: defaultPort, - exportFormatId: nil, - importFormatId: nil - ) - } - - @Test("databaseTypeId returns nil when not set") - func databaseTypeIdNil() { - let entry = makeEntry() - #expect(entry.databaseTypeId == nil) - } - - @Test("databaseTypeId returns value when set") - func databaseTypeIdSet() { - let entry = makeEntry(databaseTypeId: "MySQL") - #expect(entry.databaseTypeId == "MySQL") - } - - @Test("additionalTypeIds returns empty array by default") - func additionalTypeIdsEmpty() { - let entry = makeEntry() - #expect(entry.additionalTypeIds.isEmpty) - } - - @Test("defaultPort returns nil when not set") - func defaultPortNil() { - let entry = makeEntry() - #expect(entry.defaultPort == nil) - } - - @Test("pluginIconName returns provided value") - func pluginIconName() { - let entry = makeEntry(pluginIconName: "mysql-icon") - #expect(entry.pluginIconName == "mysql-icon") - } -} - -struct PluginSourceTests { - - @Test("PluginSource has builtIn and userInstalled cases") - func pluginSourceCases() { - let builtIn = PluginSource.builtIn - let userInstalled = PluginSource.userInstalled - - #expect(builtIn != userInstalled) - } -} - -struct PluginEntryIdentityTests { - - @Test("id property serves as the Identifiable conformance") - func identifiable() { - let entry = PluginEntry( - id: "com.example.test-plugin", - bundle: Bundle.main, - url: Bundle.main.bundleURL, - source: .userInstalled, - name: "Test", - version: "0.1.0", - pluginDescription: "", - capabilities: [], - databaseTypeId: nil, - additionalTypeIds: [], - pluginIconName: "puzzlepiece", - defaultPort: nil, - exportFormatId: nil, - importFormatId: nil - ) - #expect(entry.id == "com.example.test-plugin") - } -} diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index f3fc158c9f..b14fc4f357 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -112,7 +112,6 @@ struct RegistryClientFreshnessTests { let (client, _, _) = try makeEnvironment() let request = client.makeManifestRequest(forceRefresh: false) #expect(request.cachePolicy == .reloadRevalidatingCacheData) - #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) } @Test("forced manifest request bypasses the local cache entirely") diff --git a/TableProTests/Models/GeneralSettingsTests.swift b/TableProTests/Models/GeneralSettingsTests.swift index 7d47fb8710..c6d80acafa 100644 --- a/TableProTests/Models/GeneralSettingsTests.swift +++ b/TableProTests/Models/GeneralSettingsTests.swift @@ -107,23 +107,3 @@ struct GeneralSettingsBoolSettingTests { } } } - -struct GeneralSettingsUpdatePreferenceTests { - @Test("A settings blob still carrying automaticallyCheckForUpdates decodes without it") - func decodesBlobCarryingTheRemovedKey() throws { - let json = Data(#"{"startupBehavior":"showWelcome","automaticallyCheckForUpdates":false,"showRecentTables":true,"queryTimeoutSeconds":90}"#.utf8) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) - - #expect(decoded.showRecentTables == true) - #expect(decoded.queryTimeoutSeconds == 90) - } - - @Test("The encoded blob no longer carries the key Sparkle owns") - func doesNotEncodeTheUpdatePreference() throws { - let data = try JSONEncoder().encode(GeneralSettings.default) - let deserialized = try JSONSerialization.jsonObject(with: data) - let object = try #require(deserialized as? [String: Any]) - - #expect(object["automaticallyCheckForUpdates"] == nil) - } -}