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..3a337a667e4 --- /dev/null +++ b/mac/Config/Config/AddKeyboardView.swift @@ -0,0 +1,109 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * Contains webview to search for keyboards + * Injects DownloadCoordinator to bridge back to SwiftUI + */ + +import SwiftUI +import KeymanSettings + +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) + // 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") { + print("close button clicked") + dismissAddKeyboardView() + if settings.isInstallationInProgress() { + settings.userCanceledPackageInstallation() + } + } + } + } + .onDisappear { + print("AddKeyboardView onDisappear") + downloadCoordinator.cancelActiveDownload() + } + .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() + } + } + } + } +} + +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) {} +} + diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index 49f8d2c6493..a35fac4843e 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: 1000, + minHeight: 400, maxHeight: .infinity + ) .environmentObject(settings) .task { if !installation.getHasDisplayedInstallationComplete() { @@ -27,19 +31,21 @@ struct ConfigApp: App { .onReceive(NotificationCenter.default.publisher(for: .installationRepairStarted)) { notification in openWindow(id: "install") } } + // the size of the window when first opened + .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) - } + + // for testing purposes +// 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..8d54ad60624 --- /dev/null +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -0,0 +1,215 @@ +/* + * 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 settings: SettingsContainer? + private var progressObserver: NSKeyValueObservation? + private var activeDownload: WKDownload? + + 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 an installation is already in progress then stop another from starting + if keymanSettings.isInstallationInProgress() { + print("installation already in progress, download canceled") + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.packageInstallationAlreadyInProgress.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 + + // record download in case we need to cancel + self.activeDownload = download + + // 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))%") + } + } + } + + /** + * Called when the AddKeyboardView is closed. If there is a download in progress, it will be canceled. + */ + public func cancelActiveDownload() { + guard isDownloading else { return } // only applied during downloads + + self.activeDownload?.cancel() + self.activeDownload = nil + self.progressObserver = nil + self.isDownloading = false + self.installHelper = nil + self.settings?.userCanceledPackageInstallation() + } + + 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/GradientDivider.swift b/mac/Config/Config/InstallationViews/GradientDivider.swift index b59a884e077..5b9e3a1c1d2 100644 --- a/mac/Config/Config/InstallationViews/GradientDivider.swift +++ b/mac/Config/Config/InstallationViews/GradientDivider.swift @@ -23,7 +23,7 @@ struct GradientDivider: View { startPoint: .leading, endPoint: .trailing )) - .frame(height: 2) + .frame(height: 3) .opacity(0.5) .matchedGeometryEffect(id: id, in: namespace) } 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..0d15b467241 100644 --- a/mac/Config/Config/InstallationViews/InitialRepairView.swift +++ b/mac/Config/Config/InstallationViews/InitialRepairView.swift @@ -17,7 +17,7 @@ struct InitialRepairView: View { var body: some View { VStack { - Label("Repairs Required", systemImage: "hand.raised.fill") + Text("Repairs Required") .font(.title) .bold() .frame(maxWidth: .infinity, alignment: .center) @@ -27,13 +27,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..ffd78c72b53 100644 --- a/mac/Config/Config/InstallationViews/RerunInstallerView.swift +++ b/mac/Config/Config/InstallationViews/RerunInstallerView.swift @@ -27,13 +27,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..aae6afa2bbd 100644 --- a/mac/Config/Config/KeyboardSearchView.swift +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -3,38 +3,32 @@ * * 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) // it is injected just before makeNSView and updateNSView are called - - // 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) + let request = URLRequest(url: settings.keyboardSearchUrl) webView.load(request) return webView } @@ -45,126 +39,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..d7266bf2126 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -4,7 +4,6 @@ * Created by Gabriel Schantz on 2026-06-29 * * Main view used for configuring Keyman - * MAC-CONFIG-TODO: Set default width and height for window */ import SwiftUI @@ -14,13 +13,36 @@ 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 + + // item being targeted for deletion + @State private var idToDelete: UUID? = nil + + private var packageNameToDelete: String { + guard let uuid = idToDelete else { return "this item" } + guard let package = settings.findInstalledPackage(with: uuid) else { return "this item" } + return package.packageName + } + + @Environment(\.colorScheme) var colorScheme + var canvasColor: Color { + colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.94) + } + var cardColor: Color { + colorScheme == .dark ? Color(white: 0.20) : Color(.white) + } + /** * Assigns packageSelectedForHelpUrl the url argument and changes the selected tab to the help tab */ @@ -28,13 +50,13 @@ struct MainConfigView: View { packageSelectedForHelpUrl = url selectedTab = 1 } - + var body: some View { TabView (selection: $selectedTab) { VStack { // the add keyboard button LabelButtonView( - action: { isShowingSheet = true }, + action: { isShowingAddKeyboardSheet = true }, label: "Add Keyboard", systemImage: "plus", font: .title2 @@ -42,23 +64,114 @@ struct MainConfigView: View { .clipShape(.capsule) .padding([.top, .leading, .trailing]) // binds the visibility state to the sheet builder - .sheet(isPresented: $isShowingSheet) { - InstallKeyboardView() - .frame(width: 960, height: 390) - // MAC-CONFIG-TODO: Make width and height percentages - } + .sheet(isPresented: $isShowingAddKeyboardSheet) { + AddKeyboardView() + // disable escape key for closing view to avoid issues with canceling downloads + .interactiveDismissDisabled(true) + .frame(minWidth: 800, minHeight: 600) + } - Form { + List { // the view for single keyboard packages - PackageRowView(packages: settings.singleKeyboardPackages, isSingleKeyboardPackage: true, expandedPackageID: $expandedPackageID, showHelpTab: { url in + PackageRowView(packages: settings.singleKeyboardPackages, isSingleKeyboardPackage: true, expandedPackageID: $expandedPackageID, + idToDelete: $idToDelete, showHelpTab: { url in showHelpTab(for: url)}) // the view for multi keyboard packages - PackageRowView(packages: settings.multiKeyboardPackages, isSingleKeyboardPackage: false, expandedPackageID: $expandedPackageID, showHelpTab: { url in + PackageRowView(packages: settings.multiKeyboardPackages, isSingleKeyboardPackage: false, expandedPackageID: $expandedPackageID, + idToDelete: $idToDelete, showHelpTab: { url in showHelpTab(for: url) }) } - .formStyle(.grouped) + .listStyle(.inset) + // confirmation dialog for deleting a package + + .confirmationDialog( + "Are you sure you want to delete the Keyman package '\(packageNameToDelete)'?", + isPresented: Binding( + get: { idToDelete != nil }, + set: { if !$0 { idToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let uuid = idToDelete { + print("deleting package.id: \(uuid)") + // use multiple expanded states? + //expandedStates.removeValue(forKey: uuid) + + withAnimation(.easeInOut(duration: 0.3)) { + expandedPackageID = nil + settings.removeInstalledPackage(with: uuid) + } + } + idToDelete = nil // dismiss safely + } + + Button("Cancel", role: .cancel) { + idToDelete = nil + } + } + + // drag and drop + + // 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 < 2 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 to indicate failed package installation + + .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } + + // package installation confirmation, displays readme contents for package + + .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 { + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + print("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error.localizedDescription)") + } + } else { + settings.userCanceledPackageInstallation() + } + } + // disable escape key for closing view to avoid issues with canceling downloads + .interactiveDismissDisabled(true) + } + // the Spacer pushes the contents of the VStack to the top of the VStack Spacer() } @@ -67,7 +180,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..ecc12ed1a35 --- /dev/null +++ b/mac/Config/Config/PackageContentWebView.swift @@ -0,0 +1,92 @@ +/* + * 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) { + + // if not url, cancel + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + + // if not user-activated, pass through, e.g. for redirects + guard navigationAction.navigationType == .linkActivated else { + decisionHandler(.allow) + return + } + + // local files load in webview + if url.isFileURL { + decisionHandler(.allow) + return + } + + // handle external links by opening in web browser + decisionHandler(.cancel) + + var externalUrl = url + + // strip "link:" prefix if present + let urlString = url.absoluteString + if urlString.hasPrefix("link:https://") || urlString.hasPrefix("link:http://") { + let cleanString = urlString.replacingOccurrences(of: "link:", with: "") + if let cleanUrl = URL(string: cleanString) { + externalUrl = cleanUrl + } + } + + // Open the external link in the default browser + NSWorkspace.shared.open(externalUrl) + } + } +} diff --git a/mac/Config/Config/PackageRowView.swift b/mac/Config/Config/PackageRowView.swift index b607412636e..460ab0e7479 100644 --- a/mac/Config/Config/PackageRowView.swift +++ b/mac/Config/Config/PackageRowView.swift @@ -15,35 +15,35 @@ import KeymanSettings public struct PackageRowView: View { @EnvironmentObject var settings: SettingsContainer - // visibilty state for the delete package alert - @State private var isShowingDeleteAlert = false // used to identify the selected KeymanPackage for the delete package alert - @State private var selectedPackage: KeymanPackage? = nil - - // settings.singleKeyboardPackages or settings.multiKeyboardPackages + + @Environment(\.colorScheme) var colorScheme + var canvasColor: Color { + colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.94) + } + var cardColor: Color { + colorScheme == .dark ? Color(white: 0.20) : Color(.white) + } + + // could be settings.singleKeyboardPackages or settings.multiKeyboardPackages let packages: [KeymanPackage] // a boolean for whether or not a package contains multiple keyboards let isSingleKeyboardPackage: Bool // binded to the shared state variable in the parent view @Binding var expandedPackageID: UUID? + @Binding var idToDelete: UUID? // closure passed from the parent view let showHelpTab: (URL) -> Void - init(packages: [KeymanPackage], isSingleKeyboardPackage: Bool, expandedPackageID: Binding, showHelpTab: @escaping (URL) -> Void) { + + init(packages: [KeymanPackage], isSingleKeyboardPackage: Bool, expandedPackageID: Binding, idToDelete: Binding, showHelpTab: @escaping (URL) -> Void) { self.packages = packages self.isSingleKeyboardPackage = isSingleKeyboardPackage self._expandedPackageID = expandedPackageID + self._idToDelete = idToDelete self.showHelpTab = showHelpTab } - /** - * Sets isShowingDeleteAlert to true and assigns the state variable selectedPackage the KeymanPackage argument - */ - public func showDeleteAlert(for package: KeymanPackage) { - isShowingDeleteAlert = true - selectedPackage = package - } - public var body: some View { ForEach(packages, id: \.id) { package in ForEach(isSingleKeyboardPackage ? package.keyboards : package.keyboards.onlyFirst) { keyboard in @@ -51,18 +51,20 @@ public struct PackageRowView: View { // the package info view is shown inside each disclosure group if expandedPackageID == package.id { PackageInfoView(package: package, showAlertFunction: { package in - showDeleteAlert(for: package) + idToDelete = package.id + //showDeleteAlert(for: package) }) .transition(.move(edge: .top)) } - } label: { + } + label: { // a VStack is shown as the label for each disclosure group VStack (alignment: .leading, spacing: 0) { HStack { // 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 +86,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 @@ -122,21 +122,15 @@ public struct PackageRowView: View { } } } + .listRowBackground( + Rectangle() + .fill(cardColor) // native Mac card color = Color(.controlBackgroundColor) + ) } } - // binds the visibilty state to the alert builder - .alert("Are you sure you want to delete the keyboard \"\(selectedPackage?.packageName ?? "")\"?", - isPresented: $isShowingDeleteAlert, - presenting: selectedPackage) { package in - // cancel button - Button("Cancel", role: .cancel) { } - // delete button - Button("Delete", role: .destructive) { - settings.removeInstalledPackage(with: package.id) - } - } message: { package in - Text("You can't undo this action.") - } + // animate changes in the package list + .animation(.easeInOut, value: packages) + .padding(.vertical, 8) } // the helper method to generate the custom binding for whether a package's disclosure group is expanded or not diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift index f37cb19b988..283792d004f 100644 --- a/mac/Config/Installation/InstallationCheck.swift +++ b/mac/Config/Installation/InstallationCheck.swift @@ -197,7 +197,7 @@ public class InstallationCheck { name: NSNotification.Name.accessibilityStateResponse, object: nil // Observe notifications from any sender ) - // MAC-CONFIG_TODO: add timeout? + // MAC-CONFIG-TODO: add timeout? } /** diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift index 23e93cf4f80..38dbdd665cd 100644 --- a/mac/Config/Installation/InstallationContainer.swift +++ b/mac/Config/Installation/InstallationContainer.swift @@ -39,7 +39,7 @@ public class InstallationContainer : ObservableObject { let defaultsRepo: DefaultsRepository // create the settings repository, gaining access to the app group UserDefaults do { - defaultsRepo = try DefaultsRepository(suiteName: KeymanPaths.groupId) + defaultsRepo = try DefaultsRepository(suiteName: InputMethodUtil.groupId) print("Found group container") } catch UserDefaultsError.unknownSuite { fatalError("Group container not found.") @@ -271,6 +271,10 @@ public class InstallationContainer : ObservableObject { let success = self.inputMethodUtil.invokeKeymanInputMethodMigration() print("migration suceeded: \(success)") + // check whether + if success { + NotificationCenter.default.post(name: .dataMigrated, object: nil) + } return success } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift index 49530d95080..feee6360ad2 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift @@ -9,6 +9,8 @@ import Foundation public struct ConfigAppUtil { + static public let configBundleId = "com.keyman.config" + /** * returns the short version string from the bundle of the Config app */ diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift similarity index 90% rename from mac/Config/Installation/InputMethodUtil.swift rename to mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift index ab8f0588e2d..43dc62c170d 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift @@ -10,7 +10,6 @@ import Foundation import Carbon.HIToolbox import AppKit -import KeymanSettings public enum KeymanVersionCheckError: Error { case inputMethodNotFound @@ -25,6 +24,13 @@ public enum KeymanInvocationError: Error { public let kAccessibilityPermissionGrantedMessage = "granted" public class InputMethodUtil { + public static let keymanBundleId = "keyman.inputmethod.Keyman" + public static let groupId = "group.com.keyman" + + public static let keymanDomain = "keyman.com" + public static let keymanHelpDomain = "help.keyman.com" + public static let keymanApiDomain = "api.keyman.com" + public let keymanInputMethodApplicationName = "Keyman.app" // only initialized after message is received from input method @@ -63,14 +69,14 @@ public class InputMethodUtil { * Returns true if the Keyman input method is running */ public func isKeymanInputMethodRunning() -> Bool { - return self.isApplicationRunning(bundleId: KeymanPaths.keymanBundleId) + return self.isApplicationRunning(bundleId: InputMethodUtil.keymanBundleId) } /** * returns true if the specified bundleId is enabled */ public func isKeymanInputMethodEnabled() -> Bool { - return self.isInputMethodEnabled(bundleId: KeymanPaths.keymanBundleId) + return self.isInputMethodEnabled(bundleId: InputMethodUtil.keymanBundleId) } /** @@ -78,40 +84,40 @@ public class InputMethodUtil { * a newly installed input method must be registered before enabling */ public func registerKeymanInputMethod() -> Bool { - return self.registerInputMethod(bundleId: KeymanPaths.keymanBundleId) + return self.registerInputMethod(bundleId: InputMethodUtil.keymanBundleId) } /** * attempts to enable the Keyman input method and returns true if successful */ public func enableKeymanInputMethod() -> Bool { - return self.enableInputMethod(bundleId: KeymanPaths.keymanBundleId) + return self.enableInputMethod(bundleId: InputMethodUtil.keymanBundleId) } /** * attempts to select the Keyman input method and returns true if successful */ public func selectKeymanInputMethod() -> Bool { - return self.selectInputSource(inputSourceId: KeymanPaths.keymanBundleId) + return self.selectInputSource(inputSourceId: InputMethodUtil.keymanBundleId) } /** * attempts to disable the Keyman input method and returns true if successful */ public func disableKeymanInputMethod() -> Bool { - return self.disableInputMethod(bundleId: KeymanPaths.keymanBundleId) + return self.disableInputMethod(bundleId: InputMethodUtil.keymanBundleId) } /** * Kill Keyman -- only permitted when running oustide sandbox */ public func killKeymanInputMethod() -> Bool { - return killApplication(bundleId: KeymanPaths.keymanBundleId) + return killApplication(bundleId: InputMethodUtil.keymanBundleId) } - // MAC-CONFIG_TODO: deleting the app files with default security settings, need some other approach to uninstall /** * uninstalls the Keyman input method + * note: not useful to expose to users as default security systems prevent us from deleting the app */ public func uninstallKeyman() { _ = self.killKeymanInputMethod() @@ -160,12 +166,12 @@ public class InputMethodUtil { } } - func invokeKeymanInputMethodMigration() -> Bool { + public func invokeKeymanInputMethodMigration() -> Bool { print("invokeKeymanInputMethodMigration()") return self.invokeKeymanInputMethodAsSubProcess(argument: kMigrateCommand) == 0 } - func invokeKeymanInputMethodRequestAccess() -> Bool { + public func invokeKeymanInputMethodRequestAccess() -> Bool { var success = false do { print("invokeKeymanInputMethodRequestAccess()") @@ -215,7 +221,7 @@ public class InputMethodUtil { var currentEnv = ProcessInfo.processInfo.environment print("current env: \(String(describing: currentEnv))") - currentEnv["__CFBundleIdentifier"] = KeymanPaths.keymanBundleId // set bundle ID to that of the Keyman input method + currentEnv["__CFBundleIdentifier"] = InputMethodUtil.keymanBundleId // set bundle ID to that of the Keyman input method process.environment = currentEnv do { @@ -246,7 +252,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) + } } } } @@ -255,7 +264,7 @@ public class InputMethodUtil { * Calls Keyman input method to check whether it has accessibility permission granted. * Receives response as distributed notification named `accessibilityStateResponse` */ - func doAsyncAccessibilityCheck() { + public func doAsyncAccessibilityCheck() { do { try self.invokeKeymanInputMethodCheckAccess() } catch { @@ -292,7 +301,7 @@ public class InputMethodUtil { * returns the TISInputSource for the specified bundleId */ func getKeymanInputSource() -> TISInputSource? { - return self.getInputSource(bundleId: KeymanPaths.keymanBundleId) + return self.getInputSource(bundleId: InputMethodUtil.keymanBundleId) } /** diff --git a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift index 38408b42f68..505fa16bf5a 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(directoryName: String) -> URL func cleanupTempDirectory() } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 78eb0ab249a..eab49aea78e 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -25,6 +25,22 @@ import Foundation import Combine import ZIPFoundation +public enum InstallPackageError: LocalizedError { + case packageInstallationAlreadyInProgress + case fontCopyError + case fontRegistrationError + case internalError // due to invalid state, should never occur + + public var errorDescription: String? { + switch self { + case .packageInstallationAlreadyInProgress: return "A package installation is already in progress." + case .fontCopyError: return "There was an error copying the font." + case .fontRegistrationError: return "There was an error registering the font." + case .internalError: return "An internal error occurred." + } + } +} + // distributed notifications public extension Notification.Name { // sent from input method, received by InstallationCheck @@ -35,15 +51,31 @@ public extension Notification.Name { // 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") + // sent from InstallationContainer to SettingsContainer + static let dataMigrated = Notification.Name("com.keyman.data.migrated") } -public enum SettingsError: Error { - case unknownPackage +// 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." + } + } } +package let kmpFileExtension = ".kmp" +package let kmpFileExtensionWithoutDot = "kmp" + @MainActor // run on the main actor since data is published directly to the UI public class SettingsContainer : ObservableObject { // installed packages are loaded from disk, each package may contain one or more keyboard @@ -62,8 +94,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 + fileprivate var packageInstall: PackageInstallHelper? = nil fileprivate let packageRepository: PackageRepo fileprivate let defaultsRepository: DefaultsRepo @@ -72,6 +104,15 @@ public class SettingsContainer : ObservableObject { // not indicated in the Config app but this could change fileprivate var selectedKeyboard: String + private let keyboardSearchPrefix = "https://keyman.com/go/macos/14.0/download-keyboards/?version=" + + public var keyboardSearchUrl: URL { + let currentVersion = ConfigAppUtil.configAppVersion() + let searchString: String = keyboardSearchPrefix + currentVersion + let searchUrl = URL(string: searchString)! + return searchUrl + } + public init() { // initialize arrays before loading packages self.singleKeyboardPackages = [] @@ -90,7 +131,7 @@ public class SettingsContainer : ObservableObject { // create the settings repository, gaining access to the app group UserDefaults do { - try self.defaultsRepository = DefaultsRepository(suiteName: KeymanPaths.groupId) + try self.defaultsRepository = DefaultsRepository(suiteName: InputMethodUtil.groupId) print("Found defaults group container") } catch UserDefaultsError.unknownSuite { fatalError("Defaults group container not found.") @@ -109,7 +150,6 @@ public class SettingsContainer : ObservableObject { // this mainly consists of marking them as enabled or not self.applyUserDefaultsToInstalledPackages() - // use NotificationCenter to receive keyboard installation notifications self.registerObservers() } @@ -125,42 +165,24 @@ public class SettingsContainer : ObservableObject { self.multiKeyboardPackages = [] self.installedPackages = [] } - + /** - * register observers to handle notifications + * register observers to receive */ 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 - ) + NotificationCenter.default.addObserver(self, selector: #selector(self.reloadPackages), name: .dataMigrated, object: nil) } /** - * called for `newPackageInstalled` notification + * Refresh the packages array and apply settings. + * This should only be needed after a migration and could be removed if + * the migration were made earlier. */ - @objc func newPackageInstalled(_ notification: Notification) { - print("newPackageInstalled notification received") - self.addInstalledPackage() - self.packageDownload = nil + @objc public func reloadPackages() { + self.loadPackages() + self.applyUserDefaultsToInstalledPackages() } - /** - * 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 +204,26 @@ 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() - } - - self.packageDownload = nil - } - - /** - * for debugging: prints UserDefaults values - */ - public func logUserDefaults() { - self.defaultsRepository.logDefaults() - } + public func userCanceledPackageInstallation() { + print("user cancelled package installation") + self.packageInstall?.cleanupFailedInstallation() - /** - * 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 + self.packageInstall = nil } /** - * 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. + * Called when user chooses to cancel downgrade of package */ - 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 - } + public func packageInstallationFailed() { + print("packageInstallationFailed") + self.packageInstall?.cleanupFailedInstallation() - /** - * 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 - */ - func addInstalledPackage() { - if let package = self.packageDownload?.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.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") - } - } - } - /** * for each enabled keyboard in the package being installed, add it to the enabled keyboards set and save it in the UserDefaults */ @@ -308,23 +253,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 { @@ -343,7 +275,7 @@ public class SettingsContainer : ObservableObject { self.packageRepository.deletePackage(package: package) // remove package from installed packages list - if let index = self.installedPackages.firstIndex(where: { $0.packageName == package.packageName }) { + if let index = self.installedPackages.firstIndex(where: { $0.id == package.id }) { self.installedPackages.remove(at: index) } @@ -395,14 +327,7 @@ public class SettingsContainer : ObservableObject { * read the Keyman packages from the group container directory and store in the installedPackages array */ func loadPackages() { - var packagesArray = nil as [KeymanPackage]? - - // read keyboards from disk - packagesArray = self.packageRepository.loadAllPackages() - - if let persistedPackages = packagesArray { - self.installedPackages = persistedPackages - } + self.installedPackages = self.packageRepository.loadAllPackages() } /** @@ -470,4 +395,161 @@ public class SettingsContainer : ObservableObject { } } } + + // MARK: Package Download and Installation + + /** + * check whether an installation is already in progress + */ + public func isInstallationInProgress() -> 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.isInstallationInProgress() else { + throw InstallPackageError.packageInstallationAlreadyInProgress + } + + 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? { + return PackageInstallHelper(filename: kmpFilename, 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?.prepareToInstall(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 { + // find the existing package with the same name in the installedPackages array and replace it + // (we cannot use the id for this search, as the ids are unique) + 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.isInstallationInProgress() else { + throw InstallPackageError.packageInstallationAlreadyInProgress + } + + // 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?.cleanupFailedInstallation() + 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 installed with the specified name. + */ + func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { + return PackageInstallHelper(filename: kmpFilename, 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..3387ff3ac33 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 @@ -228,7 +261,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { * build the URL where the keyboard can be installed from the Keyman website */ static func buildSharePackageUrl(packageUrl: URL) -> URL? { - return URL(string: "https://\(KeymanPaths.keymanDomain)/go/keyboard/\(packageUrl.lastPathComponent)/share") + return URL(string: "https://\(InputMethodUtil.keymanDomain)/go/keyboard/\(packageUrl.lastPathComponent)/share") } /** diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift index e9d4013dabf..f4e2dfaa095 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]? @@ -29,14 +29,10 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return info.version.description } var copyright: String? { - if let copy = info.copyright?.description { - return copy - } else { - return nil - } + return info.copyright?.description } var readmeFilename: String? { - if let filename = options?.readmeFile { + if let filename = options.readmeFile { return filename } if let fileArray = self.files { @@ -47,9 +43,8 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return nil } var helpFilename: String? { - if let filename = options?.welcomeFile { - return filename - } + if let filename = options.welcomeFile { return filename } + if let fileArray = self.files { if fileArray.contains(where: { $0.name == defaultHelpFilename }) { return defaultHelpFilename @@ -59,11 +54,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return nil } var graphicFilename: String? { - if let filename = options?.graphicFile { - return filename - } else { - return nil - } + return options.graphicFile } enum CodingKeys: String, CodingKey { @@ -79,8 +70,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 +139,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/KeymanPaths.swift b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift index 5244858eaa4..3cc1767b49a 100644 --- a/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift +++ b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift @@ -35,26 +35,38 @@ public enum KeymanPathError: Error { } public struct KeymanPaths { - // MAC-CONFIG-TODO: move to input method util? - static public let keymanBundleId = "keyman.inputmethod.Keyman" - static public let configBundleId = "com.keyman.config" - static public let groupId = "group.com.keyman" - - static public let keymanDomain = "keyman.com" - static public let keymanHelpDomain = "help.keyman.com" - static public let keymanApiDomain = "api.keyman.com" - // keyman file extensions static public let keymanPackageFileExtension: String = "kmp" static private let preKeyman19PackagesDirectoryName = "Keyman-Keyboards" - static private let keymanSubdirectoryName = keymanBundleId + static private let keymanSubdirectoryName = InputMethodUtil.keymanBundleId + + static public var getFontsDirectory: URL { + // force unwrap is safe here because the system user domain library always exists + let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + let fontsDirectory = libraryURL.appendingPathComponent(fontsDirectoryName, isDirectory: true) + + // if for some reason it doesn't exist, create it + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: fontsDirectory.path) { + do { + try fileManager.createDirectory(at: fontsDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + print("error: could not create fonts directory: \(error.localizedDescription)") + } + } + + return fontsDirectory + } // keyman 19 directory names static private let containerPreferencesPartialPath = "Library/Preferences" static private let containerPackagesPartialPath = "Library/Application Support/Keyman-Packages" static private let containerTempPartialPath = "Library/Application Support/temp" + // system directory names + static private let fontsDirectoryName = "Fonts" + // keyman 17 and earlier let keyman17DocumentsDirectory: URL? let keyman17PackagesDirectory: URL? @@ -92,9 +104,9 @@ public struct KeymanPaths { self.keyman19ContainerDirectory = containerDir self.keyman19PreferencesDirectory = KeymanPaths.buildContainerPreferencesUrl(container: containerDir) - self.keyman19PackagesDirectory = KeymanPaths.buildKeyman19PackagesUrl(container: containerDir) self.keyman19TempDirectory = KeymanPaths.buildKeyman19TempUrl(container: containerDir) + //self.logPaths() } @@ -227,7 +239,7 @@ public struct KeymanPaths { * build the URL to the app group container */ private static func buildContainerUrl() -> URL? { - return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: KeymanPaths.groupId) + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: InputMethodUtil.groupId) } /** @@ -250,19 +262,4 @@ public struct KeymanPaths { private static func buildKeyman19TempUrl(container: URL) -> URL { return container.appendingPathComponent(KeymanPaths.containerTempPartialPath, isDirectory: true) } - - // MAC-CONFIG-TODO: remove - fileprivate func checkContainerUrl() -> Bool { - var containerValid = false - let sharedFileManager = FileManager.default - - /* a URL of the expected form is always returned, even if the app group is invalid, so verify access before using" */ - - if let containerUrl = sharedFileManager.containerURL(forSecurityApplicationGroupIdentifier: KeymanPaths.groupId) { - containerValid = true - print("containerUrl = \(containerUrl)") - } - - return containerValid - } } 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..eda824f9733 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -0,0 +1,383 @@ +/* + * 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 +import CoreText + +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 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 + + // following properties cannot be set until new package is unzipped and loaded + public private(set) var installPackageLocation: URL? // derived from new package name + 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, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { + self.packageRepository = packageRepo + self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) + + // unzip in a directory named the same as the kmp file minus .kmp extension + self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: filename.replacingOccurrences(of: kmpFileExtension, with: "")) + self.installedPackages = installedPackages + self.isDownload = isDownload + + // 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) + } + + /** + * Prepare for installation by unzipping and loading the package and determining where it should be installed. + * + */ + public func prepareToInstall(for kmpFileUrl: URL) throws { + print ("prepareToInstall \(kmpFileUrl)") + + do { + // 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 + let package = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) + self.packageToInstall = package + + // if there is an existing package of the same name, use its location as the place to install + if let existingPackage = findExistingPackage() { + self.packageToReplace = existingPackage + self.installPackageLocation = existingPackage.sourceDirectoryUrl + } else { + // if this is a new package, then use the same name as the temporary install directory + let directoryName = self.temporaryPackageLocation.lastPathComponent + self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(directoryName: directoryName) + } + + // now that we know what we are installing, determine the type of install + self.packageInstallationType = self.determinePackageInstallationType(newPackage: package) + } 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() + } + } + + /** + * 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(newPackage: KeymanPackage) -> PackageInstallationType { + var installationType: PackageInstallationType = .newPackage(newPackage.packageName) + + // if we are replacing an existing package, then determine what type of replacement this is + if let existingPackage = self.packageToReplace { + let newVersion = newPackage.packageVersion + let existingVersion = existingPackage.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 + } + + /** + * Install all fonts found in the package (files with an extension of .ttf or .otf). + * The package has been copied to the installation directory, so all fonts are located at `installPackageLocation` + * If any fonts fail to install, log the error but continue to the next font + */ + func installFontsForPackage() { + let fileManager = FileManager.default + + guard let installLocation = self.installPackageLocation else { + print("error: installPackageLocation not set when installing fonts") + return + } + + var fileUrls: [URL] = [] + do { + fileUrls = try fileManager.contentsOfDirectory( + at: installLocation, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) } + catch { + print("error: unable to get contents of directory at \(installLocation.path) with error: \(String(describing: error))") + } + + for fontUrl in fileUrls { + let ext = fontUrl.pathExtension.lowercased() + if ext == "ttf" || ext == "otf" { + // if a font fails to install, log error and continue + guard self.validateFont(at: fontUrl) else { + print("error: the font \(fontUrl.lastPathComponent) is not valid") + continue + } + do { + try self.copyFontToFontsDirectory(at: fontUrl) + try self.registerFontWithSystem(at: fontUrl) + } catch { + print("error: the font \(fontUrl.lastPathComponent) could not be installed with error: \(String(describing: error))") + } + } + } + } + + /** + * Check to see whether the font appears to be valid before installing it. + */ + func validateFont(at url: URL) -> Bool { + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { + return false + } + return !descriptors.isEmpty + } + + /** + * Copy the font to the fonts directory and return the URL for its new location. + * If a font of the same name already exists, then remove it before copying the new one. + */ + func copyFontToFontsDirectory(at fontUrl: URL) throws { + let fontsDirectory = KeymanPaths.getFontsDirectory + let fontDestinationUrl = fontsDirectory.appendingPathComponent(fontUrl.lastPathComponent) + let fileManager = FileManager.default + + // remove the font from the fonts directory just in case it is an old one + if fileManager.fileExists(atPath: fontDestinationUrl.path) { + print("removed existing font: \(fontDestinationUrl.lastPathComponent)") + try? fileManager.removeItem(at: fontDestinationUrl) + } + + try fileManager.copyItem(at: fontUrl, to: fontDestinationUrl) + print("added font: \(fontDestinationUrl.lastPathComponent)") + } + + /** + * Register the font in the macOS font manager. + * The scope is specified as `CTFontManagerScope.user` which makes the font available to any app + * and causes it to appear in the macOS Font Book application. + */ + func registerFontWithSystem(at fontUrl: URL) throws { + let dispatchGroup = DispatchGroup() + var registrationError: Error? + + // pause current thread until background tasks are complete + dispatchGroup.enter() + + // CTFontManagerRegisterFontURLs returns void -- errors must be captured in the block + CTFontManagerRegisterFontURLs([fontUrl] as CFArray, .user, true) { (errors, done) -> Bool in + let errorArray = errors as? [CFError] ?? [] + + if !errorArray.isEmpty { + + for cfError in errorArray { + let errorCode = CFErrorGetCode(cfError) + + // code 105 = kCTFontManagerErrorAlreadyRegistered + // It is safe to ignore because the font is + if errorCode == 105 { + print("font \(fontUrl.lastPathComponent) is already registered.") + continue + } + + // if it's any other error, capture it to throw later + print("registerFontWithSystem failed for \(fontUrl.lastPathComponent), error: \(String(describing: cfError))") + registrationError = InstallPackageError.fontRegistrationError + } + + dispatchGroup.leave() + return false // stop registration execution + } + + if done { + dispatchGroup.leave() + } + return true // Continue processing + } + + // wait synchronously for CoreText to finish processing the font file + dispatchGroup.wait() + + // throw an error out to your installation pipeline if registration failed + if let error = registrationError { + throw error + } + } + + /** + * If a package of the same name exists, return it. + */ + func findExistingPackage() -> KeymanPackage? { + var existingPackage: KeymanPackage? = nil + + if let package = self.installedPackages.first(where: { $0.packageName == self.packageToInstall?.packageName }) { + existingPackage = package + } + return existingPackage + } + + /** + * Install the newly downloaded package (no existing package to replace) + */ + func installNewPackage() throws { + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + print("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + } + + try self.movePackageFromTemporaryToInstalled() + self.installFontsForPackage() + } + + /** + * 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() + self.installFontsForPackage() + } + + /** + * 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 { + if let installLocation = self.installPackageLocation { + try FileManager.default.removeItem(at: installLocation) + } + } + + /** + * Move the downloaded package into the keyman packages directory. + */ + func movePackageFromTemporaryToInstalled() throws { + if let installLocation = self.installPackageLocation { + try FileManager.default.moveItem(at: self.temporaryPackageLocation, to: installLocation) + + // Update the KeymanPackage object with its new location + if let package = self.packageToInstall { + package.sourceDirectoryUrl = installLocation + } + } + } + + /** + * 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..02e43251355 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() @@ -170,11 +160,12 @@ public class PackageRepository: PackageRepo { public func getUnzipDestinationUrl(for packageName: String) -> URL { 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 { - return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(packageName) + public func buildInstallationUrlForPackageName(directoryName: String) -> URL { + return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(directoryName) } /** @@ -186,7 +177,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/Sources/Util/ConfigLogger.swift b/mac/KeymanSettings/Sources/Util/ConfigLogger.swift index 30d3496cb02..f9322d6a75c 100644 --- a/mac/KeymanSettings/Sources/Util/ConfigLogger.swift +++ b/mac/KeymanSettings/Sources/Util/ConfigLogger.swift @@ -10,7 +10,7 @@ import OSLog class ConfigLogger { //static let shared = ConfigLogger() - fileprivate let subsystem = KeymanPaths.configBundleId + fileprivate let subsystem = ConfigAppUtil.configBundleId fileprivate let testCategory = "test" public let testLogger: Logger diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift index ed1697c2c80..3e90186a43a 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(directoryName: String) -> URL { return URL(fileURLWithPath: "") }