diff --git a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift deleted file mode 100644 index f3417b8713..0000000000 --- a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// MissingDriverPluginPrompt.swift -// TablePro -// - -import AppKit -import os - -/// Asks for the driver a file needs before the file is opened, rather than raising a window -/// headlined "Could not connect" whose only action leaves for the connection list. -@MainActor -internal enum MissingDriverPluginPrompt { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingDriverPluginPrompt") - - /// Whether the open may go ahead. - internal static func ensureInstalled(for type: DatabaseType, opening url: URL) async -> Bool { - /// A plugin without `TableProProvidesDatabaseTypeIds` registers on the eager path, which a - /// Finder open beats to the question: launch intents route on a fixed 150ms timer. Asking - /// before the barrier offers to install a plugin the user already has. - if !PluginManager.shared.isDriverInstalled(for: type) { - await PluginManager.shared.waitForInitialLoad() - } - guard !PluginManager.shared.isDriverInstalled(for: type) else { return true } - /// A plugin that ships inside the app and still did not load is disabled or damaged, and - /// downloading cannot fix either. The connect attempt reports what actually went wrong. - guard type.isDownloadablePlugin else { return true } - - let displayName = PluginMetadataRegistry.shared.snapshot(for: type)?.displayName ?? type.rawValue - let confirmed = await AlertHelper.confirm( - title: String( - format: String(localized: "Install the %@ plugin to open “%@”?"), - displayName, - url.lastPathComponent - ), - message: String(localized: "TablePro reads this file with a driver it downloads from the plugin registry."), - confirmButton: String(localized: "Install") - ) - guard confirmed else { return false } - - do { - try await PluginInstallProgressPresenter.run( - title: String(format: String(localized: "Downloading the %@ plugin…"), displayName) - ) { progress in - try await PluginManager.shared.installMissingPlugin(for: type, progress: progress) - } - logger.info("Installed \(type.rawValue, privacy: .public) to open a file") - return true - } catch { - logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.publicLogShape, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Plugin Installation Failed"), - message: error.localizedDescription, - window: nil - ) - return false - } - } -} diff --git a/TablePro/Core/Plugins/MissingExportFormatPrompt.swift b/TablePro/Core/Plugins/MissingExportFormatPrompt.swift deleted file mode 100644 index 4ddb2da3f0..0000000000 --- a/TablePro/Core/Plugins/MissingExportFormatPrompt.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// MissingExportFormatPrompt.swift -// TablePro -// - -import AppKit -import os - -@MainActor -internal enum MissingExportFormatPrompt { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingExportFormatPrompt") - - internal static func ensureInstalled(_ format: InstallableExportFormat, window: NSWindow?) async -> Bool { - let confirmed = await AlertHelper.confirm( - title: String(format: String(localized: "Install the %@ plugin?"), format.displayName), - message: String(localized: "TablePro writes this format with a plugin it downloads from the plugin registry."), - confirmButton: String(localized: "Install"), - window: window - ) - guard confirmed else { return false } - - do { - try await PluginInstallProgressPresenter.run( - title: String(format: String(localized: "Downloading the %@ plugin…"), format.displayName), - window: window - ) { progress in - try await PluginManager.shared.installMissingExportFormat(format, progress: progress) - } - logger.info("Installed the \(format.formatId, privacy: .public) export format") - return true - } catch { - logger.error( - "Install failed for \(format.formatId, privacy: .public): \(error.publicLogShape, privacy: .public)" - ) - AlertHelper.showErrorSheet( - title: String(localized: "Plugin Installation Failed"), - message: error.localizedDescription, - window: window - ) - return false - } - } -} diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index 75be6dd9f7..3c83f356d5 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -60,7 +60,7 @@ enum PluginError: LocalizedError { case .downloadFailed(let reason): return String(format: String(localized: "Plugin download failed: %@"), reason) case .pluginNotInstalled(let databaseType): - return String(format: String(localized: "The %@ plugin is not installed. You can download it from the plugin marketplace."), databaseType) + return String(format: String(localized: "The %@ plugin is not installed."), databaseType) case .pluginUpdateUnavailable(let reason): return reason case .incompatibleWithCurrentApp(let minimumRequired): diff --git a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift index 100cb512bb..4b45a1dee0 100644 --- a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift +++ b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift @@ -1,21 +1,6 @@ -// -// PluginInstallProgressPresenter.swift -// TablePro -// - import AppKit -/// The progress of a plugin download the user is waiting on, shown while it runs. -/// -/// Every caller of `installMissingPlugin` used to discard the fraction it publishes, so pressing -/// Install closed the alert and left the app looking hung for the length of a network download. -/// The HIG asks for progress on anything past a couple of seconds, and a registry ZIP is reliably -/// past it. -/// -/// A panel rather than an `NSAlert`, for two reasons. `AlertHelper` runs an alert application-modal -/// when no window qualifies, which is exactly the Finder-open case this exists for, and a modal run -/// loop cannot be updated from the `await` that is driving it. And an alert with no button is not -/// what `NSAlert` is for: this is a progress report, not a question. +// A panel, not an NSAlert: with no window an alert runs app-modal, and the await driving it cannot update it. @MainActor internal final class PluginInstallProgressPresenter { private var panel: NSPanel? @@ -23,12 +8,12 @@ internal final class PluginInstallProgressPresenter { private let indicator = NSProgressIndicator() private let label = NSTextField(labelWithString: "") - internal init() {} + private init() {} // Ended before the caller presents a failure, since a second sheet on the window would queue. internal static func run( title: String, - window: NSWindow? = nil, + window: NSWindow?, _ install: (_ progress: @escaping @MainActor @Sendable (Double) -> Void) async throws -> Void ) async throws { let presenter = PluginInstallProgressPresenter() @@ -37,9 +22,7 @@ internal final class PluginInstallProgressPresenter { try await install { fraction in presenter.update(fraction: fraction) } } - /// Presented as a sheet on the window the user was working in, and as a free-standing panel - /// when there is none, which is how a Finder open before any window exists reaches the screen. - internal func begin(title: String, window: NSWindow? = nil) { + private func begin(title: String, window: NSWindow?) { guard panel == nil else { return } label.stringValue = title let panel = makePanel() @@ -54,13 +37,8 @@ internal final class PluginInstallProgressPresenter { parent.beginSheet(panel) } - /// The first fraction that has actually moved is what turns the bar determinate. A zero is - /// published before the first byte arrives, and a server that sends no `Content-Length` - /// publishes nothing after it until the whole file is down, so adopting it would park a - /// determinate bar at 0% for the entire download: the wait this panel exists to explain. - /// Stopping the animation goes with the switch, or its timer runs on under a bar that is no - /// longer using it. - internal func update(fraction: Double) { + // The installer publishes 0 before the first byte, then maybe nothing until the end, so 0 stays indeterminate. + private func update(fraction: Double) { let clamped = min(max(fraction, 0), 1) guard clamped > 0 else { return } if indicator.isIndeterminate { @@ -70,7 +48,7 @@ internal final class PluginInstallProgressPresenter { indicator.doubleValue = clamped * 100 } - internal func end() { + private func end() { indicator.stopAnimation(nil) guard let panel else { return } self.panel = nil @@ -89,8 +67,6 @@ internal final class PluginInstallProgressPresenter { label.font = .preferredFont(forTextStyle: .body) label.lineBreakMode = .byTruncatingMiddle - /// Starts indeterminate because the first byte has not arrived yet, and a bar sitting at - /// zero reads as stalled rather than as starting. indicator.translatesAutoresizingMaskIntoConstraints = false indicator.style = .bar indicator.isIndeterminate = true diff --git a/TablePro/Core/Plugins/PluginInstallPrompt.swift b/TablePro/Core/Plugins/PluginInstallPrompt.swift new file mode 100644 index 0000000000..d4a3d5e074 --- /dev/null +++ b/TablePro/Core/Plugins/PluginInstallPrompt.swift @@ -0,0 +1,81 @@ +import AppKit +import os + +@MainActor +internal enum PluginInstallPrompt { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstallPrompt") + + internal typealias Install = (_ progress: @escaping @MainActor @Sendable (Double) -> Void) async throws -> Void + + internal static func ensureInstalled(_ format: InstallableExportFormat, window: NSWindow?) async -> Bool { + await confirmAndRun( + pluginName: format.displayName, + title: String(format: String(localized: "Install the %@ plugin?"), format.displayName), + message: String(localized: "TablePro writes this format with a plugin it downloads from the plugin registry."), + window: window + ) { progress in + try await PluginManager.shared.installMissingExportFormat(format, progress: progress) + } + } + + internal static func ensureInstalled(for type: DatabaseType, opening url: URL) async -> Bool { + // A Finder open is routed before eager-path plugins register, so asking first offers one the user has. + if !PluginManager.shared.isDriverInstalled(for: type) { + await PluginManager.shared.waitForInitialLoad() + } + guard !PluginManager.shared.isDriverInstalled(for: type) else { return true } + // A bundled driver that did not load is disabled or damaged; the connect attempt reports which. + guard type.isDownloadablePlugin else { return true } + + let displayName = PluginMetadataRegistry.shared.snapshot(for: type)?.displayName ?? type.rawValue + return await confirmAndRun( + pluginName: displayName, + title: String( + format: String(localized: "Install the %@ plugin to open “%@”?"), + displayName, + url.lastPathComponent + ), + message: String(localized: "TablePro reads this file with a driver it downloads from the plugin registry."), + window: nil + ) { progress in + try await PluginManager.shared.installMissingPlugin(for: type, progress: progress) + } + } + + internal static func run(pluginName: String, window: NSWindow?, _ install: Install) async -> Bool { + do { + try await PluginInstallProgressPresenter.run( + title: String(format: String(localized: "Downloading the %@ plugin…"), pluginName), + window: window, + install + ) + logger.info("Installed the \(pluginName, privacy: .public) plugin") + return true + } catch { + logger.error("Install failed for \(pluginName, privacy: .public): \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: window + ) + return false + } + } + + private static func confirmAndRun( + pluginName: String, + title: String, + message: String, + window: NSWindow?, + install: Install + ) async -> Bool { + let confirmed = await AlertHelper.confirm( + title: title, + message: message, + confirmButton: String(localized: "Install"), + window: window + ) + guard confirmed else { return false } + return await run(pluginName: pluginName, window: window, install) + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 1271f38848..9227f03dc5 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -1,8 +1,3 @@ -// -// TabRouter.swift -// TablePro -// - import AppKit import Foundation import os @@ -452,7 +447,7 @@ internal final class TabRouter { return } - guard await MissingDriverPluginPrompt.ensureInstalled(for: type, opening: url) else { + guard await PluginInstallPrompt.ensureInstalled(for: type, opening: url) else { throw TabRouterError.userCancelled } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 5694038390..6fc653e63e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -179774,6 +179774,9 @@ } } } + }, + "The %@ plugin is not installed." : { + }, "The %@ plugin is not installed. TablePro offers to install it on connect." : { "extractionState" : "manual", @@ -179788,6 +179791,7 @@ } }, "The %@ plugin is not installed. Would you like to download it from the plugin marketplace?" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -179826,8 +179830,12 @@ } } } + }, + "The %@ plugin is not installed. Would you like to install it?" : { + }, "The %@ plugin is not installed. You can download it from the plugin marketplace." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { diff --git a/TablePro/Views/Connection/PluginInstallModifier.swift b/TablePro/Views/Connection/PluginInstallModifier.swift index 14a648fe21..fcfbac67ae 100644 --- a/TablePro/Views/Connection/PluginInstallModifier.swift +++ b/TablePro/Views/Connection/PluginInstallModifier.swift @@ -1,157 +1,51 @@ -// -// PluginInstallModifier.swift -// TablePro -// - -import os import SwiftUI -struct PluginInstallModifier: ViewModifier { - private static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstallModifier") - - @Binding var connection: DatabaseConnection? - @State private var installFailed: String? - var onInstalled: (DatabaseConnection) -> Void +struct PluginInstallModifier: ViewModifier { + @Binding var item: Item? + let databaseType: KeyPath + let onInstalled: (Item) -> Void func body(content: Content) -> some View { content .alert( String(localized: "Plugin Not Installed"), isPresented: Binding( - get: { connection != nil }, - set: { if !$0 { connection = nil } } - ) - ) { + get: { item != nil }, + set: { if !$0 { item = nil } } + ), + presenting: item + ) { pending in Button(String(localized: "Install")) { - if let conn = connection { - connection = nil - install(conn) - } - } - Button(String(localized: "Cancel"), role: .cancel) { - connection = nil - } - } message: { - if let conn = connection { - Text(String(format: String(localized: "The %@ plugin is not installed. Would you like to download it from the plugin marketplace?"), conn.type.displayName)) - } - } - .alert( - String(localized: "Plugin Installation Failed"), - isPresented: Binding( - get: { installFailed != nil }, - set: { if !$0 { installFailed = nil } } - ) - ) { - Button("OK", role: .cancel) { - installFailed = nil - } - } message: { - if let message = installFailed { - Text(message) - } + install(pending) + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { pending in + Text(String( + format: String(localized: "The %@ plugin is not installed. Would you like to install it?"), + pending[keyPath: databaseType].displayName + )) } } - private func install(_ conn: DatabaseConnection) { + private func install(_ pending: Item) { + let type = pending[keyPath: databaseType] Task { - let presenter = PluginInstallProgressPresenter() - presenter.begin( - title: String(format: String(localized: "Downloading the %@ plugin…"), conn.type.displayName) - ) - do { - try await PluginManager.shared.installMissingPlugin(for: conn.type) { fraction in - presenter.update(fraction: fraction) - } - presenter.end() - Self.logger.info("Installed plugin for \(conn.type.rawValue), retrying connection") - onInstalled(conn) - } catch { - presenter.end() - installFailed = error.localizedDescription + let isInstalled = await PluginInstallPrompt.run(pluginName: type.displayName, window: nil) { progress in + try await PluginManager.shared.installMissingPlugin(for: type, progress: progress) + } + if isInstalled { + onInstalled(pending) } } } } extension View { - func pluginInstallPrompt( - connection: Binding, - onInstalled: @escaping (DatabaseConnection) -> Void + func pluginInstallPrompt( + for item: Binding, + type databaseType: KeyPath, + onInstalled: @escaping (Item) -> Void ) -> some View { - modifier(PluginInstallModifier(connection: connection, onInstalled: onInstalled)) - } - - func pluginInstallPromptForType( - type: Binding, - onInstalled: @escaping (DatabaseType) -> Void - ) -> some View { - modifier(PluginInstallTypeModifier(type: type, onInstalled: onInstalled)) - } -} - -struct PluginInstallTypeModifier: ViewModifier { - private static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstallTypeModifier") - - @Binding var type: DatabaseType? - @State private var installFailed: String? - var onInstalled: (DatabaseType) -> Void - - func body(content: Content) -> some View { - content - .alert( - String(localized: "Plugin Not Installed"), - isPresented: Binding( - get: { type != nil }, - set: { if !$0 { type = nil } } - ) - ) { - Button(String(localized: "Install")) { - if let t = type { - type = nil - install(t) - } - } - Button(String(localized: "Cancel"), role: .cancel) { - type = nil - } - } message: { - if let t = type { - Text(String(format: String(localized: "The %@ plugin is not installed. Would you like to download it from the plugin marketplace?"), t.displayName)) - } - } - .alert( - String(localized: "Plugin Installation Failed"), - isPresented: Binding( - get: { installFailed != nil }, - set: { if !$0 { installFailed = nil } } - ) - ) { - Button("OK", role: .cancel) { - installFailed = nil - } - } message: { - if let message = installFailed { - Text(message) - } - } - } - - private func install(_ t: DatabaseType) { - Task { - let presenter = PluginInstallProgressPresenter() - presenter.begin(title: String(format: String(localized: "Downloading the %@ plugin…"), t.displayName)) - do { - try await PluginManager.shared.installMissingPlugin(for: t) { fraction in - presenter.update(fraction: fraction) - } - presenter.end() - Self.logger.info("Installed plugin for \(t.rawValue), opening connection form") - onInstalled(t) - } catch { - presenter.end() - installFailed = error.localizedDescription - } - } + modifier(PluginInstallModifier(item: item, databaseType: databaseType, onInstalled: onInstalled)) } } diff --git a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift index bf2793848b..4804e3559c 100644 --- a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift +++ b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift @@ -1,8 +1,3 @@ -// -// PluginInstallStatusRow.swift -// TablePro -// - import SwiftUI struct PluginInstallStatusRow: View { @@ -39,15 +34,7 @@ struct PluginInstallStatusRow: View { } } - /// An indeterminate spinner says nothing about a download that can take a while. The tracker - /// already publishes a fraction, so the bar shows it, and staging is called out explicitly so - /// a full bar never reads as ready. - /// - /// A fraction of zero is the exception, because it is not nought per cent done: the installer - /// publishes it before the transfer starts and publishes nothing again until the whole file is - /// down. Measured, `URLSession`'s async `download` delivers no byte callbacks to a per-task - /// delegate, so there is no finer answer to draw. A determinate bar parked at zero for the - /// length of the download is the exact impression this row exists to avoid. + // A zero fraction means not started; see PluginInstallProgressPresenter.update(fraction:). @ViewBuilder private var installProgress: some View { switch tracker.state(forDatabaseType: coordinator.network.type)?.phase { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift b/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift index fd468e0ff8..e60d5c4731 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift @@ -1,8 +1,3 @@ -// -// ConnectionFormDetailView.swift -// TablePro -// - import SwiftUI /// The split view's detail column: the selected section over the bar that commits the window. @@ -36,7 +31,7 @@ struct ConnectionFormDetailView: View { coordinator.pluginDiagnostic = nil } } - .pluginInstallPrompt(connection: $coordinator.pluginInstallConnection) { _ in + .pluginInstallPrompt(for: $coordinator.pluginInstallConnection, type: \.type) { _ in coordinator.test() } .alert( diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 372bedfc8e..98d51fe17c 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -1,8 +1,3 @@ -// -// ExportDialog.swift -// TablePro -// - import AppKit import os import SwiftUI @@ -247,7 +242,7 @@ struct ExportDialog: View { @MainActor private func installOfferedFormat(_ format: InstallableExportFormat) async { - guard await MissingExportFormatPrompt.ensureInstalled(format, window: hostWindow), + guard await PluginInstallPrompt.ensureInstalled(format, window: hostWindow), let plugin = pluginManager.exportPlugin(forFormat: format.formatId) else { return } if let settable = plugin as? any SettablePluginDiscoverable { diff --git a/TablePro/Views/Welcome/WelcomePresentations.swift b/TablePro/Views/Welcome/WelcomePresentations.swift index 03fb4071dc..f6cf3921ee 100644 --- a/TablePro/Views/Welcome/WelcomePresentations.swift +++ b/TablePro/Views/Welcome/WelcomePresentations.swift @@ -1,8 +1,3 @@ -// -// WelcomePresentations.swift -// TablePro -// - import SwiftUI import TableProImport import UniformTypeIdentifiers @@ -31,10 +26,10 @@ internal struct WelcomePresentations: ViewModifier { WelcomeSheetView { vm.presentsWelcomeSheet = false } } .modifier(WelcomeConnectionCreationOverlays(vm: vm)) - .pluginInstallPromptForType(type: $vm.pendingInstallType) { type in + .pluginInstallPrompt(for: $vm.pendingInstallType, type: \.self) { type in vm.completePendingInstall(for: type) } - .pluginInstallPrompt(connection: $vm.pluginInstallConnection) { connection in + .pluginInstallPrompt(for: $vm.pluginInstallConnection, type: \.type) { connection in vm.connectAfterInstall(connection) } .sheet(item: $vm.pluginDiagnostic) { item in