diff --git a/mac/Config/Config.xcodeproj/project.pbxproj b/mac/Config/Config.xcodeproj/project.pbxproj index 3b8bb555a05..d4a05511f9f 100644 --- a/mac/Config/Config.xcodeproj/project.pbxproj +++ b/mac/Config/Config.xcodeproj/project.pbxproj @@ -33,23 +33,6 @@ D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 375D21122FF2F21800FCD24A /* Exceptions for "Config" folder in "Config" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - ConfigTests/ConfigTests.swift, - ); - target = D88F03C52F50ED5000C02A31 /* Config */; - }; - 375D21132FF2F21800FCD24A /* Exceptions for "Config" folder in "ConfigTests" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - ConfigTests/ConfigTests.swift, - ); - target = D88F03D22F50ED5100C02A31 /* ConfigTests */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - /* Begin PBXFileSystemSynchronizedRootGroup section */ D87D6F492FAF95400083A95E /* Installation */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -58,10 +41,6 @@ }; D88F03C82F50ED5000C02A31 /* Config */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 375D21122FF2F21800FCD24A /* Exceptions for "Config" folder in "Config" target */, - 375D21132FF2F21800FCD24A /* Exceptions for "Config" folder in "ConfigTests" target */, - ); path = Config; sourceTree = ""; }; diff --git a/mac/Config/Config/AddKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift new file mode 100644 index 00000000000..0069cf91596 --- /dev/null +++ b/mac/Config/Config/AddKeyboardView.swift @@ -0,0 +1,100 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * Contains webview to search for keyboards and injects + * DownloadCoordinator to bridge back to SwiftUI + */ + +import SwiftUI +import KeymanSettings + +struct VisualEffectBlur: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .hudWindow // Matches native dark/light HUD styling + view.blendingMode = .withinWindow + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +struct AddKeyboardView: View { + @EnvironmentObject var settings: SettingsContainer + @Environment(\.dismiss) private var dismissAddKeyboardView + @StateObject private var downloadCoordinator = DownloadCoordinator() + + var body: some View { + ZStack { + KeyboardSearchView(coordinator: downloadCoordinator) + .environmentObject(settings) + .padding() + + if downloadCoordinator.isDownloading { + // Dim the background slightly to focus on the progress panel + Color.black.opacity(0.2) + .transition(.opacity) + + VStack(spacing: 16) { + Text("Downloading File...") + .font(.headline) + + // Native progress bar bound to the coordinator's value (0.0 to 1.0) + ProgressView(value: downloadCoordinator.downloadProgress, total: 1.0) + .progressViewStyle(.linear) + .frame(width: 250) + + Text("\(Int(downloadCoordinator.downloadProgress * 100))%") + .font(.body) + .foregroundColor(.secondary) + } + .padding(24) + // Gives it a beautiful native translucent macOS look + .background(VisualEffectBlur()) + .cornerRadius(12) + .shadow(radius: 10) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.default, value: downloadCoordinator.isDownloading) + .toolbar { + // Placement determines where on the bar it sits + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismissAddKeyboardView() + } + } + } + .alert("Package Installation Failed", isPresented: $downloadCoordinator.loadPackageFailed) { + Button("OK", role: .cancel) { } + } message: { + if let message = downloadCoordinator.loadFailureMessage { + Text(message) + } + } + .sheet(isPresented: $downloadCoordinator.showConfirmPackageSheet) { + if let helper = downloadCoordinator.installHelper { + PackageConfirmationView(installHelper: helper) { accepted in + if accepted { + print("installing validated package: \(helper.packageName ?? "unknown package")") + do { + try settings.installPackage() + } catch { + print("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error.localizedDescription)") + } + } else { + settings.userCanceledPackageInstallation() + } + + // close sheet + downloadCoordinator.showConfirmPackageSheet = false + // close + dismissAddKeyboardView() + } + } + } + } +} diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index 49f8d2c6493..dd6a872dd6e 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -18,6 +18,10 @@ struct ConfigApp: App { var body: some Scene { Window("Configuration", id: "main-config") { MainConfigView() + .frame( + minWidth: 600, maxWidth: 800, + minHeight: 400, maxHeight: .infinity + ) .environmentObject(settings) .task { if !installation.getHasDisplayedInstallationComplete() { @@ -27,16 +31,16 @@ struct ConfigApp: App { .onReceive(NotificationCenter.default.publisher(for: .installationRepairStarted)) { notification in openWindow(id: "install") } } + // the size of the window when first opened + // .defaultSize(width: 1024, height: 768) + .defaultSize(width: 800, height: 600) + .windowResizability(.contentSize) Window("Installation", id: "install") { MainInstallView() .environmentObject(installation) } .windowResizability(.contentSize) .defaultSize(width: 600, height: 500) - Window("Config Test", id: "config-debug") { - ConfigDebugView() - .environmentObject(settings) - } Window("Install Test", id: "install-debug") { InstallDebugView() .environmentObject(installation) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift deleted file mode 100644 index c8d735f261a..00000000000 --- a/mac/Config/Config/ConfigDebugView.swift +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Keyman is copyright (C) SIL Global. MIT License. - * - * Created by Shawn Schantz on 2026-02-26 - * - * View for debugging Keyman configuration - */ - -import SwiftUI -import KeymanSettings - -struct ConfigDebugView: View { - @EnvironmentObject var settings: SettingsContainer - @State private var isShowingSheet = false - - var body: some View { - VStack { - HStack { - Image(systemName: "keyboard") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("multiple keyboard package count = \(settings.multiKeyboardPackages.count)") - Text("single keyboard package count = \(settings.singleKeyboardPackages.count)") - Button("log defaults") { - settings.logUserDefaults() - } - Button("clear defaults") { - settings.clearUserDefaults() - } - Button("install keyboard") { - isShowingSheet = true - } - Spacer() - } - .padding() - .frame(width: 700, height: 100) - // Binds the visibility state to the sheet builder - .sheet(isPresented: $isShowingSheet) { - InstallKeyboardView() - .presentationDetents([.medium, .large]) - .frame(width: 700, height: 500) - } - - - ScrollView { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(settings.singleKeyboardPackages.enumerated()), id: \.offset) { index, package in - VStack { - HStack(alignment: .center, spacing: 10) { - Text(package.packageName) - .font(.headline) - Text(package.packageVersion) - .font(.subheadline) - // Example of Icon-Only Button - Spacer() - if let nsImage = package.graphicImage { - Image(nsImage: nsImage) - .resizable() // Allows resizing - .scaledToFit() // Maintains original aspect ratio - .frame(maxWidth: 140, maxHeight: 250) // Controls the bounds - } - Button(action: { - settings.removeInstalledPackage(with: package.id) - }) { - Label("remove", systemImage: "trash.fill") - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } - KeyboardListDebugView(packageId: package.id, keyboards: package.keyboards) - } - } - } - .padding(.trailing, 25) // allow space for scroll bar - } - } - .padding() - } -} - -#Preview { - let settings = SettingsContainer() - ConfigDebugView() - .environmentObject(settings) -} diff --git a/mac/Config/Config/ConfigTests/ConfigTests.swift b/mac/Config/Config/ConfigTests/ConfigTests.swift deleted file mode 100644 index 4150236bbc6..00000000000 --- a/mac/Config/Config/ConfigTests/ConfigTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Keyman is copyright (C) SIL Global. MIT License. - * - * Created by Shawn Schantz on 2026-02-26 - * - * Tests for Config app - * - */ - -import Testing - -struct ConfigTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift new file mode 100644 index 00000000000..285a5b837d1 --- /dev/null +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -0,0 +1,198 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-08-21 + * + * For coordination between WKWebview and SwiftUI views. + * Implements WKNavigationDelegate and WKDownloadDelegate to trigger downloads + * of Keyman packages and publishes several fields to allow SwiftUI views to + * - display download progress + * - display errors that cause the download or package validation to fail + * - prompt with a confirm sheet including a package read me and button to install + */ + +import WebKit +import Combine +import KeymanSettings + +// safe to designate the whole Coordinator class as @MainActor with Swift 6.0 +// when delegate calls come on a background thread, Swift 6 will +// intercept and switch to the main thread for calls to our code + +@MainActor +public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate { + @Published var isDownloading = false + // progress is between 0.0 and 1.0 + @Published var downloadProgress: Double = 0.0 + @Published var showConfirmPackageSheet = false + @Published var installHelper: PackageInstallHelper? + @Published var loadFailureMessage: String? + @Published var loadPackageFailed = false + + var downloadFileUrl: URL? + var settings: SettingsContainer? + private var progressObserver: NSKeyValueObservation? + + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + preferences: WKWebpagePreferences, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { + + print("deciding navigation based on action") + + if let url = navigationAction.request.url { + print("webView navigationAction.request.url: \(url)") + } + + // Trust HTML download attribute if present + if navigationAction.shouldPerformDownload { + print("webView called decisionHandler for download") + decisionHandler(.download, preferences) + return + } + + // MAC-CONFIG-TODO: is this necessary or is download attribute enough to identify + // check if URL ends with a target file extension + if let url = navigationAction.request.url { + if url.pathExtension.lowercased() == KeymanPaths.keymanPackageFileExtension { + decisionHandler(.download, preferences) + print("webView found .kmp, called decisionHandler for download") + return + } + } + + decisionHandler(.allow, preferences) + } + + /** decide whether the navigation should be allowed, canceled or result in a download */ + public func webView(_ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void) { + print("deciding navigation based on response") + + if navigationResponse.canShowMIMEType { + decisionHandler(.allow) + } else { + guard let keymanSettings = self.settings else { + print("webView decidePolicyFor:decisionHandler: no settings") + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.internalError.localizedDescription + decisionHandler(.cancel) + return + } + + // if a download is already in progress then stop another from starting + if keymanSettings.isDownloadInProgress() { + print("download already in progress, download canceled") + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.downloadInProgress.localizedDescription + decisionHandler(.cancel) + } else { + decisionHandler(.download) + } + } + } + + public func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + print("📍 didBecome called via navigationAction") + download.delegate = self // Assign delegate for file saving + + setupDownloadTracking(download) + } + + public func webView(_ webView: WKWebView, + navigationResponse: WKNavigationResponse, + didBecome download: WKDownload) { + print("📍 didBecome called via navigationResponse") + download.delegate = self + + setupDownloadTracking(download) + } + + // Common setup function to attach the delegate and the KVO progress observer + private func setupDownloadTracking(_ download: WKDownload) { + download.delegate = self + + // reset progress states + self.isDownloading = true + self.downloadProgress = 0.0 + + progressObserver = download.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] _, change in + guard let newValue = change.newValue else { return } + + Task { @MainActor [weak self] in + self?.downloadProgress = newValue + print("Download Progress: \(Int(newValue * 100))%") + } + } + } + + public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) { + print("download initiated") + + guard let keymanSettings = self.settings else { + print("tried to access settings before they were intialized in updateNSView") + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.internalError.localizedDescription + completionHandler(nil) + return + } + + // notify settings that a keyboard download is beginning and get the + // helper that is managing state for the package installation + + do { + if let helper = try keymanSettings.initiateKmpFileDownload(kmpFilename: suggestedFilename) { + + self.loadFailureMessage = nil // Reset previous error + self.loadPackageFailed = false + + self.installHelper = helper + + completionHandler(helper.temporaryKmpFileLocation) + } + } catch { + print("Could not initiate package download, error: \(error)") + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + completionHandler(nil) + } + } + + public func downloadDidFinish(_ download: WKDownload) { + self.isDownloading = false + self.progressObserver = nil + + if let downloadDestination = installHelper?.temporaryKmpFileLocation { + print("Download of \(downloadDestination.path()) was successful.") + if let settings { + do { + try settings.packageDownloadComplete(kmpFileUrl: downloadDestination) + // Trigger the SwiftUI modal sheet + self.showConfirmPackageSheet = true + } catch { + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + } + } + } + } + + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + print("Download failed with error: \(error.localizedDescription)") + self.isDownloading = false + self.progressObserver = nil + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + self.installHelper = nil + if let settings { + settings.packageInstallationFailed() + } + } + + public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + // The web process crashed. Reload the webview safely here. + print("WebKit process terminated unexpectedly: reloading content...") + webView.reload() + } +} diff --git a/mac/Config/Config/HelpView.swift b/mac/Config/Config/HelpView.swift deleted file mode 100644 index d4f603364e3..00000000000 --- a/mac/Config/Config/HelpView.swift +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Keyman is copyright (C) SIL Global. MIT License. - * - * Created by Gabriel Schantz on 2026-08-03 - * - * Webview used to show help for Keyman keyboards - */ -import Foundation - -import SwiftUI -import WebKit -import KeymanSettings - -public struct HelpView: NSViewRepresentable { - let helpFileURL: URL - - // create the AppKit view instance - public func makeNSView(context: Context) -> WKWebView { - let webView = WKWebView() - return webView - } - - // update the view when SwiftUI state changes - public func updateNSView(_ nsView: WKWebView, context: Context) { - let request = URLRequest(url: helpFileURL) - - // only load the request if it's not already loading/loaded to prevent infinite loops - if nsView.url != helpFileURL { - if let helpUrl = request.url { - nsView.loadFileURL(helpUrl, allowingReadAccessTo: helpUrl.deletingLastPathComponent()) - } - } - } -} diff --git a/mac/Config/Config/InstallKeyboardView.swift b/mac/Config/Config/InstallKeyboardView.swift deleted file mode 100644 index 916cac7b121..00000000000 --- a/mac/Config/Config/InstallKeyboardView.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI -import KeymanSettings - -struct InstallKeyboardView: View { - @EnvironmentObject var settings: SettingsContainer - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack { - KeyboardSearchView() - .environmentObject(settings) - .padding() - } - .toolbar { - // Placement determines where on the bar it sits - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - dismiss() - } - } - } - } -} diff --git a/mac/Config/Config/InstallationViews/InitialInstallView.swift b/mac/Config/Config/InstallationViews/InitialInstallView.swift index 69bbc12d36a..0c391c26997 100644 --- a/mac/Config/Config/InstallationViews/InitialInstallView.swift +++ b/mac/Config/Config/InstallationViews/InitialInstallView.swift @@ -44,7 +44,7 @@ struct InitialInstallView: View { HStack { Text("Proceed to continue with installation") .font(.title2) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) NavigationButton(action: .advance, onContinue: onContinue) } diff --git a/mac/Config/Config/InstallationViews/InitialRepairView.swift b/mac/Config/Config/InstallationViews/InitialRepairView.swift index 745dabd1a2a..2c61521c2db 100644 --- a/mac/Config/Config/InstallationViews/InitialRepairView.swift +++ b/mac/Config/Config/InstallationViews/InitialRepairView.swift @@ -17,7 +17,8 @@ struct InitialRepairView: View { var body: some View { VStack { - Label("Repairs Required", systemImage: "hand.raised.fill") +// Label("Repairs Required", systemImage: "hand.raised.fill") + Text("Repairs Required") .font(.title) .bold() .frame(maxWidth: .infinity, alignment: .center) @@ -27,13 +28,18 @@ struct InitialRepairView: View { Form { HStack { Spacer() - Image(systemName: "hammer.circle.fill") + Image(systemName: "wrench.and.screwdriver.fill") .font(.system(size: 100)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color("Keyman Blue"), // first color for the wrench + Color("Keyman Orange") // second color for the screwdriver + ) .padding(.bottom, 16) Spacer() } Text("One or more Keyman components or permissions require your attention. Complete the following steps to restore your Keyman installation.") - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) } .formStyle(.grouped) .padding(.top, 50) diff --git a/mac/Config/Config/InstallationViews/RerunInstallerView.swift b/mac/Config/Config/InstallationViews/RerunInstallerView.swift index 9cc1872cecb..e826616da39 100644 --- a/mac/Config/Config/InstallationViews/RerunInstallerView.swift +++ b/mac/Config/Config/InstallationViews/RerunInstallerView.swift @@ -17,6 +17,7 @@ struct RerunInstallerView: View { var body: some View { VStack { +// Label("Missing Keyman Components", systemImage: "hand.raised.fill") Text("Missing Keyman Components") .font(.title) .bold() @@ -27,13 +28,18 @@ struct RerunInstallerView: View { Form { HStack { Spacer() - Image(systemName: "wrench.and.screwdriver.fill") - .font(.system(size: 100)) - .padding(.bottom, 16) + Image(systemName: "wrench.and.screwdriver.fill") + .font(.system(size: 100)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color("Keyman Blue"), // first color for the wrench + Color("Keyman Orange") // second color for the screwdriver + ) + .padding(.bottom, 16) Spacer() } Text("Your Keyman input method is either missing or outdated. Run the Keyman installer to install a new version.") - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .foregroundStyle(.secondary) } .formStyle(.grouped) diff --git a/mac/Config/Config/InstallationViews/RestartComputerView.swift b/mac/Config/Config/InstallationViews/RestartComputerView.swift index c5cab61c28e..493b698a94f 100644 --- a/mac/Config/Config/InstallationViews/RestartComputerView.swift +++ b/mac/Config/Config/InstallationViews/RestartComputerView.swift @@ -26,7 +26,7 @@ struct RestartComputerView: View { .font(.system(size: 100)) .padding(16) Text("Restart your Mac to complete the installation. After restarting, open Keyman Configuration again if it doesn't launch automatically.") - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .padding(.bottom, 8) Spacer() diff --git a/mac/Config/Config/KeyboardListDebugView.swift b/mac/Config/Config/KeyboardListDebugView.swift deleted file mode 100644 index 50c357ff66d..00000000000 --- a/mac/Config/Config/KeyboardListDebugView.swift +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Keyman is copyright (C) SIL Global. MIT License. - * - * Created by Shawn Schantz on 2026-04-02 - * - * Subview to display list of keyboards for a package - */ - -import SwiftUI -import KeymanSettings -import Combine - -struct KeyboardListDebugView: View { - @EnvironmentObject var settings: SettingsContainer - @State var packageId: UUID - @State var keyboards: [Keyboard] - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach($keyboards) { $keyboard in - HStack { - Toggle("", isOn: Binding( - get: { settings.isKeyboardEnabled(packageId: packageId, keyboardKey: keyboard.keyboardKey) }, - set: { newValue in settings.setKeyboardEnabled(packageId: packageId, keyboardKey: keyboard.keyboardKey, enabled: newValue) - settings.objectWillChange.send() } - )) - Text(keyboard.keyboardId) - .padding(.leading, 5) - Spacer() - } - } - } - } -} diff --git a/mac/Config/Config/KeyboardSearchView.swift b/mac/Config/Config/KeyboardSearchView.swift index a9ba9283d00..64b617cd1d0 100644 --- a/mac/Config/Config/KeyboardSearchView.swift +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -3,16 +3,18 @@ * * Created by Shawn Schantz on 2026-06-16 * - * Webview to search for Keyman keyboards + * Webview to search for Keyman keyboards/packages */ import Foundation import SwiftUI +import Combine import WebKit import KeymanSettings struct KeyboardSearchView: NSViewRepresentable { + @ObservedObject var coordinator: DownloadCoordinator @EnvironmentObject var settings: SettingsContainer // note that the EnvironmentObject is not available within init (if we were to implement that) @@ -21,18 +23,13 @@ struct KeyboardSearchView: NSViewRepresentable { // MAC-CONFIG-TODO: build URL rather than hard-code let searchURL = URL(string: "https://keyman.com/go/macos/14.0/download-keyboards/?version=19.0.284")! - /** Creates the Coordinator to handle WebKit delegate methods */ - func makeCoordinator() -> Coordinator { - Coordinator() - } - /** Creates the underlying NSView (WKWebView) for macOS */ func makeNSView(context: Context) -> WKWebView { print("makeNSView called") let webView = WKWebView() // assign the coordinator as the navigation delegate - webView.navigationDelegate = context.coordinator + webView.navigationDelegate = self.coordinator let request = URLRequest(url: searchURL) webView.load(request) @@ -45,126 +42,11 @@ struct KeyboardSearchView: NSViewRepresentable { * as the environment has been loaded by now. */ func updateNSView(_ nsView: WKWebView, context: Context) { - if context.coordinator.settings == nil { - context.coordinator.settings = self.settings + if coordinator.settings == nil { + coordinator.settings = self.settings print("updateNSView, settings intialized for coordinator") } } - - class Coordinator: NSObject, WKNavigationDelegate, WKDownloadDelegate { - var downloadFileUrl: URL? = nil - var settings: SettingsContainer? - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - preferences: WKWebpagePreferences, - decisionHandler: @escaping @MainActor (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { - - print("deciding navigation based on action") - - if let url = navigationAction.request.url { - print("webView navigationAction.request.url: \(url)") - } - - // Trust HTML download attribute if present - if navigationAction.shouldPerformDownload { - print("webView called decisionHandler for download") - decisionHandler(.download, preferences) - return - } - - // MAC-CONFIG-TODO: is this necessary or is download attribute enough to identify - // check if URL ends with a target file extension - if let url = navigationAction.request.url { - if url.pathExtension.lowercased() == KeymanPaths.keymanPackageFileExtension { - decisionHandler(.download, preferences) - print("webView found .kmp, called decisionHandler for download") - return - } - } - - decisionHandler(.allow, preferences) - } - - /** decide whether the navigation should be allowed, canceled or result in a download */ - func webView(_ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void) { - print("deciding navigation based on response") - - if navigationResponse.canShowMIMEType { - decisionHandler(.allow) - } else { - guard let keymanSettings = self.settings else { - print("webView decidePolicyFor:decisionHandler: no settings") - decisionHandler(.cancel) - return - } - - // if a download is already in progress then stop another from starting - if keymanSettings.isDownloadInProgress() { - print("download already in progress, download canceled") - decisionHandler(.cancel) - } else { - decisionHandler(.download) - } - } - } - - func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { - print("webView navigationAction:didBecome called") - download.delegate = self // Assign delegate for file saving - } - - func webView(_ webView: WKWebView, - navigationResponse: WKNavigationResponse, - didBecome download: WKDownload) { - print("webView navigationResponse:didBecome called") - download.delegate = self - } - - func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) { - print("download initiated") - - guard let keymanSettings = self.settings else { - print("tried to access settings before they were intialized in updateNSView") - completionHandler(nil) - return - } - - // notify settings that a keyboard download is beginning and get the URL to - // the temporary folder where it should be downloaded - - downloadFileUrl = keymanSettings.preparePackageDownload(kmpFileName: suggestedFilename) - if let downloadFileUrl { - completionHandler(downloadFileUrl) - } else { - print("could not prepare package for download") - completionHandler(nil) - } - } - - func downloadDidFinish(_ download: WKDownload) { - if let downloadFileUrl { - print("Download of \(downloadFileUrl.path()) was successful.") - if let settings { - settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) - } - } - } - - // MAC-CONFIG-TODO: remove package if it already exists - - func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { - print("Download failed with error: \(error.localizedDescription)") - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - // The web process crashed. Reload the webview safely here. - print("WebKit process terminated unexpectedly: reloading content...") - webView.reload() - } - } } diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 66f53c5aeb6..9707091adca 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -14,13 +14,19 @@ struct MainConfigView: View { @EnvironmentObject var settings: SettingsContainer // visibilty state for the add package sheet - @State private var isShowingSheet = false + @State private var isShowingAddKeyboardSheet = false // used to identify the expanded KeymanPackage id // both single and multi package views share the same state variable so only a single disclosure group is expanded at once @State private var expandedPackageID: UUID? = nil @State private var selectedTab = 0 @State private var packageSelectedForHelpUrl: URL? = nil + // for drag and drop package installation + @State private var packageInstallHelper: PackageInstallHelper? = nil + @State private var isShowingDropKmpAlert = false + @State private var alertMessage = "" + @State private var isHovering = false + /** * Assigns packageSelectedForHelpUrl the url argument and changes the selected tab to the help tab */ @@ -34,7 +40,7 @@ struct MainConfigView: View { VStack { // the add keyboard button LabelButtonView( - action: { isShowingSheet = true }, + action: { isShowingAddKeyboardSheet = true }, label: "Add Keyboard", systemImage: "plus", font: .title2 @@ -42,8 +48,8 @@ struct MainConfigView: View { .clipShape(.capsule) .padding([.top, .leading, .trailing]) // binds the visibility state to the sheet builder - .sheet(isPresented: $isShowingSheet) { - InstallKeyboardView() + .sheet(isPresented: $isShowingAddKeyboardSheet) { + AddKeyboardView() .frame(width: 960, height: 390) // MAC-CONFIG-TODO: Make width and height percentages } @@ -58,7 +64,54 @@ struct MainConfigView: View { showHelpTab(for: url) }) } .formStyle(.grouped) - + // highlight border with accent color when hovering over view + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.accentColor, lineWidth: 2).opacity(isHovering ? 1 : 0)) + .animation(.easeInOut(duration: 0.2), value: isHovering) + // accepts URL drops + .dropDestination(for: URL.self) { urls, _ in + // reject drop if it is more than one file + guard let droppedFileUrl = urls.first, urls.count == 1 else { + let error = DropKmpError.tooManyFiles + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false // the drop failed + } + do { + packageInstallHelper = try settings.initiateKmpFileInstallation(at: droppedFileUrl) + return true // the drop was successful + } catch { + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false + } + } isTargeted: { hovering in + isHovering = hovering + } + // alert triggers automatically when $isShowingDropKmpAlert is true + .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } + .sheet(item: $packageInstallHelper) { helper in + PackageConfirmationView(installHelper: helper) { accepted in + + // close PackageConfirmationView sheet before updating list + packageInstallHelper = nil + + if accepted { + print("installing validated package: \(helper.packageName ?? "unknown package")") + do { + try settings.installPackage() + } catch { + print("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error.localizedDescription)") + } + } else { + settings.userCanceledPackageInstallation() + } + } + } + // the Spacer pushes the contents of the VStack to the top of the VStack Spacer() } @@ -67,7 +120,7 @@ struct MainConfigView: View { .tag(0) if let url = packageSelectedForHelpUrl { - HelpView(helpFileURL: url) + PackageContentWebView(packageFileUrl: url) .padding() .tabItem { Text("Help") } .tag(1) diff --git a/mac/Config/Config/PackageConfirmationView.swift b/mac/Config/Config/PackageConfirmationView.swift new file mode 100644 index 00000000000..7dc310597bd --- /dev/null +++ b/mac/Config/Config/PackageConfirmationView.swift @@ -0,0 +1,60 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * View presented as modal sheet in response to initiating a package installation. + * Displays readme.htm contents and allows user to proceed with install or cancel. + */ + +import SwiftUI +import KeymanSettings + +struct PackageConfirmationView: View { + let installHelper: PackageInstallHelper + let completion: (Bool) -> Void + + var body: some View { + VStack(spacing: 16) { + if let installationPrompt = installHelper.packageInstallationType?.prompt { + let packageName = installHelper.packageToInstall?.packageName ?? "Unknown" + Label(packageName, systemImage: "keyboard") + .font(.title) + .foregroundStyle(Color("Keyman Orange")) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + Text(installationPrompt) + .font(.title3) + .multilineTextAlignment(.leading) + } + + if let readmeFileUrl = installHelper.packageToInstall?.readmeFileUrl { + PackageContentWebView(packageFileUrl: readmeFileUrl) + .cornerRadius(8) + .padding(6) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color("Keyman Orange"), lineWidth: 2) + ) + .padding() + } else { + Text("Read me not available.") + .font(.title) + } + + HStack { + Button("Cancel") { + completion(false) + } + .keyboardShortcut(.cancelAction) + + Button("Install") { + completion(true) + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 580, height: 500) + } +} diff --git a/mac/Config/Config/PackageContentWebView.swift b/mac/Config/Config/PackageContentWebView.swift new file mode 100644 index 00000000000..3881ecdd5df --- /dev/null +++ b/mac/Config/Config/PackageContentWebView.swift @@ -0,0 +1,72 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-08-03 + * + * Webview used to display html content from within the Keyman package + * Any http links clicked are opened in a browser window + */ +import Foundation + +import SwiftUI +import WebKit +import KeymanSettings + +public struct PackageContentWebView: NSViewRepresentable { + let packageFileUrl: URL + + // create the AppKit view instance + public func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView() + + // Connect the delegate to catch link clicks + webView.navigationDelegate = context.coordinator + + return webView + } + + // update the view when SwiftUI state changes + public func updateNSView(_ nsView: WKWebView, context: Context) { + let request = URLRequest(url: packageFileUrl) + + // only load the request if it's not already loading/loaded to prevent infinite loops + if nsView.url != packageFileUrl { + if let fileUrl = request.url { + nsView.loadFileURL(fileUrl, allowingReadAccessTo: fileUrl.deletingLastPathComponent()) + } + } + } + + /** + * Coordinator acts as the WKNavigationDelegate + */ + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /** + * If a url links to the web rather than locally, open it in the default browser + */ + @MainActor + public class Coordinator: NSObject, WKNavigationDelegate { + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + + // check whether the user clicked a link + if navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url { + + // if it is an external link, intercept it and open it a browser window + if url.scheme == "http" || url.scheme == "https" { + NSWorkspace.shared.open(url) // opens default macOS browser + decisionHandler(.cancel) // blocks the webview from loading it + return + } + } + + // allow local navigation + decisionHandler(.allow) + } + } +} diff --git a/mac/Config/Config/PackageRowView.swift b/mac/Config/Config/PackageRowView.swift index b607412636e..b4705151d1a 100644 --- a/mac/Config/Config/PackageRowView.swift +++ b/mac/Config/Config/PackageRowView.swift @@ -62,7 +62,7 @@ public struct PackageRowView: View { // if the package contains one keyboard, show the keyboard name, otherwise show the package name Text(isSingleKeyboardPackage ? keyboard.name: package.packageName) .font(.title) - + // see keyboard help button if let url = package.helpFileUrl { IconButtonView( @@ -84,8 +84,6 @@ public struct PackageRowView: View { .toggleStyle(.switch) .gridColumnAlignment(.leading) } - - } // if the package contains multiple keyboards shows an HStack with the keyboard name and toggle button for each keyboard in the package @@ -124,6 +122,8 @@ public struct PackageRowView: View { } } } + // animate changes in the package list + .animation(.easeInOut, value: packages) // binds the visibilty state to the alert builder .alert("Are you sure you want to delete the keyboard \"\(selectedPackage?.packageName ?? "")\"?", isPresented: $isShowingDeleteAlert, diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/Config/Installation/InputMethodUtil.swift index ab8f0588e2d..eaa2d9cc97f 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/Config/Installation/InputMethodUtil.swift @@ -246,7 +246,10 @@ public class InputMethodUtil { NSWorkspace.shared.openApplication(at: inputMethodUrl, configuration: openConfig) { (app, error) in if let error = error { - print("Could not launch Keyman input method: \(error.localizedDescription)") + print("Could not launch Keyman input method at \(inputMethodUrl), due to error: \(error.localizedDescription), code: \(error._code)") + Thread.callStackSymbols.forEach { symbol in + print(symbol) + } } } } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift index 38408b42f68..8b8a710b501 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift @@ -19,6 +19,6 @@ public protocol PackageRepo { func loadSinglePackage(packageUrl: URL) throws -> KeymanPackage func getDownloadUrl(for kmpFilename: String) -> URL func getUnzipDestinationUrl(for packageName: String) -> URL - func getInstallationUrlForPackageName(packageName: String) -> URL + func buildInstallationUrlForPackageName(packageName: String) -> URL func cleanupTempDirectory() } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 78eb0ab249a..a9ba652df92 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -25,6 +25,18 @@ import Foundation import Combine import ZIPFoundation +public enum InstallPackageError: LocalizedError { + case downloadInProgress + case internalError // due to invalid state, should never occur + + public var errorDescription: String? { + switch self { + case .downloadInProgress: return "A download is already in progress." + case .internalError: return "An internal error occurred." + } + } +} + // distributed notifications public extension Notification.Name { // sent from input method, received by InstallationCheck @@ -33,16 +45,26 @@ public extension Notification.Name { static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed") } -// in-app notifications -public extension Notification.Name { - static let newPackageInstalled = Notification.Name("com.keyman.package.installed") - static let packageReplaced = Notification.Name("com.keyman.package.replaced") - static let packageDowngradeRequested = Notification.Name("com.keyman.package.downgrade.requested") +// define LocalizedError so that UI can present a localizable message +// when the attempt to install a KMP file using drag and drop fails +public enum DropKmpError: LocalizedError { + case invalidFileType(String) + case alreadyInstalled(String) + case installFailed(String) + case tooManyFiles + + public var errorDescription: String? { + switch self { + case .invalidFileType(let fileName): return "The file \(fileName) is not a .KMP file." + case .alreadyInstalled(let fileName): return "The package \(fileName) is already installed." + case .installFailed(let fileName): return "The file \(fileName) could not be installed." + case .tooManyFiles: return "Only a single .KMP file can be installed at a time." + } + } } -public enum SettingsError: Error { - case unknownPackage -} +private let kmpFileExtension = ".kmp" +private let kmpFileExtensionWithoutDot = "kmp" @MainActor // run on the main actor since data is published directly to the UI public class SettingsContainer : ObservableObject { @@ -62,8 +84,8 @@ public class SettingsContainer : ObservableObject { @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] - // when a new package is downloaded, it is tracked here - public private(set) var packageDownload: PackageDownload? = nil + // when a new package is being installed, it is tracked here + public private(set) var packageInstall: PackageInstallHelper? = nil fileprivate let packageRepository: PackageRepo fileprivate let defaultsRepository: DefaultsRepo @@ -108,9 +130,6 @@ public class SettingsContainer : ObservableObject { // next, apply the settings to the packages // this mainly consists of marking them as enabled or not self.applyUserDefaultsToInstalledPackages() - - // use NotificationCenter to receive keyboard installation notifications - self.registerObservers() } /** @@ -125,42 +144,7 @@ public class SettingsContainer : ObservableObject { self.multiKeyboardPackages = [] self.installedPackages = [] } - - /** - * register observers to handle notifications - */ - func registerObservers() { - // for installation of a new package - NotificationCenter.default.addObserver( - self, selector: #selector(newPackageInstalled(_:)), - name: .newPackageInstalled, object: nil - ) - // for replacement of an existing package - NotificationCenter.default.addObserver( - self, selector: #selector(existingPackageReplaced(_:)), - name: .packageReplaced, object: nil - ) - } - - /** - * called for `newPackageInstalled` notification - */ - @objc func newPackageInstalled(_ notification: Notification) { - print("newPackageInstalled notification received") - self.addInstalledPackage() - self.packageDownload = nil - } - - /** - * called for `packageReplaced` notification - */ - @objc func existingPackageReplaced(_ notification: Notification) { - print("existingPackageReplaced notification received") - self.replaceInstalledPackage() - self.packageDownload = nil - } - /** * Whenever the installedPackages array changes, recreate the two subarrays */ @@ -182,103 +166,30 @@ public class SettingsContainer : ObservableObject { self.multiKeyboardPackages = partitionedPackages.multiple.sorted { $0.packageName.caseInsensitiveCompare($1.packageName) == .orderedAscending } } - /** - * Called when user approves the downgrade of package - */ - public func userConfirmedPackageDowngrade() { - if let download = self.packageDownload { - do { - try download.replaceExistingPackageWithNewPackage() - } catch { - print("unable to downgrade package: \(download.packageToInstall?.packageName ?? "unknown")") - } - } - } - /** * Called when user chooses to cancel downgrade of package */ - public func userCanceledPackageDowngrade() { - if let download = self.packageDownload { - print("user cancelled package downgrade") - download.cleanupFailedInstallation() + public func userCanceledPackageInstallation() { + if let install = self.packageInstall { + print("user cancelled package installation") + install.cleanupFailedInstallation() } - self.packageDownload = nil - } - - /** - * for debugging: prints UserDefaults values - */ - public func logUserDefaults() { - self.defaultsRepository.logDefaults() - } - - /** - * for debugging: clears all UserDefaults values - */ - public func clearUserDefaults() { - self.defaultsRepository.clearDefaults() - } - - /** - * check whether a download is already in progress - */ - public func isDownloadInProgress() -> Bool { - // MAC-CONFIG-TODO: add logic, this does not actually prevent downloads when hard-coded to true - return false - } - - /** - * Called by the WebView Coordinator before initiating a package download. - * Creates a PackageDownload instance to manage the state of the package being downloaded with the specified name. - * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. - */ - public func preparePackageDownload(kmpFileName: String) -> URL? { - // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: ".kmp", with: "") - - let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) - - self.packageDownload = packageDownload - return packageDownload.temporaryKmpFileLocation - } - - /** - * Called by the WebView Coordinator after the download is complete. - * Delegates to the PackageDownload instance to decide whether the package should be installed. - */ - public func packageDownloadComplete(kmpFileUrl: URL) { - print ("packageDownloadComplete \(kmpFileUrl)") - - self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) + self.packageInstall = nil } /** - * The package is approved for installation, so add it to the package list and update the UserDefaults for enabled keyboards + * Called when user chooses to cancel downgrade of package */ - func addInstalledPackage() { - if let package = self.packageDownload?.packageToInstall { - self.installedPackages.append(package) - self.addEnabledKeyboards(for: package) + public func packageInstallationFailed() { + if let install = self.packageInstall { + print("packageInstallationFailed") + install.cleanupFailedInstallation() } - } - /** - * The package is approved for installation, so replace the package of the same name in the package list. - * Also update the UserDefaults for enabled keyboards because the new package is enabled by default, and the existing may be disabled - */ - func replaceInstalledPackage() { - if let package = self.packageDownload?.packageToInstall { - if let index = self.installedPackages.firstIndex(where: { $0.packageName == package.packageName }) { - self.installedPackages[index] = package - self.addEnabledKeyboards(for: package) - } else { - print("Error: package '\(package.packageName)' not found for replacement") - } - } + self.packageInstall = nil } - + /** * for each enabled keyboard in the package being installed, add it to the enabled keyboards set and save it in the UserDefaults */ @@ -308,23 +219,10 @@ public class SettingsContainer : ObservableObject { return package } - /** - * find the installed package with the specified package name - */ - public func findInstalledPackage(with packageName: String) -> KeymanPackage? { - guard let package = self.installedPackages.first(where: { $0.packageName == packageName }) else { - print ("Error: could not find package with name: \(packageName)") - return nil - } - - return package - } - /** * remove/uninstall the package with the specified UUID */ public func removeInstalledPackage(with id: UUID) { - if let package = findInstalledPackage(with: id) { self.removeInstalledPackage(package: package) } else { @@ -470,4 +368,165 @@ public class SettingsContainer : ObservableObject { } } } + + // MARK: Package Download and Installation + + /** + * check whether a download is already in progress + */ + public func isDownloadInProgress() -> Bool { + return self.packageInstall != nil + } + + /** + * Called by the WebView DownloadCoordinator before initiating a package download. + * Returns a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + */ + public func initiateKmpFileDownload(kmpFilename: String) throws -> PackageInstallHelper? { + + guard !self.isDownloadInProgress() else { + throw InstallPackageError.downloadInProgress + } + + if let helper = self.preparePackageDownload(kmpFilename: kmpFilename) { + self.packageInstall = helper + } + + return self.packageInstall + } + + /** + * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + */ + func preparePackageDownload(kmpFilename: String) -> PackageInstallHelper? { + // package name is filename minus .kmp extension + let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") + + return PackageInstallHelper(filename: kmpFilename, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) + } + + /** + * Called by the WebView DownloadCoordinator after the download is complete. + * Delegates to the PackageInstallHelper instance to decide whether the package should be installed. + */ + public func packageDownloadComplete(kmpFileUrl: URL) throws { + print ("packageDownloadComplete \(kmpFileUrl)") + + do { + try self.packageInstall?.packageDownloadComplete(for: kmpFileUrl) + } catch { + // clear failed download + self.packageInstall = nil + throw error + } + } + + /** + * The package is approved for installation, so add it to the package list and update the UserDefaults for enabled keyboards + */ + func addInstalledPackage() { + if let package = self.packageInstall?.packageToInstall { + self.installedPackages.append(package) + self.addEnabledKeyboards(for: package) + } + } + + /** + * The package is approved for installation, so replace the package of the same name in the package list. + * Also update the UserDefaults for enabled keyboards because the new package is enabled by default, and the existing may be disabled + */ + func replaceInstalledPackage() { + if let package = self.packageInstall?.packageToInstall { + if let index = self.installedPackages.firstIndex(where: { $0.packageName == package.packageName }) { + self.installedPackages[index] = package + self.addEnabledKeyboards(for: package) + } else { + print("Error: package '\(package.packageName)' not found for replacement") + } + } + } + + // MARK: Drag and drop Package Installation + + /** + * Begin installation of a package from a KMP file. + * Called when a .KMP file is dropped on the Configuration view + */ + public func initiateKmpFileInstallation(at fileLocation: URL) throws -> PackageInstallHelper? { + guard !self.isDownloadInProgress() else { + throw InstallPackageError.downloadInProgress + } + + // validate the URL of the KMP file + try self.validateDroppedFile(from: fileLocation) + + let kmpFilename = fileLocation.lastPathComponent + if let helper = self.preparePackageDrop(kmpFilename: kmpFilename) { + self.packageInstall = helper + do { + try helper.prepareToInstall(for: fileLocation) + } catch { + // clear failed download + self.packageInstall = nil + throw error + } + } + + return self.packageInstall + } + + /** + * Install the package and add it to the installedPackages array and UserDefaults + */ + public func installPackage() throws { + if let install = self.packageInstall { + do { + try install.installPackage() + } catch { + self.packageInstall = nil + throw error + } + commitPackageInstall() + } + } + + /** + * Update the data model for the installed package. + */ + func commitPackageInstall() { + if let install = self.packageInstall { + + guard let installationType = install.packageInstallationType else { return } + + switch installationType { + case .newPackage: + self.addInstalledPackage() + case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: + self.replaceInstalledPackage() + } + } + + self.packageInstall = nil + } + + /** + * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. + */ + func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { + // package name is filename minus .kmp extension + let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") + + return PackageInstallHelper(filename: kmpFilename, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) + } + + /** + * Validate the URL for the file we are dropping + */ + func validateDroppedFile(from fileLocation: URL) throws { + // if the file does not end with .kmp, reject it + if fileLocation.pathExtension.lowercased() != kmpFileExtensionWithoutDot { + throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) + } + } } diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index ffbc6bc3a29..cedbdeb12de 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -39,6 +39,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { public let fonts: [String] public let packageName: String public let packageVersion: String + public let minimumSupportedKeymanVersion: String public let author: String? public let websiteUrl: URL? @@ -89,6 +90,8 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { self.packageName = packageSource.info.name.description self.packageVersion = packageSource.info.version.description + self.minimumSupportedKeymanVersion = packageSource.system.fileVersion.description + self.author = packageSource.info.author?.description if let websiteUrlString = packageSource.info.website?.url { self.websiteUrl = URL(string: websiteUrlString) @@ -145,13 +148,16 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { /** * initializer that does not rely on package source -- provided to create unit test data */ - public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, author: String? = nil, website: URL? = nil, copyright: String? = nil, readmeFileName: String? = nil, helpFilename: String? = nil, graphicName: String? = nil) { + public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, + minimumKeymanVersion: String = "7.0.0", author: String? = nil, website: URL? = nil, copyright: String? = nil, + readmeFileName: String? = nil, helpFilename: String? = nil, graphicName: String? = nil) { self.id = UUID() self.sourceDirectoryUrl = sourceDirectoryUrl self.sharePackageUrl = sharePackageUrl self.keyboards = keyboards self.packageName = packageName self.packageVersion = packageVersion + self.minimumSupportedKeymanVersion = minimumKeymanVersion self.author = author self.websiteUrl = website self.copyright = copyright @@ -199,15 +205,42 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { } /** - * validate whether the package contain a kmx file for each of its keyboards + * validate the package + * 1. whether the package can be loaded by this version of Keyman + * 2. whether it contains a kmx file for each of its keyboards */ public func validate() throws { + try self.validateKeymanVersionForPackage() + // if validateKmxFile throws an error, then the loop is stopped and the error is propagated try self.keyboards.forEach { keyboard in try keyboard.validateKmxFile(in: self.sourceDirectoryUrl) } } + /** + * verify that the version of Keyman is equal to our newer than the + * minimum required Keyman version specifed by the package + */ + func validateKeymanVersionForPackage() throws { + let keymanVersion = ConfigAppUtil.configAppVersion() + let minimumKeymanVersion = self.minimumSupportedKeymanVersion + var meetsRequiredVersion: Bool = false + let comparisonResult = keymanVersion.compare(minimumKeymanVersion, options: .numeric) + + if comparisonResult == .orderedAscending { + // keyman version is too old + meetsRequiredVersion = false + print("for package '\(self.packageName)' keyman version \(keymanVersion) is older than required version \(minimumKeymanVersion)") + } else { + meetsRequiredVersion = true + } + + if (!meetsRequiredVersion) { + throw LoadPackageError.insufficientKeymanVersion(packageName: self.packageName, requiredKeymanVersion: minimumKeymanVersion, actualKeymanVersion: keymanVersion) + } + } + /** * create the image specified for the package * if none specified, load the default image diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift index e9d4013dabf..ffce791ac34 100644 --- a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift +++ b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift @@ -15,8 +15,8 @@ let defaultReadmeFilename = "readme.htm" public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { public var id = UUID() - let system: SystemInfo? - let options: Options? + let system: SystemInfo + let options: Options let info: Info let files: [PackageFile]? let keyboards: [KeyboardSource]? @@ -36,7 +36,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { } } var readmeFilename: String? { - if let filename = options?.readmeFile { + if let filename = options.readmeFile { return filename } if let fileArray = self.files { @@ -47,7 +47,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return nil } var helpFilename: String? { - if let filename = options?.welcomeFile { + if let filename = options.welcomeFile { return filename } if let fileArray = self.files { @@ -59,7 +59,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return nil } var graphicFilename: String? { - if let filename = options?.graphicFile { + if let filename = options.graphicFile { return filename } else { return nil @@ -79,8 +79,8 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { self.info = try container.decode(Info.self, forKey: .info) self.keyboards = try container.decodeIfPresent([KeyboardSource].self, forKey: .keyboards) - self.system = try container.decodeIfPresent(SystemInfo.self, forKey: .system) - self.options = try container.decodeIfPresent(Options.self, forKey: .options) + self.system = try container.decode(SystemInfo.self, forKey: .system) + self.options = try container.decode(Options.self, forKey: .options) self.files = try container.decodeIfPresent([PackageFile].self, forKey: .files) if files?.isEmpty ?? true { @@ -148,7 +148,7 @@ struct Website: Decodable { struct SystemInfo: Decodable { let keymanDeveloperVersion: String? - let fileVersion: String? + let fileVersion: String enum CodingKeys: String, CodingKey { case keymanDeveloperVersion diff --git a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift deleted file mode 100644 index ebee061c7de..00000000000 --- a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Keyman is copyright (C) SIL Global. MIT License. - * - * Created by Shawn Schantz on 2026-06-30 - * - * Tracks the state of a package being downloaded with functions - * to derive its temporary download location, compare it to a - * package of the same type if it exists and replace or delete depending - * on its version and user feedback. - */ - -import Foundation - -@MainActor // run on the main actor as it is called from SettingsContainer -public class PackageDownload { - let temporaryKmpFileLocation: URL - let temporaryPackageLocation: URL - let installPackageLocation: URL - let installedPackages: [KeymanPackage] // needed to check for existing package after download - var packageToInstall: KeymanPackage? // the newly downloaded package - var packageToReplace: KeymanPackage? // the package to replace, if it exists - - fileprivate let packageRepository: PackageRepo - - public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage]) { - self.packageRepository = packageRepo - self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) - self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: packageName) - self.installPackageLocation = self.packageRepository.getInstallationUrlForPackageName(packageName: packageName) - self.installedPackages = installedPackages - - // cannot be initialized until after download when packageName of new package is known - self.packageToReplace = nil - - // if any packages are remaining from an earlier download, delete them - self.packageRepository.cleanupTempDirectory() - } - - /** - * Indicates that a package has been downloaded and is ready to be unzipped and installed - */ - public func packageDownloadComplete(for kmpFileUrl: URL) { - print ("packageDownloadComplete \(kmpFileUrl)") - - do { - try self.unzipDownloadedPackage(for: kmpFileUrl) - try self.handleNewPackage() - } catch { - self.cleanupFailedInstallation() - - print ("package installation failed with error '\(error)' for \(kmpFileUrl)") - // MAC-CONFIG-TODO: handle error - // send notification that installation failed? - } - } - - /** - * Unzip the and load the downloaded package - */ - func unzipDownloadedPackage(for kmpFileUrl: URL) throws { - try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) - - // load the unzipped package from the temporary location and save a reference to it - let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) - self.packageToInstall = newPackage - } - - /** - * Decides whether the package should be installed. - * - If this package is not replacing a package, then it is installed. - * - If this package is replacing an older package, the new package replaces the old. - * - If this package is replacing a newer package, then the user is notified to confirm. - */ - func handleNewPackage() throws { - // first check whether this install is replacing an existing package, - if self.checkForExistingPackage() { - if self.replacingInstalledPackageWithEarlierVersion() { - // check with the user before allowing a downgrade - self.sendNotificationToConfirmPackageDowngrade() - } else { - try self.replaceExistingPackageWithNewPackage() - } - } else { - try self.installNewPackage() - } - } - - /** - * Check whether a package of the same name is already installed which may be replaced. - */ - func checkForExistingPackage() -> Bool { - var packageExists = false - - if let package = self.installedPackages.first(where: { $0.packageName == self.packageToInstall?.packageName }) { - self.packageToReplace = package - packageExists = true - } - return packageExists - } - - /** - * Send a notification that an attempt to downgrade a package has been detected - */ - func sendNotificationToConfirmPackageDowngrade() { - NotificationCenter.default.post(name: .packageDowngradeRequested, object: nil) - } - - /** - * Install the newly downloaded package (no existing package to replace) - */ - func installNewPackage() throws { - try self.movePackageFromTemporaryToInstalled() - try self.deleteDownloadedKmpFile() - - NotificationCenter.default.post(name: .newPackageInstalled, object: nil) - } - - /** - * Replace the existing installed package with the newly download package - */ - func replaceExistingPackageWithNewPackage() throws { - try self.deleteInstalledPackage() - try self.deleteDownloadedKmpFile() - try self.movePackageFromTemporaryToInstalled() - - NotificationCenter.default.post(name: .packageReplaced, object: nil) - } - - /** - * Clean up the downloaded .kmp file and package folder - */ - func cleanupFailedInstallation() { - print("cleanupFailedInstallation of: \(self.temporaryPackageLocation.lastPathComponent)") - do { - try self.deleteDownloadedKmpFile() - } catch { - print("cleanupFailedInstallation did not delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") - } - do { - try self.deleteDownloadedPackage() - } catch { - print("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryPackageLocation.lastPathComponent)") - } - } - - /** - * Delete the existing installed package that matches the downloaded package - */ - func deleteInstalledPackage() throws { - try FileManager.default.removeItem(at: self.installPackageLocation) - } - - /** - * Move the downloaded package into the keyman packages directory. - */ - func movePackageFromTemporaryToInstalled() throws { - try FileManager.default.moveItem(at: self.temporaryPackageLocation, to: self.installPackageLocation) - - // Update the KeymanPackage object with its new location - if let package = self.packageToInstall { - package.sourceDirectoryUrl = self.installPackageLocation - } - } - - /** - * Delete the downloaded .kmp file from the temp directory - */ - func deleteDownloadedKmpFile() throws { - try FileManager.default.removeItem(at: self.temporaryKmpFileLocation) - } - - /** - * Delete the downloaded package from the temp directory - */ - func deleteDownloadedPackage() throws { - try FileManager.default.removeItem(at: self.temporaryPackageLocation) - } - - /** - * Determine whether the new package is older than the currently installed package - */ - func replacingInstalledPackageWithEarlierVersion() -> Bool { - var downgrade = false - - guard let installedVersion = self.packageToReplace?.packageVersion, - let newVersion = self.packageToInstall?.packageVersion else { - return false - } - - let comparisonResult = newVersion.compare(installedVersion, options: .numeric) - - if comparisonResult == .orderedAscending { - // downgrade detected - downgrade = true - print("downgrade: new version is older than installed version") - } else if comparisonResult == .orderedDescending { - print("upgrade: new version is newer than installed version") - } else { - print("new and installed versions are identical") - } - - return downgrade - } -} diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift new file mode 100644 index 00000000000..0067315ac03 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -0,0 +1,261 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-30 + * + * Tracks the state of a package being installed with functions + * to derive its temporary install location, compare it to a + * package of the same type if it exists and replace or delete depending + * on its version and user feedback. + */ + +import Foundation + +public enum PackageInstallationType { + case newPackage(String) + case replaceSameVersionPackage(String) + case replaceOlderPackage(String, String, String) + case replaceNewerPackage(String, String, String) + + public var prompt: LocalizedStringResource { + switch self { + case .newPackage(let packageName): + return "The package '\(packageName)' is ready to install" + case .replaceSameVersionPackage(let packageName): + return "The package '\(packageName)' is ready to re-install" + case .replaceOlderPackage(let packageName, let existingVersion, let newVersion): + return "The package '\(packageName)' is ready to update from version \(existingVersion) to \(newVersion)" + case .replaceNewerPackage(let packageName, let existingVersion, let newVersion): + return "The package '\(packageName)' is ready to downgrade from version \(existingVersion) to \(newVersion)" + } + } +} + +@MainActor // run on the main actor as it is called from SettingsContainer +public class PackageInstallHelper: Identifiable { + public let id = UUID() + public let temporaryKmpFileLocation: URL + let temporaryPackageLocation: URL + let installPackageLocation: URL + let installedPackages: [KeymanPackage] // needed to check for existing package after download + let isDownload: Bool // if not download, then the package was opened from disk or dropped + public private(set) var packageToInstall: KeymanPackage? // the newly downloaded package + public private(set) var packageToReplace: KeymanPackage? // the package to replace, if it exists + public private(set) var packageInstallationType: PackageInstallationType? + + public var packageName: String? { + return packageToInstall?.packageName + } + + fileprivate let packageRepository: PackageRepo + + public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { + self.packageRepository = packageRepo + self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) + self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: packageName) + self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) + self.installedPackages = installedPackages + self.isDownload = isDownload + + // cannot be initialized until after download when packageName of new package is known + self.packageToReplace = nil + + // if any packages are remaining from an earlier download, delete them + self.packageRepository.cleanupTempDirectory() + } + + /** + * Indicates that a package has been downloaded and can be prepared for installation + */ + public func packageDownloadComplete(for kmpFileUrl: URL) throws { + print ("packageDownloadComplete \(kmpFileUrl)") + + try self.prepareToInstall(for: kmpFileUrl) + } + + /** + * Indicates that a package is ready to be unzipped and loaded + */ + public func prepareToInstall(for kmpFileUrl: URL) throws { + print ("prepareToInstall \(kmpFileUrl)") + + do { + try self.unzipAndLoadPackage(for: kmpFileUrl) + } catch { + self.cleanupFailedInstallation() + print ("package installation failed with error '\(error)' for \(kmpFileUrl)") + throw error + } + } + + /** + * Install the new package and replace existing package if necessary + */ + public func installPackage() throws { + print ("installPackage \(self.packageToInstall?.packageName ?? "unknown package")") + + // prepareToInstall will always set this + guard let installationType = self.packageInstallationType else { + print("error: installationType not set before call to installPackage") + throw InstallPackageError.internalError + } + + switch installationType { + case .newPackage: + try self.installNewPackage() + case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: + try self.replaceExistingPackageWithNewPackage() + } + } + + /** + * Unzip and load the downloaded package + */ + func unzipAndLoadPackage(for kmpFileUrl: URL) throws { + // unzip to the temp directory + try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) + + // load the unzipped package from the temp directory and save a reference to it + self.packageToInstall = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) + + // now that we know what we are installing, determine the type of install + self.packageInstallationType = self.determinePackageInstallationType() + } + + /** + * Decides what type of package installation this is: + * - a new package + * - an update of an existing package + * - a downgrade of an existing package + */ + func determinePackageInstallationType() -> PackageInstallationType { + let packageAlreadyInstalled = self.checkForExistingPackage() + var installationType: PackageInstallationType = .newPackage("unknown package") + + // If there is no new package, return bogus value of .newPackage. + // Without a package, the installation will fail elsewhere and the + // type of installation is completely irrelevant. + guard let newPackage = self.packageToInstall else { + print("error: packageToInstall not set when determining package installation type") + return installationType + } + + if !packageAlreadyInstalled { + installationType = PackageInstallationType.newPackage(newPackage.packageName) + } else { + if let installedPackage = self.packageToReplace { + let newVersion = newPackage.packageVersion + let existingVersion = installedPackage.packageVersion + + let comparisonResult = newVersion.compare(existingVersion, options: .numeric) + + if comparisonResult == .orderedAscending { + print("package downgrade: new version is older than existing version") + installationType = PackageInstallationType.replaceNewerPackage(newPackage.packageName, existingVersion, newVersion) + } else if comparisonResult == .orderedDescending { + print("package upgrade: new version is newer than existing version") + installationType = PackageInstallationType.replaceOlderPackage(newPackage.packageName, existingVersion, newVersion) + } else { + print("new and existing package versions are identical") + installationType = PackageInstallationType.replaceSameVersionPackage(newPackage.packageName) + } + } + } + + return installationType + } + + /** + * Check whether a package of the same name is already installed which may be replaced. + */ + func checkForExistingPackage() -> Bool { + var packageExists = false + + if let package = self.installedPackages.first(where: { $0.packageName == self.packageToInstall?.packageName }) { + self.packageToReplace = package + packageExists = true + } + return packageExists + } + + /** + * Install the newly downloaded package (no existing package to replace) + */ + func installNewPackage() throws { + try self.movePackageFromTemporaryToInstalled() + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + print("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + } + } + + /** + * Replace the existing installed package with the newly download package + */ + func replaceExistingPackageWithNewPackage() throws { + try self.deleteInstalledPackage() + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + print("replaceExistingPackageWithNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + } + try self.movePackageFromTemporaryToInstalled() + } + + /** + * Clean up the downloaded .kmp file and package folder + */ + func cleanupFailedInstallation() { + // we only have a .kmp file in the temp directory for downloads + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + print("cleanupFailedInstallation did not delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + } + do { + try self.deleteUnzippedPackage() + } catch { + print("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryPackageLocation.lastPathComponent)") + } + } + + /** + * Delete the existing installed package that matches the downloaded package + */ + func deleteInstalledPackage() throws { + try FileManager.default.removeItem(at: self.installPackageLocation) + } + + /** + * Move the downloaded package into the keyman packages directory. + */ + func movePackageFromTemporaryToInstalled() throws { + try FileManager.default.moveItem(at: self.temporaryPackageLocation, to: self.installPackageLocation) + + // Update the KeymanPackage object with its new location + if let package = self.packageToInstall { + package.sourceDirectoryUrl = self.installPackageLocation + } + } + + /** + * Delete the downloaded .kmp file from the temp directory + */ + func deleteDownloadedKmpFile() throws { + try FileManager.default.removeItem(at: self.temporaryKmpFileLocation) + } + + /** + * Delete the unzipped package in the temp directory + */ + func deleteUnzippedPackage() throws { + try FileManager.default.removeItem(at: self.temporaryPackageLocation) + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index e35541969aa..f5079680441 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -10,7 +10,9 @@ import Foundation -enum LoadPackageError: Error { +public enum LoadPackageError: LocalizedError { + case invalidUrl + case unzipError case containsNoFiles case containsNoKeyboards case kmpJsonFileUnreadable @@ -19,33 +21,21 @@ enum LoadPackageError: Error { case missingKeyboardId case missingKeyboardVersion case missingKmxFile -} - -enum InstallPackageError: Error { - case invalidUrl - case unzipError -} + case insufficientKeymanVersion(packageName: String, requiredKeymanVersion: String, actualKeymanVersion: String) -// Conform to LocalizedError to provide the description -extension LoadPackageError: LocalizedError { - var errorDescription: String? { + public var errorDescription: String? { switch self { - case .containsNoFiles: - return NSLocalizedString("The package contains no files.", comment: "") - case .containsNoKeyboards: - return NSLocalizedString("The package contains no keyboards", comment: "") - case .kmpJsonFileUnreadable: - return NSLocalizedString("The package's kmp.json file could not be parsed", comment: "") - case .kmpJsonFileNotFound: - return NSLocalizedString("The package's kmp.json file was not found", comment: "") - case .missingKeyboardName: - return NSLocalizedString("A keyboard in the package has no name", comment: "") - case .missingKeyboardId: - return NSLocalizedString("A keyboard in the package has no id", comment: "") - case .missingKeyboardVersion: - return NSLocalizedString("A keyboard in the package has no version", comment: "") - case .missingKmxFile: - return NSLocalizedString("A keyboard in the package has no corresponding KMX file", comment: "") + case .invalidUrl: return "The URL is not valid." + case .unzipError: return "The keyboard package could not be unzipped." + case .containsNoFiles: return "The keyboard package contains no files." + case .containsNoKeyboards: return "The keyboard package contains no keyboards." + case .kmpJsonFileUnreadable: return "The package's kmp.json file could not be parsed." + case .kmpJsonFileNotFound: return "The package's kmp.json file was not found." + case .missingKeyboardName: return "A keyboard in the package has no name." + case .missingKeyboardId: return "A keyboard in the package has no ID." + case .missingKeyboardVersion: return "A keyboard in the package has no version." + case .missingKmxFile: return "A keyboard in the package has no corresponding KMX file." + case .insufficientKeymanVersion(let packageName, let requiredKeymanVersion, let actualKeymanVersion): return "The keyboard package '\(packageName)' requires Keyman version \(requiredKeymanVersion) but your version is \(actualKeymanVersion)." } } } @@ -89,7 +79,7 @@ public class PackageRepository: PackageRepo { */ public func loadSinglePackage(packageUrl: URL) throws -> KeymanPackage { print("loadSinglePackage from url: \(packageUrl)") - guard let source = try readPackageFromDirectory(packageDirectoryUrl: packageUrl) else { throw InstallPackageError.invalidUrl } + guard let source = try readPackageFromDirectory(packageDirectoryUrl: packageUrl) else { throw LoadPackageError.invalidUrl } let package = KeymanPackage(packageUrl: packageUrl, packageSource: source) try package.validate() @@ -171,9 +161,9 @@ public class PackageRepository: PackageRepo { return self.pathUtil.keyman19TempDirectory.appendingPathComponent(packageName) } /** - * get the url to where the specified package should be installed + * build the URL where the specified package will be installed */ - public func getInstallationUrlForPackageName(packageName: String) -> URL { + public func buildInstallationUrlForPackageName(packageName: String) -> URL { return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(packageName) } @@ -186,7 +176,7 @@ public class PackageRepository: PackageRepo { print("Successfully unzipped the file!") } catch { print("Extraction failed: \(error.localizedDescription)") - throw InstallPackageError.unzipError + throw LoadPackageError.unzipError } } diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift index ed1697c2c80..28ee3c4f01d 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift @@ -94,7 +94,7 @@ class PackageRepoStub: PackageRepo { return URL(fileURLWithPath: "") } - func getInstallationUrlForPackageName(packageName: String) -> URL { + func buildInstallationUrlForPackageName(packageName: String) -> URL { return URL(fileURLWithPath: "") }