From 48b16488d47d2162feab0167c66c43590c4fcf1e Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 10 Aug 2026 16:33:09 -0400 Subject: [PATCH 01/23] feat(mac): drag and drop .kmp with debug window --- mac/Config/Config/ConfigDebugView.swift | 27 ++++++++++- mac/Config/Installation/InputMethodUtil.swift | 2 +- .../KeymanSettings/SettingsContainer.swift | 47 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index c8d735f261a..5fa12ef38c3 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -12,6 +12,7 @@ import KeymanSettings struct ConfigDebugView: View { @EnvironmentObject var settings: SettingsContainer @State private var isShowingSheet = false + @State private var isHovering = false var body: some View { VStack { @@ -41,7 +42,31 @@ struct ConfigDebugView: View { .frame(width: 700, height: 500) } - + VStack { + Text(settings.dragStatusMessage) + .font(.system(.body, design: .monospaced)) + .multilineTextAlignment(.center) + .padding() + .frame(width: 350, height: 180) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isHovering ? Color.accentColor : Color.gray, lineWidth: 2) + ) + // Accept URL drops + .dropDestination(for: URL.self) { urls, _ in + guard let archiveURL = urls.first, urls.count == 1 else { + settings.dragStatusMessage = "Drop exactly one file." + return false + } + return settings.processDraggedKmpFile(from: archiveURL) + } isTargeted: { hovering in + isHovering = hovering + } + } + .padding() + ScrollView { VStack(alignment: .leading, spacing: 6) { ForEach(Array(settings.singleKeyboardPackages.enumerated()), id: \.offset) { index, package in diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/Config/Installation/InputMethodUtil.swift index ab8f0588e2d..273868a4fb1 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/Config/Installation/InputMethodUtil.swift @@ -246,7 +246,7 @@ 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)") } } } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 78eb0ab249a..fd0c11e8a06 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -61,6 +61,7 @@ public class SettingsContainer : ObservableObject { // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] + @Published public var dragStatusMessage = "Drag a single .kmp archive here" // when a new package is downloaded, it is tracked here public private(set) var packageDownload: PackageDownload? = nil @@ -229,6 +230,52 @@ public class SettingsContainer : ObservableObject { return false } + public func processDraggedKmpFile(from fileLocation: URL) -> Bool { + // if the file does not end with .kmp, reject it + guard fileLocation.pathExtension.lowercased() == "kmp" else { + dragStatusMessage = "Rejected: file must have a .kmp extension." + return false + } + + // if we cannot get a URL to the install location, then reject it (should never happen) + guard let destinationURL = getInstalledPackageUrl(for: fileLocation) else { + dragStatusMessage = "Unable to find application data directory." + return false + } + + // if a package of the same name is installed, reject it + guard !FileManager.default.fileExists(atPath: destinationURL.path) else { + dragStatusMessage = "The package \(destinationURL.lastPathComponent) is already installed." + return false + } + + do { + try self.installDraggedPackage(from: fileLocation, to: destinationURL) + dragStatusMessage = "The package \(destinationURL.lastPathComponent) was installed successfully." + return true + } catch { + dragStatusMessage = "The package \(destinationURL.lastPathComponent) failed to install." + return false + } + } + + func getInstalledPackageUrl(for draggedKmpFile: URL) -> URL? { + // package name is filename minus .kmp extension + let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: ".kmp", with: "") + return self.packageRepository.getInstallationUrlForPackageName(packageName: packageName) + } + + func installDraggedPackage(from draggedFileUrl: URL, to installPackageLocation: URL) throws { + try self.packageRepository.unzipKmpFile(at: draggedFileUrl, to: installPackageLocation) + + // load the unzipped package and get a reference to it + let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) + + // add the new package to the array and enable its keyboards + self.installedPackages.append(newPackage) + self.addEnabledKeyboards(for: newPackage) + } + /** * 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. From d9705067d8a0d18cdccff319f1e4509dd0478cab Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 11 Aug 2026 21:47:42 -0400 Subject: [PATCH 02/23] feat(mac): handled drag and drop errors display alert when drag and drop fails handle installation errors clean up un-installable .kmp files --- mac/Config/Config/ConfigDebugView.swift | 28 +++- .../Sources/KeymanSettings/PackageRepo.swift | 2 +- .../KeymanSettings/SettingsContainer.swift | 152 ++++++++++++------ .../Sources/Persistence/PackageDownload.swift | 2 +- .../Persistence/PackageRepository.swift | 46 +++--- .../Tests/KeymanSettingsTests/RepoStubs.swift | 2 +- 6 files changed, 150 insertions(+), 82 deletions(-) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index 5fa12ef38c3..43701a261e1 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -12,6 +12,9 @@ import KeymanSettings struct ConfigDebugView: View { @EnvironmentObject var settings: SettingsContainer @State private var isShowingSheet = false + @State private var dropError: DropKmpError? + @State private var isShowingDropKmpAlert = false + @State private var alertMessage = "" @State private var isHovering = false var body: some View { @@ -43,7 +46,7 @@ struct ConfigDebugView: View { } VStack { - Text(settings.dragStatusMessage) + Text(settings.dropStatusMessage) .font(.system(.body, design: .monospaced)) .multilineTextAlignment(.center) .padding() @@ -56,16 +59,33 @@ struct ConfigDebugView: View { ) // Accept URL drops .dropDestination(for: URL.self) { urls, _ in - guard let archiveURL = urls.first, urls.count == 1 else { - settings.dragStatusMessage = "Drop exactly one file." + // reject drop if it is more than one file + guard let droppedFileUrl = urls.first, urls.count == 1 else { + let error = DropKmpError.tooManyFiles + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false // the drop failed + } + do { + try settings.processDroppedKmpFile(at: droppedFileUrl) + return true // the drop was successful + } catch { + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true return false + } - return settings.processDraggedKmpFile(from: archiveURL) } isTargeted: { hovering in isHovering = hovering } } .padding() + // alert riggers automatically when $dropError becomes non-nil + .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } ScrollView { VStack(alignment: .leading, spacing: 6) { diff --git a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift index 38408b42f68..8b8a710b501 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift @@ -19,6 +19,6 @@ public protocol PackageRepo { func loadSinglePackage(packageUrl: URL) throws -> KeymanPackage func getDownloadUrl(for kmpFilename: String) -> URL func getUnzipDestinationUrl(for packageName: String) -> URL - func getInstallationUrlForPackageName(packageName: String) -> URL + func buildInstallationUrlForPackageName(packageName: String) -> URL func cleanupTempDirectory() } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index fd0c11e8a06..dd4e2925295 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -40,10 +40,26 @@ public extension Notification.Name { static let packageDowngradeRequested = Notification.Name("com.keyman.package.downgrade.requested") } -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." + } + } } +private let kmpFileExtension = ".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 @@ -61,7 +77,7 @@ public class SettingsContainer : ObservableObject { // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] - @Published public var dragStatusMessage = "Drag a single .kmp archive here" + @Published public var dropStatusMessage = "Drag a single .kmp archive here" // when a new package is downloaded, it is tracked here public private(set) var packageDownload: PackageDownload? = nil @@ -230,52 +246,6 @@ public class SettingsContainer : ObservableObject { return false } - public func processDraggedKmpFile(from fileLocation: URL) -> Bool { - // if the file does not end with .kmp, reject it - guard fileLocation.pathExtension.lowercased() == "kmp" else { - dragStatusMessage = "Rejected: file must have a .kmp extension." - return false - } - - // if we cannot get a URL to the install location, then reject it (should never happen) - guard let destinationURL = getInstalledPackageUrl(for: fileLocation) else { - dragStatusMessage = "Unable to find application data directory." - return false - } - - // if a package of the same name is installed, reject it - guard !FileManager.default.fileExists(atPath: destinationURL.path) else { - dragStatusMessage = "The package \(destinationURL.lastPathComponent) is already installed." - return false - } - - do { - try self.installDraggedPackage(from: fileLocation, to: destinationURL) - dragStatusMessage = "The package \(destinationURL.lastPathComponent) was installed successfully." - return true - } catch { - dragStatusMessage = "The package \(destinationURL.lastPathComponent) failed to install." - return false - } - } - - func getInstalledPackageUrl(for draggedKmpFile: URL) -> URL? { - // package name is filename minus .kmp extension - let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: ".kmp", with: "") - return self.packageRepository.getInstallationUrlForPackageName(packageName: packageName) - } - - func installDraggedPackage(from draggedFileUrl: URL, to installPackageLocation: URL) throws { - try self.packageRepository.unzipKmpFile(at: draggedFileUrl, to: installPackageLocation) - - // load the unzipped package and get a reference to it - let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) - - // add the new package to the array and enable its keyboards - self.installedPackages.append(newPackage) - self.addEnabledKeyboards(for: newPackage) - } - /** * 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. @@ -283,7 +253,7 @@ public class SettingsContainer : ObservableObject { */ public func preparePackageDownload(kmpFileName: String) -> URL? { // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: ".kmp", with: "") + let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) @@ -517,4 +487,86 @@ public class SettingsContainer : ObservableObject { } } } + + // MARK: Drag and drop Package Installation + + /** + * Attempt to install a package from a KMP file. Called when file is dropped on the Configuration view + */ + public func processDroppedKmpFile(at fileLocation: URL) throws { + // get the location where the package will be installed + let destinationURL = try self.validateDropUrlForInstallation(from: fileLocation) + + // install it + try self.installDroppedKmpFile(from: fileLocation, to: destinationURL) + } + + /** + * Validate the URL for the file we are dropping and return the installation location + * Throws errors if the URL does not end with .kmp or the same package is already installed + */ + func validateDropUrlForInstallation(from fileLocation: URL) throws -> URL { + // if the file does not end with .kmp, reject it + guard fileLocation.pathExtension.lowercased() == "kmp" else { + dropStatusMessage = "Rejected: file must have a .kmp extension." + throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) + } + + // if we cannot get a URL to the install location, then reject it (should never happen) + guard let destinationURL = buildInstalledPackageUrl(for: fileLocation) else { + dropStatusMessage = "Unable to find application data directory." + throw DropKmpError.installFailed(fileLocation.lastPathComponent) + } + + // if a package of the same name is installed, reject it + guard !FileManager.default.fileExists(atPath: destinationURL.path) else { + dropStatusMessage = "The package \(destinationURL.lastPathComponent) is already installed." + throw DropKmpError.alreadyInstalled(fileLocation.lastPathComponent) + } + + return destinationURL + } + + /** + * Build the URL where the package will be installed + */ + func buildInstalledPackageUrl(for draggedKmpFile: URL) -> URL? { + // package name is filename minus .kmp extension + let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: kmpFileExtension, with: "") + return self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) + } + + /** + * Install the package from the dropped kmp file + */ + func installDroppedKmpFile(from droppedFileUrl: URL, to installPackageLocation: URL) throws { + var newPackage: KeymanPackage? = nil + + try self.packageRepository.unzipKmpFile(at: droppedFileUrl, to: installPackageLocation) + + do { + // load the unzipped package and get a reference to it + newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) + } + catch { + // the package could not be loaded, so delete it from disk + do { + if FileManager.default.fileExists(atPath: installPackageLocation.path) { + try FileManager.default.removeItem(at: installPackageLocation) + print("removed uninstalled dropped kmp file at: \(installPackageLocation)") + } + } catch { + print("could not remove uninstalled dropped kmp file: \(error.localizedDescription)") + } + + // re-throw error to notify user of reason installation failed + throw error + } + + if let installedPackage = newPackage { + // add the newly installed package to the array and enable its keyboards + self.installedPackages.append(installedPackage) + self.addEnabledKeyboards(for: installedPackage) + } + } } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift index ebee061c7de..0fc6c992d38 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift @@ -26,7 +26,7 @@ public class PackageDownload { 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.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) self.installedPackages = installedPackages // cannot be initialized until after download when packageName of new package is known diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index e35541969aa..94f5e7b5689 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -10,7 +10,7 @@ import Foundation -enum LoadPackageError: Error { +public enum LoadPackageError: LocalizedError { case containsNoFiles case containsNoKeyboards case kmpJsonFileUnreadable @@ -19,33 +19,29 @@ enum LoadPackageError: Error { case missingKeyboardId case missingKeyboardVersion case missingKmxFile + + public var errorDescription: String? { + switch self { + 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." + } + } } -enum InstallPackageError: Error { +enum InstallPackageError: LocalizedError { case invalidUrl case unzipError -} - -// 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." } } } @@ -171,9 +167,9 @@ public class PackageRepository: PackageRepo { return self.pathUtil.keyman19TempDirectory.appendingPathComponent(packageName) } /** - * get the url to where the specified package should be installed + * build the URL where the specified package will be installed */ - public func getInstallationUrlForPackageName(packageName: String) -> URL { + public func buildInstallationUrlForPackageName(packageName: String) -> URL { return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(packageName) } diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift index ed1697c2c80..28ee3c4f01d 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift @@ -94,7 +94,7 @@ class PackageRepoStub: PackageRepo { return URL(fileURLWithPath: "") } - func getInstallationUrlForPackageName(packageName: String) -> URL { + func buildInstallationUrlForPackageName(packageName: String) -> URL { return URL(fileURLWithPath: "") } From 8276645c2f9928f1d00a88115c13394298c356db Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 12 Aug 2026 09:03:18 -0400 Subject: [PATCH 03/23] feat(mac): added function to clean up package on failed drop install --- mac/Config/Config/ConfigDebugView.swift | 4 +- .../KeymanSettings/SettingsContainer.swift | 153 +++++++++--------- 2 files changed, 82 insertions(+), 75 deletions(-) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index 43701a261e1..8ecb81f24f0 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -12,6 +12,8 @@ import KeymanSettings struct ConfigDebugView: View { @EnvironmentObject var settings: SettingsContainer @State private var isShowingSheet = false + + // for drag and drop package installation @State private var dropError: DropKmpError? @State private var isShowingDropKmpAlert = false @State private var alertMessage = "" @@ -46,7 +48,7 @@ struct ConfigDebugView: View { } VStack { - Text(settings.dropStatusMessage) + Text("Drag a single .kmp archive here") .font(.system(.body, design: .monospaced)) .multilineTextAlignment(.center) .padding() diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index dd4e2925295..ebf80856c01 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -77,7 +77,6 @@ public class SettingsContainer : ObservableObject { // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] - @Published public var dropStatusMessage = "Drag a single .kmp archive here" // when a new package is downloaded, it is tracked here public private(set) var packageDownload: PackageDownload? = nil @@ -238,64 +237,6 @@ public class SettingsContainer : ObservableObject { self.defaultsRepository.clearDefaults() } - /** - * check whether a download is already in progress - */ - public func isDownloadInProgress() -> Bool { - // MAC-CONFIG-TODO: add logic, this does not actually prevent downloads when hard-coded to true - return false - } - - /** - * Called by the WebView Coordinator before initiating a package download. - * Creates a PackageDownload instance to manage the state of the package being downloaded with the specified name. - * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. - */ - public func preparePackageDownload(kmpFileName: String) -> URL? { - // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") - - let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) - - self.packageDownload = packageDownload - return packageDownload.temporaryKmpFileLocation - } - - /** - * Called by the WebView Coordinator after the download is complete. - * Delegates to the PackageDownload instance to decide whether the package should be installed. - */ - public func packageDownloadComplete(kmpFileUrl: URL) { - print ("packageDownloadComplete \(kmpFileUrl)") - - self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) - } - - /** - * 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 */ @@ -488,13 +429,73 @@ public class SettingsContainer : ObservableObject { } } + // MARK: Package Download and Installation + + /** + * check whether a download is already in progress + */ + public func isDownloadInProgress() -> Bool { + // MAC-CONFIG-TODO: add logic, this does not actually prevent downloads when hard-coded to true + return false + } + + /** + * Called by the WebView Coordinator before initiating a package download. + * Creates a PackageDownload instance to manage the state of the package being downloaded with the specified name. + * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. + */ + public func preparePackageDownload(kmpFileName: String) -> URL? { + // package name is filename minus .kmp extension + let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") + + let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) + + self.packageDownload = packageDownload + return packageDownload.temporaryKmpFileLocation + } + + /** + * Called by the WebView Coordinator after the download is complete. + * Delegates to the PackageDownload instance to decide whether the package should be installed. + */ + public func packageDownloadComplete(kmpFileUrl: URL) { + print ("packageDownloadComplete \(kmpFileUrl)") + + self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) + } + + /** + * 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") + } + } + } + // MARK: Drag and drop Package Installation /** * Attempt to install a package from a KMP file. Called when file is dropped on the Configuration view */ public func processDroppedKmpFile(at fileLocation: URL) throws { - // get the location where the package will be installed + // validate URL and get the location where the package will be installed let destinationURL = try self.validateDropUrlForInstallation(from: fileLocation) // install it @@ -503,24 +504,21 @@ public class SettingsContainer : ObservableObject { /** * Validate the URL for the file we are dropping and return the installation location - * Throws errors if the URL does not end with .kmp or the same package is already installed + * Throws errors if the URL does not end with .kmp or a package of the same name is already installed */ func validateDropUrlForInstallation(from fileLocation: URL) throws -> URL { // if the file does not end with .kmp, reject it guard fileLocation.pathExtension.lowercased() == "kmp" else { - dropStatusMessage = "Rejected: file must have a .kmp extension." throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) } // if we cannot get a URL to the install location, then reject it (should never happen) guard let destinationURL = buildInstalledPackageUrl(for: fileLocation) else { - dropStatusMessage = "Unable to find application data directory." throw DropKmpError.installFailed(fileLocation.lastPathComponent) } // if a package of the same name is installed, reject it guard !FileManager.default.fileExists(atPath: destinationURL.path) else { - dropStatusMessage = "The package \(destinationURL.lastPathComponent) is already installed." throw DropKmpError.alreadyInstalled(fileLocation.lastPathComponent) } @@ -537,7 +535,7 @@ public class SettingsContainer : ObservableObject { } /** - * Install the package from the dropped kmp file + * Install the package from the dropped kmp file at the specified location */ func installDroppedKmpFile(from droppedFileUrl: URL, to installPackageLocation: URL) throws { var newPackage: KeymanPackage? = nil @@ -549,15 +547,8 @@ public class SettingsContainer : ObservableObject { newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) } catch { - // the package could not be loaded, so delete it from disk - do { - if FileManager.default.fileExists(atPath: installPackageLocation.path) { - try FileManager.default.removeItem(at: installPackageLocation) - print("removed uninstalled dropped kmp file at: \(installPackageLocation)") - } - } catch { - print("could not remove uninstalled dropped kmp file: \(error.localizedDescription)") - } + // the package could not be loaded, so remove it from the installation directory + self.removeFailedInstallation(at: installPackageLocation) // re-throw error to notify user of reason installation failed throw error @@ -569,4 +560,18 @@ public class SettingsContainer : ObservableObject { self.addEnabledKeyboards(for: installedPackage) } } + + /** + * Clean up after a failed drag and drop installation + */ + func removeFailedInstallation(at installPackageLocation: URL) { + do { + if FileManager.default.fileExists(atPath: installPackageLocation.path) { + try FileManager.default.removeItem(at: installPackageLocation) + print("removed uninstalled dropped kmp file at: \(installPackageLocation)") + } + } catch { + print("could not remove uninstalled dropped kmp file: \(error.localizedDescription)") + } + } } From 652c6f75c7f21a5fd229a1d1f10afa61294dd50b Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 10 Aug 2026 16:33:09 -0400 Subject: [PATCH 04/23] feat(mac): drag and drop .kmp with debug window --- .../KeymanSettings/SettingsContainer.swift | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index ebf80856c01..44eb3e7a37b 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -77,6 +77,7 @@ public class SettingsContainer : ObservableObject { // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] + @Published public var dragStatusMessage = "Drag a single .kmp archive here" // when a new package is downloaded, it is tracked here public private(set) var packageDownload: PackageDownload? = nil @@ -237,6 +238,110 @@ public class SettingsContainer : ObservableObject { 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 + } + + public func processDraggedKmpFile(from fileLocation: URL) -> Bool { + // if the file does not end with .kmp, reject it + guard fileLocation.pathExtension.lowercased() == "kmp" else { + dragStatusMessage = "Rejected: file must have a .kmp extension." + return false + } + + // if we cannot get a URL to the install location, then reject it (should never happen) + guard let destinationURL = getInstalledPackageUrl(for: fileLocation) else { + dragStatusMessage = "Unable to find application data directory." + return false + } + + // if a package of the same name is installed, reject it + guard !FileManager.default.fileExists(atPath: destinationURL.path) else { + dragStatusMessage = "The package \(destinationURL.lastPathComponent) is already installed." + return false + } + + do { + try self.installDraggedPackage(from: fileLocation, to: destinationURL) + dragStatusMessage = "The package \(destinationURL.lastPathComponent) was installed successfully." + return true + } catch { + dragStatusMessage = "The package \(destinationURL.lastPathComponent) failed to install." + return false + } + } + + func getInstalledPackageUrl(for draggedKmpFile: URL) -> URL? { + // package name is filename minus .kmp extension + let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: ".kmp", with: "") + return self.packageRepository.getInstallationUrlForPackageName(packageName: packageName) + } + + func installDraggedPackage(from draggedFileUrl: URL, to installPackageLocation: URL) throws { + try self.packageRepository.unzipKmpFile(at: draggedFileUrl, to: installPackageLocation) + + // load the unzipped package and get a reference to it + let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) + + // add the new package to the array and enable its keyboards + self.installedPackages.append(newPackage) + self.addEnabledKeyboards(for: newPackage) + } + + /** + * Called by the WebView Coordinator before initiating a package download. + * Creates a PackageDownload instance to manage the state of the package being downloaded with the specified name. + * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. + */ + public func preparePackageDownload(kmpFileName: String) -> URL? { + // package name is filename minus .kmp extension + let packageName = kmpFileName.replacingOccurrences(of: ".kmp", with: "") + + let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) + + self.packageDownload = packageDownload + return packageDownload.temporaryKmpFileLocation + } + + /** + * Called by the WebView Coordinator after the download is complete. + * Delegates to the PackageDownload instance to decide whether the package should be installed. + */ + public func packageDownloadComplete(kmpFileUrl: URL) { + print ("packageDownloadComplete \(kmpFileUrl)") + + self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) + } + + /** + * 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 */ From 6a996aefd4f106db02e002522c2cd98e6a6dfb3e Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 11 Aug 2026 21:47:42 -0400 Subject: [PATCH 05/23] feat(mac): handled drag and drop errors display alert when drag and drop fails handle installation errors clean up un-installable .kmp files --- .../KeymanSettings/SettingsContainer.swift | 50 +------------------ 1 file changed, 2 insertions(+), 48 deletions(-) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 44eb3e7a37b..8af211d048b 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -77,7 +77,7 @@ public class SettingsContainer : ObservableObject { // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] - @Published public var dragStatusMessage = "Drag a single .kmp archive here" + @Published public var dropStatusMessage = "Drag a single .kmp archive here" // when a new package is downloaded, it is tracked here public private(set) var packageDownload: PackageDownload? = nil @@ -246,52 +246,6 @@ public class SettingsContainer : ObservableObject { return false } - public func processDraggedKmpFile(from fileLocation: URL) -> Bool { - // if the file does not end with .kmp, reject it - guard fileLocation.pathExtension.lowercased() == "kmp" else { - dragStatusMessage = "Rejected: file must have a .kmp extension." - return false - } - - // if we cannot get a URL to the install location, then reject it (should never happen) - guard let destinationURL = getInstalledPackageUrl(for: fileLocation) else { - dragStatusMessage = "Unable to find application data directory." - return false - } - - // if a package of the same name is installed, reject it - guard !FileManager.default.fileExists(atPath: destinationURL.path) else { - dragStatusMessage = "The package \(destinationURL.lastPathComponent) is already installed." - return false - } - - do { - try self.installDraggedPackage(from: fileLocation, to: destinationURL) - dragStatusMessage = "The package \(destinationURL.lastPathComponent) was installed successfully." - return true - } catch { - dragStatusMessage = "The package \(destinationURL.lastPathComponent) failed to install." - return false - } - } - - func getInstalledPackageUrl(for draggedKmpFile: URL) -> URL? { - // package name is filename minus .kmp extension - let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: ".kmp", with: "") - return self.packageRepository.getInstallationUrlForPackageName(packageName: packageName) - } - - func installDraggedPackage(from draggedFileUrl: URL, to installPackageLocation: URL) throws { - try self.packageRepository.unzipKmpFile(at: draggedFileUrl, to: installPackageLocation) - - // load the unzipped package and get a reference to it - let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) - - // add the new package to the array and enable its keyboards - self.installedPackages.append(newPackage) - self.addEnabledKeyboards(for: newPackage) - } - /** * 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. @@ -299,7 +253,7 @@ public class SettingsContainer : ObservableObject { */ public func preparePackageDownload(kmpFileName: String) -> URL? { // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: ".kmp", with: "") + let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) From e7c35394ce71a24390de2c5aeb88be981b9d1bfe Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 12 Aug 2026 09:03:18 -0400 Subject: [PATCH 06/23] feat(mac): added function to clean up package on failed drop install --- .../KeymanSettings/SettingsContainer.swift | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 8af211d048b..ebf80856c01 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -77,7 +77,6 @@ public class SettingsContainer : ObservableObject { // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) @Published public private(set) var singleKeyboardPackages: [KeymanPackage] @Published public private(set) var multiKeyboardPackages: [KeymanPackage] - @Published public var dropStatusMessage = "Drag a single .kmp archive here" // when a new package is downloaded, it is tracked here public private(set) var packageDownload: PackageDownload? = nil @@ -238,64 +237,6 @@ public class SettingsContainer : ObservableObject { self.defaultsRepository.clearDefaults() } - /** - * check whether a download is already in progress - */ - public func isDownloadInProgress() -> Bool { - // MAC-CONFIG-TODO: add logic, this does not actually prevent downloads when hard-coded to true - return false - } - - /** - * Called by the WebView Coordinator before initiating a package download. - * Creates a PackageDownload instance to manage the state of the package being downloaded with the specified name. - * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. - */ - public func preparePackageDownload(kmpFileName: String) -> URL? { - // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") - - let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) - - self.packageDownload = packageDownload - return packageDownload.temporaryKmpFileLocation - } - - /** - * Called by the WebView Coordinator after the download is complete. - * Delegates to the PackageDownload instance to decide whether the package should be installed. - */ - public func packageDownloadComplete(kmpFileUrl: URL) { - print ("packageDownloadComplete \(kmpFileUrl)") - - self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) - } - - /** - * 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 */ From 5c5dd6fd4001ce8fb3f8407054f64c63329dc92e Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Sat, 15 Aug 2026 22:06:03 -0400 Subject: [PATCH 07/23] feat(mac): move drag and drop code to main config view also add animation for changes to package list --- mac/Config/Config/ConfigDebugView.swift | 2 +- mac/Config/Config/MainConfigView.swift | 38 ++++++++++++++++++++++++- mac/Config/Config/PackageRowView.swift | 6 ++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index 8ecb81f24f0..ae63e60c354 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -82,7 +82,7 @@ struct ConfigDebugView: View { } } .padding() - // alert riggers automatically when $dropError becomes non-nil + // alert triggers automatically when $dropError becomes non-nil .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { Button("OK", role: .cancel) { } } message: { diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 66f53c5aeb6..92d9946d49b 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -21,6 +21,12 @@ struct MainConfigView: View { @State private var selectedTab = 0 @State private var packageSelectedForHelpUrl: URL? = nil + // for drag and drop package installation + @State private var dropError: DropKmpError? + @State private var isShowingDropKmpAlert = false + @State private var alertMessage = "" + @State private var isHovering = false + /** * Assigns packageSelectedForHelpUrl the url argument and changes the selected tab to the help tab */ @@ -58,7 +64,37 @@ struct MainConfigView: View { showHelpTab(for: url) }) } .formStyle(.grouped) - + // highlight border with accent color when hovering over view + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.accentColor, lineWidth: 2).opacity(isHovering ? 1 : 0)) + .animation(.easeInOut(duration: 0.2), value: isHovering) + // accepts URL drops + .dropDestination(for: URL.self) { urls, _ in + // reject drop if it is more than one file + guard let droppedFileUrl = urls.first, urls.count == 1 else { + let error = DropKmpError.tooManyFiles + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false // the drop failed + } + do { + try settings.processDroppedKmpFile(at: droppedFileUrl) + return true // the drop was successful + } catch { + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false + + } + } isTargeted: { hovering in + isHovering = hovering + } + // alert triggers automatically when $dropError becomes non-nil + .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } + // the Spacer pushes the contents of the VStack to the top of the VStack Spacer() } diff --git a/mac/Config/Config/PackageRowView.swift b/mac/Config/Config/PackageRowView.swift index b607412636e..b4705151d1a 100644 --- a/mac/Config/Config/PackageRowView.swift +++ b/mac/Config/Config/PackageRowView.swift @@ -62,7 +62,7 @@ public struct PackageRowView: View { // if the package contains one keyboard, show the keyboard name, otherwise show the package name Text(isSingleKeyboardPackage ? keyboard.name: package.packageName) .font(.title) - + // see keyboard help button if let url = package.helpFileUrl { IconButtonView( @@ -84,8 +84,6 @@ public struct PackageRowView: View { .toggleStyle(.switch) .gridColumnAlignment(.leading) } - - } // if the package contains multiple keyboards shows an HStack with the keyboard name and toggle button for each keyboard in the package @@ -124,6 +122,8 @@ public struct PackageRowView: View { } } } + // animate changes in the package list + .animation(.easeInOut, value: packages) // binds the visibilty state to the alert builder .alert("Are you sure you want to delete the keyboard \"\(selectedPackage?.packageName ?? "")\"?", isPresented: $isShowingDeleteAlert, From a18c740d8aaa7c5ee708f711dffd33d1c49e4dad Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 18 Aug 2026 09:43:32 -0400 Subject: [PATCH 08/23] rename PackageDownload to PackageInstallHelper modifying the class to handle not just installation of packages being downloaded but also installation due to drag and drop or opening a .kmp from the file menu also added boolean to distinguish a download and install operation because that is the only case where we delete the original .kmp file that was downloaded to the temp directory --- mac/Config/Config/KeyboardSearchView.swift | 9 ++- .../KeymanSettings/SettingsContainer.swift | 69 ++++++++++++++++--- ...nload.swift => PackageInstallHelper.swift} | 39 +++++++---- .../Persistence/PackageRepository.swift | 22 ++---- 4 files changed, 98 insertions(+), 41 deletions(-) rename mac/KeymanSettings/Sources/Persistence/{PackageDownload.swift => PackageInstallHelper.swift} (85%) diff --git a/mac/Config/Config/KeyboardSearchView.swift b/mac/Config/Config/KeyboardSearchView.swift index a9ba9283d00..de7925b1083 100644 --- a/mac/Config/Config/KeyboardSearchView.swift +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -148,14 +148,17 @@ struct KeyboardSearchView: NSViewRepresentable { if let downloadFileUrl { print("Download of \(downloadFileUrl.path()) was successful.") if let settings { - settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) + do { + try settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) + } catch { + // MAC-CONFIG-TODO: communicate failed install to user + } } } } - - // MAC-CONFIG-TODO: remove package if it already exists func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + // MAC-CONFIG-TODO: communicate failed install to user print("Download failed with error: \(error.localizedDescription)") } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index ebf80856c01..22865ae15e0 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -25,6 +25,16 @@ import Foundation import Combine import ZIPFoundation +public enum InstallPackageError: LocalizedError { + case downloadInProgress + + public var errorDescription: String? { + switch self { + case .downloadInProgress: return "A download is already in progress." + } + } +} + // distributed notifications public extension Notification.Name { // sent from input method, received by InstallationCheck @@ -59,6 +69,7 @@ public enum DropKmpError: LocalizedError { } private let kmpFileExtension = ".kmp" +private let kmpFileExtensionWithoutDot = "kmp" @MainActor // run on the main actor since data is published directly to the UI public class SettingsContainer : ObservableObject { @@ -79,7 +90,7 @@ public class SettingsContainer : ObservableObject { @Published public private(set) var multiKeyboardPackages: [KeymanPackage] // when a new package is downloaded, it is tracked here - public private(set) var packageDownload: PackageDownload? = nil + public private(set) var packageDownload: PackageInstallHelper? = nil fileprivate let packageRepository: PackageRepo fileprivate let defaultsRepository: DefaultsRepo @@ -436,19 +447,19 @@ public class SettingsContainer : ObservableObject { */ public func isDownloadInProgress() -> Bool { // MAC-CONFIG-TODO: add logic, this does not actually prevent downloads when hard-coded to true - return false + return self.packageDownload != 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. + * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. */ public func preparePackageDownload(kmpFileName: String) -> URL? { // package name is filename minus .kmp extension let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") - let packageDownload = PackageDownload(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages) + let packageDownload = PackageInstallHelper(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) self.packageDownload = packageDownload return packageDownload.temporaryKmpFileLocation @@ -456,12 +467,12 @@ public class SettingsContainer : ObservableObject { /** * Called by the WebView Coordinator after the download is complete. - * Delegates to the PackageDownload instance to decide whether the package should be installed. + * Delegates to the PackageInstallHelper instance to decide whether the package should be installed. */ - public func packageDownloadComplete(kmpFileUrl: URL) { + public func packageDownloadComplete(kmpFileUrl: URL) throws { print ("packageDownloadComplete \(kmpFileUrl)") - self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) + try self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) } /** @@ -495,11 +506,47 @@ public class SettingsContainer : ObservableObject { * Attempt to install a package from a KMP file. Called when file is dropped on the Configuration view */ public func processDroppedKmpFile(at fileLocation: URL) throws { - // validate URL and get the location where the package will be installed - let destinationURL = try self.validateDropUrlForInstallation(from: fileLocation) + guard !self.isDownloadInProgress() else { + throw InstallPackageError.downloadInProgress + } + // validate URL and get the location where the package will be installed + try self.validateDroppedFile(from: fileLocation) // install it - try self.installDroppedKmpFile(from: fileLocation, to: destinationURL) +// try self.installDroppedKmpFile(from: fileLocation, to: destinationURL) + + let droppedFilename = fileLocation.lastPathComponent + if let packageDownload = self.preparePackageDrop(kmpFileName: droppedFilename) { + self.packageDownload = packageDownload + do { + try packageDownload.prepareToInstall(for: fileLocation) + } catch { + // clear failed download + self.packageDownload = nil + throw error + } + } + } + + /** + * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. + */ + public func preparePackageDrop(kmpFileName: String) -> PackageInstallHelper? { + // package name is filename minus .kmp extension + let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") + + return PackageInstallHelper(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) + } + + /** + * Validate the URL for the file we are dropping + */ + func validateDroppedFile(from fileLocation: URL) throws { + // if the file does not end with .kmp, reject it + if fileLocation.pathExtension.lowercased() != kmpFileExtensionWithoutDot { + throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) + } } /** @@ -508,7 +555,7 @@ public class SettingsContainer : ObservableObject { */ func validateDropUrlForInstallation(from fileLocation: URL) throws -> URL { // if the file does not end with .kmp, reject it - guard fileLocation.pathExtension.lowercased() == "kmp" else { + guard fileLocation.pathExtension.lowercased() == kmpFileExtensionWithoutDot else { throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift similarity index 85% rename from mac/KeymanSettings/Sources/Persistence/PackageDownload.swift rename to mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 0fc6c992d38..06c4f9e3057 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -3,8 +3,8 @@ * * 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 + * 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. */ @@ -12,22 +12,24 @@ import Foundation @MainActor // run on the main actor as it is called from SettingsContainer -public class PackageDownload { +public class PackageInstallHelper { let temporaryKmpFileLocation: URL let temporaryPackageLocation: URL let installPackageLocation: URL let installedPackages: [KeymanPackage] // needed to check for existing package after download + let isDownload: Bool // if not download, then the package was opened from disk or dropped 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]) { + public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { self.packageRepository = packageRepo self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: packageName) self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) self.installedPackages = installedPackages + self.isDownload = isDownload // cannot be initialized until after download when packageName of new package is known self.packageToReplace = nil @@ -37,25 +39,32 @@ public class PackageDownload { } /** - * Indicates that a package has been downloaded and is ready to be unzipped and installed + * Indicates that a package has been downloaded and can be prepared for installation */ - public func packageDownloadComplete(for kmpFileUrl: URL) { + public func packageDownloadComplete(for kmpFileUrl: URL) throws { print ("packageDownloadComplete \(kmpFileUrl)") + try self.prepareToInstall(for: kmpFileUrl) + } + + /** + * Indicates that a package is ready to be unzipped and installed + */ + public func prepareToInstall(for kmpFileUrl: URL) throws { + print ("prepareToInstall \(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? + print ("package installation failed with error '\(error)' for \(kmpFileUrl)") + throw error } } /** - * Unzip the and load the downloaded package + * Unzip and load the downloaded package */ func unzipDownloadedPackage(for kmpFileUrl: URL) throws { try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) @@ -110,7 +119,13 @@ public class PackageDownload { */ func installNewPackage() throws { try self.movePackageFromTemporaryToInstalled() - try self.deleteDownloadedKmpFile() + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + print("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + } NotificationCenter.default.post(name: .newPackageInstalled, object: nil) } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index 94f5e7b5689..65f260f049e 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -11,6 +11,8 @@ import Foundation public enum LoadPackageError: LocalizedError { + case invalidUrl + case unzipError case containsNoFiles case containsNoKeyboards case kmpJsonFileUnreadable @@ -19,9 +21,11 @@ public enum LoadPackageError: LocalizedError { case missingKeyboardId case missingKeyboardVersion case missingKmxFile - + public var errorDescription: String? { switch self { + 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." @@ -34,18 +38,6 @@ public enum LoadPackageError: LocalizedError { } } -enum InstallPackageError: LocalizedError { - case invalidUrl - case unzipError - - public var errorDescription: String? { - switch self { - case .invalidUrl: return "The URL is not valid." - case .unzipError: return "The keyboard package could not be unzipped." - } - } -} - public class PackageRepository: PackageRepo { fileprivate let packageFileName = "kmp.json" fileprivate let pathUtil: KeymanPaths @@ -85,7 +77,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() @@ -182,7 +174,7 @@ public class PackageRepository: PackageRepo { print("Successfully unzipped the file!") } catch { print("Extraction failed: \(error.localizedDescription)") - throw InstallPackageError.unzipError + throw LoadPackageError.unzipError } } From f194a15c7bbab93ccb801b7d403d1b44d45bb66a Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 18 Aug 2026 17:36:20 -0400 Subject: [PATCH 09/23] feat(mac): enforce minimum keyman version for package --- .../Config/ConfigTests/ConfigTests.swift | 18 --------- .../Sources/Model/KeymanPackage.swift | 37 ++++++++++++++++++- .../Persistence/Data/PackageSource.swift | 18 ++++----- .../Persistence/PackageRepository.swift | 2 + 4 files changed, 46 insertions(+), 29 deletions(-) delete mode 100644 mac/Config/Config/ConfigTests/ConfigTests.swift 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/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index 2c9b7e5faf7..ede03331b4d 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -39,6 +39,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { public let fonts: [String] public let packageName: String public let packageVersion: String + public let minimumSupportedKeymanVersion: String public let author: String? public let websiteUrl: URL? @@ -89,6 +90,8 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { self.packageName = packageSource.info.name.description self.packageVersion = packageSource.info.version.description + self.minimumSupportedKeymanVersion = packageSource.system.fileVersion.description + self.author = packageSource.info.author?.description if let websiteUrlString = packageSource.info.website?.url { self.websiteUrl = URL(string: websiteUrlString) @@ -145,13 +148,16 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { /** * initializer that does not rely on package source -- provided to create unit test data */ - public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, author: String? = nil, website: URL? = nil, copyright: String? = nil, readmeFileName: String? = nil, helpFilename: String? = nil, graphicName: String? = nil) { + public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, + minimumKeymanVersion: String = "7.0.0", author: String? = nil, website: URL? = nil, copyright: String? = nil, + readmeFileName: String? = nil, helpFilename: String? = nil, graphicName: String? = nil) { self.id = UUID() self.sourceDirectoryUrl = sourceDirectoryUrl self.sharePackageUrl = sharePackageUrl self.keyboards = keyboards self.packageName = packageName self.packageVersion = packageVersion + self.minimumSupportedKeymanVersion = minimumKeymanVersion self.author = author self.websiteUrl = website self.copyright = copyright @@ -199,15 +205,42 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { } /** - * validate whether the package contain a kmx file for each of its keyboards + * validate the package + * 1. whether the package can be loaded by this version of Keyman + * 2. whether it contains a kmx file for each of its keyboards */ public func validate() throws { + try self.validateKeymanVersionForPackage() + // if validateKmxFile throws an error, then the loop is stopped and the error is propagated try self.keyboards.forEach { keyboard in try keyboard.validateKmxFile(in: self.sourceDirectoryUrl) } } + /** + * verify that the version of Keyman is equal to our newer than the + * minimum required Keyman version specifed by the package + */ + func validateKeymanVersionForPackage() throws { + let keymanVersion = ConfigAppUtil.configAppVersion() + let minimumKeymanVersion = self.minimumSupportedKeymanVersion + var meetsRequiredVersion: Bool = false + let comparisonResult = keymanVersion.compare(minimumKeymanVersion, options: .numeric) + + if comparisonResult == .orderedAscending { + // keyman version is too old + meetsRequiredVersion = false + print("for package '\(self.packageName)' keyman version \(keymanVersion) is older than required version \(minimumKeymanVersion)") + } else { + meetsRequiredVersion = true + } + + if (!meetsRequiredVersion) { + throw LoadPackageError.insufficientKeymanVersion(packageName: self.packageName, requiredKeymanVersion: minimumKeymanVersion, actualKeymanVersion: keymanVersion) + } + } + /** * create the image specified for the package * if none specified, load the default image diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift index e9d4013dabf..dc5702b343d 100644 --- a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift +++ b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift @@ -15,8 +15,8 @@ let defaultReadmeFilename = "readme.htm" public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { public var id = UUID() - let system: SystemInfo? - let options: Options? + let system: SystemInfo + let options: Options let info: Info let files: [PackageFile]? let keyboards: [KeyboardSource]? @@ -36,7 +36,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { } } var readmeFilename: String? { - if let filename = options?.readmeFile { + if let filename = options.readmeFile { return filename } if let fileArray = self.files { @@ -47,7 +47,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return nil } var helpFilename: String? { - if let filename = options?.welcomeFile { + if let filename = options.welcomeFile { return filename } if let fileArray = self.files { @@ -59,7 +59,7 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { return nil } var graphicFilename: String? { - if let filename = options?.graphicFile { + if let filename = options.graphicFile { return filename } else { return nil @@ -79,8 +79,8 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { self.info = try container.decode(Info.self, forKey: .info) self.keyboards = try container.decodeIfPresent([KeyboardSource].self, forKey: .keyboards) - self.system = try container.decodeIfPresent(SystemInfo.self, forKey: .system) - self.options = try container.decodeIfPresent(Options.self, forKey: .options) + self.system = try container.decode(SystemInfo.self, forKey: .system) + self.options = try container.decode(Options.self, forKey: .options) self.files = try container.decodeIfPresent([PackageFile].self, forKey: .files) if files?.isEmpty ?? true { @@ -147,8 +147,8 @@ struct Website: Decodable { } struct SystemInfo: Decodable { - let keymanDeveloperVersion: String? - let fileVersion: String? + let keymanDeveloperVersion: String + let fileVersion: String enum CodingKeys: String, CodingKey { case keymanDeveloperVersion diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index 65f260f049e..f5079680441 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -21,6 +21,7 @@ public enum LoadPackageError: LocalizedError { case missingKeyboardId case missingKeyboardVersion case missingKmxFile + case insufficientKeymanVersion(packageName: String, requiredKeymanVersion: String, actualKeymanVersion: String) public var errorDescription: String? { switch self { @@ -34,6 +35,7 @@ public enum LoadPackageError: LocalizedError { 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)." } } } From 52d41e79326b0be0aa5c2ef89c3aff0141336ce5 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 19 Aug 2026 22:02:23 -0400 Subject: [PATCH 10/23] feat(mac): used shared webview for help and readme added default and min size for config view --- mac/Config/Config.xcodeproj/project.pbxproj | 21 --- mac/Config/Config/ConfigApp.swift | 8 ++ mac/Config/Config/HelpView.swift | 34 ----- mac/Config/Config/MainConfigView.swift | 24 +++- mac/Config/Config/PackageContentWebView.swift | 72 ++++++++++ mac/Config/Config/PackageInstallView.swift | 48 +++++++ .../KeymanSettings/SettingsContainer.swift | 127 ++++++++++++------ .../Persistence/PackageInstallHelper.swift | 51 +++++-- 8 files changed, 267 insertions(+), 118 deletions(-) delete mode 100644 mac/Config/Config/HelpView.swift create mode 100644 mac/Config/Config/PackageContentWebView.swift create mode 100644 mac/Config/Config/PackageInstallView.swift 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/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index 49f8d2c6493..2841c45a436 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -18,6 +18,10 @@ struct ConfigApp: App { var body: some Scene { Window("Configuration", id: "main-config") { MainConfigView() + .frame( + minWidth: 600, maxWidth: 800, + minHeight: 400, maxHeight: .infinity + ) .environmentObject(settings) .task { if !installation.getHasDisplayedInstallationComplete() { @@ -27,6 +31,10 @@ struct ConfigApp: App { .onReceive(NotificationCenter.default.publisher(for: .installationRepairStarted)) { notification in openWindow(id: "install") } } + // the size of the window when first opened + // .defaultSize(width: 1024, height: 768) + .defaultSize(width: 800, height: 600) + .windowResizability(.contentSize) Window("Installation", id: "install") { MainInstallView() .environmentObject(installation) 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/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 92d9946d49b..0935923dbbd 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -22,7 +22,7 @@ struct MainConfigView: View { @State private var packageSelectedForHelpUrl: URL? = nil // for drag and drop package installation - @State private var dropError: DropKmpError? + @State private var packageInstallHelper: PackageInstallHelper? = nil @State private var isShowingDropKmpAlert = false @State private var alertMessage = "" @State private var isHovering = false @@ -77,23 +77,37 @@ struct MainConfigView: View { return false // the drop failed } do { - try settings.processDroppedKmpFile(at: droppedFileUrl) + packageInstallHelper = try settings.initiateKmpFileInstallation(at: droppedFileUrl) return true // the drop was successful } catch { self.alertMessage = error.localizedDescription self.isShowingDropKmpAlert = true return false - } } isTargeted: { hovering in isHovering = hovering } - // alert triggers automatically when $dropError becomes non-nil + // alert triggers automatically when $isShowingDropKmpAlert is true .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { Button("OK", role: .cancel) { } } message: { Text(alertMessage) } + .sheet(item: $packageInstallHelper) { helper in + PackageInstallView(installHelper: helper) { accepted in + if accepted { + print("Processing validated package: \(helper.packageName ?? "unknown package")") + do { + try helper.install() + } catch { + print("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error.localizedDescription)") + } + } else { + settings.userCanceledPackageInstallation() + } + packageInstallHelper = nil + } + } // the Spacer pushes the contents of the VStack to the top of the VStack Spacer() @@ -103,7 +117,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/PackageContentWebView.swift b/mac/Config/Config/PackageContentWebView.swift new file mode 100644 index 00000000000..3881ecdd5df --- /dev/null +++ b/mac/Config/Config/PackageContentWebView.swift @@ -0,0 +1,72 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-08-03 + * + * Webview used to display html content from within the Keyman package + * Any http links clicked are opened in a browser window + */ +import Foundation + +import SwiftUI +import WebKit +import KeymanSettings + +public struct PackageContentWebView: NSViewRepresentable { + let packageFileUrl: URL + + // create the AppKit view instance + public func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView() + + // Connect the delegate to catch link clicks + webView.navigationDelegate = context.coordinator + + return webView + } + + // update the view when SwiftUI state changes + public func updateNSView(_ nsView: WKWebView, context: Context) { + let request = URLRequest(url: packageFileUrl) + + // only load the request if it's not already loading/loaded to prevent infinite loops + if nsView.url != packageFileUrl { + if let fileUrl = request.url { + nsView.loadFileURL(fileUrl, allowingReadAccessTo: fileUrl.deletingLastPathComponent()) + } + } + } + + /** + * Coordinator acts as the WKNavigationDelegate + */ + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /** + * If a url links to the web rather than locally, open it in the default browser + */ + @MainActor + public class Coordinator: NSObject, WKNavigationDelegate { + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + + // check whether the user clicked a link + if navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url { + + // if it is an external link, intercept it and open it a browser window + if url.scheme == "http" || url.scheme == "https" { + NSWorkspace.shared.open(url) // opens default macOS browser + decisionHandler(.cancel) // blocks the webview from loading it + return + } + } + + // allow local navigation + decisionHandler(.allow) + } + } +} diff --git a/mac/Config/Config/PackageInstallView.swift b/mac/Config/Config/PackageInstallView.swift new file mode 100644 index 00000000000..f8d8d80ada4 --- /dev/null +++ b/mac/Config/Config/PackageInstallView.swift @@ -0,0 +1,48 @@ +/* + * 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 PackageInstallView: View { + let installHelper: PackageInstallHelper + let completion: (Bool) -> Void + + var body: some View { + VStack(spacing: 20) { + Text("Install Package?") + .font(.headline) + + Text("Ready to install: \(installHelper.packageName ?? "unknown package")") + .multilineTextAlignment(.center) + + if let readmeFileUrl = installHelper.packageToInstall?.readmeFileUrl { + PackageContentWebView(packageFileUrl: readmeFileUrl) + .padding() + } else { + Text("Read me not available.") + .font(.title) + } + + HStack { + Button("Cancel") { + completion(false) + } + .keyboardShortcut(.cancelAction) + + Button("Accept & Install") { + completion(true) + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 540, height: 400) + } +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 22865ae15e0..4fc30acbcd9 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -89,8 +89,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: PackageInstallHelper? = nil + // when a new package is being installed, it is tracked here + public private(set) var packageInstall: PackageInstallHelper? = nil fileprivate let packageRepository: PackageRepo fileprivate let defaultsRepository: DefaultsRepo @@ -176,7 +176,7 @@ public class SettingsContainer : ObservableObject { @objc func newPackageInstalled(_ notification: Notification) { print("newPackageInstalled notification received") self.addInstalledPackage() - self.packageDownload = nil + self.packageInstall = nil } /** @@ -185,7 +185,7 @@ public class SettingsContainer : ObservableObject { @objc func existingPackageReplaced(_ notification: Notification) { print("existingPackageReplaced notification received") self.replaceInstalledPackage() - self.packageDownload = nil + self.packageInstall = nil } /** @@ -213,11 +213,11 @@ public class SettingsContainer : ObservableObject { * Called when user approves the downgrade of package */ public func userConfirmedPackageDowngrade() { - if let download = self.packageDownload { + if let install = self.packageInstall { do { - try download.replaceExistingPackageWithNewPackage() + try install.replaceExistingPackageWithNewPackage() } catch { - print("unable to downgrade package: \(download.packageToInstall?.packageName ?? "unknown")") + print("unable to downgrade package: \(install.packageToInstall?.packageName ?? "unknown")") } } } @@ -226,14 +226,26 @@ public class SettingsContainer : ObservableObject { * Called when user chooses to cancel downgrade of package */ public func userCanceledPackageDowngrade() { - if let download = self.packageDownload { + if let install = self.packageInstall { print("user cancelled package downgrade") - download.cleanupFailedInstallation() + install.cleanupFailedInstallation() } - self.packageDownload = nil + self.packageInstall = nil } + /** + * Called when user chooses to cancel downgrade of package + */ + public func userCanceledPackageInstallation() { + if let install = self.packageInstall { + print("user cancelled package installation") + install.cleanupFailedInstallation() + } + + self.packageInstall = nil + } + /** * for debugging: prints UserDefaults values */ @@ -447,7 +459,7 @@ public class SettingsContainer : ObservableObject { */ public func isDownloadInProgress() -> Bool { // MAC-CONFIG-TODO: add logic, this does not actually prevent downloads when hard-coded to true - return self.packageDownload != nil + return self.packageInstall != nil } /** @@ -461,7 +473,7 @@ public class SettingsContainer : ObservableObject { let packageDownload = PackageInstallHelper(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) - self.packageDownload = packageDownload + self.packageInstall = packageDownload return packageDownload.temporaryKmpFileLocation } @@ -472,14 +484,14 @@ public class SettingsContainer : ObservableObject { public func packageDownloadComplete(kmpFileUrl: URL) throws { print ("packageDownloadComplete \(kmpFileUrl)") - try self.packageDownload?.packageDownloadComplete(for: kmpFileUrl) + try self.packageInstall?.packageDownloadComplete(for: kmpFileUrl) } /** * 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 { + if let package = self.packageInstall?.packageToInstall { self.installedPackages.append(package) self.addEnabledKeyboards(for: package) } @@ -490,7 +502,7 @@ public class SettingsContainer : ObservableObject { * 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 package = self.packageInstall?.packageToInstall { if let index = self.installedPackages.firstIndex(where: { $0.packageName == package.packageName }) { self.installedPackages[index] = package self.addEnabledKeyboards(for: package) @@ -516,27 +528,54 @@ public class SettingsContainer : ObservableObject { // try self.installDroppedKmpFile(from: fileLocation, to: destinationURL) let droppedFilename = fileLocation.lastPathComponent - if let packageDownload = self.preparePackageDrop(kmpFileName: droppedFilename) { - self.packageDownload = packageDownload + if let packageDownload = self.preparePackageDrop(kmpFilename: droppedFilename) { + self.packageInstall = packageDownload do { try packageDownload.prepareToInstall(for: fileLocation) } catch { // clear failed download - self.packageDownload = nil + self.packageInstall = nil throw error } } } + /** + * Attempt to install a package from a KMP file. Called when file is dropped on the Configuration view + */ + public func initiateKmpFileInstallation(at fileLocation: URL) throws -> PackageInstallHelper? { + guard !self.isDownloadInProgress() else { + throw InstallPackageError.downloadInProgress + } + + // validate the URL of the KMP file + try self.validateDroppedFile(from: fileLocation) + + let kmpFilename = fileLocation.lastPathComponent + + if let helper = self.preparePackageDrop(kmpFilename: kmpFilename) { + self.packageInstall = helper + do { + try helper.prepareToInstall(for: fileLocation) + } catch { + // clear failed download + self.packageInstall = nil + throw error + } + } + + return self.packageInstall + } + /** * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. */ - public func preparePackageDrop(kmpFileName: String) -> PackageInstallHelper? { + public func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") + let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") - return PackageInstallHelper(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) + return PackageInstallHelper(filename: kmpFilename, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) } /** @@ -584,29 +623,29 @@ public class SettingsContainer : ObservableObject { /** * Install the package from the dropped kmp file at the specified location */ - func installDroppedKmpFile(from droppedFileUrl: URL, to installPackageLocation: URL) throws { - var newPackage: KeymanPackage? = nil - - try self.packageRepository.unzipKmpFile(at: droppedFileUrl, to: installPackageLocation) - - do { - // load the unzipped package and get a reference to it - newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) - } - catch { - // the package could not be loaded, so remove it from the installation directory - self.removeFailedInstallation(at: installPackageLocation) - - // re-throw error to notify user of reason installation failed - throw error - } - - if let installedPackage = newPackage { - // add the newly installed package to the array and enable its keyboards - self.installedPackages.append(installedPackage) - self.addEnabledKeyboards(for: installedPackage) - } - } +// func installDroppedKmpFile(from droppedFileUrl: URL, to installPackageLocation: URL) throws { +// var newPackage: KeymanPackage? = nil +// +// try self.packageRepository.unzipKmpFile(at: droppedFileUrl, to: installPackageLocation) +// +// do { +// // load the unzipped package and get a reference to it +// newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) +// } +// catch { +// // the package could not be loaded, so remove it from the installation directory +// self.removeFailedInstallation(at: installPackageLocation) +// +// // re-throw error to notify user of reason installation failed +// throw error +// } +// +// if let installedPackage = newPackage { +// // add the newly installed package to the array and enable its keyboards +// self.installedPackages.append(installedPackage) +// self.addEnabledKeyboards(for: installedPackage) +// } +// } /** * Clean up after a failed drag and drop installation diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 06c4f9e3057..ad01a2062d5 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -12,15 +12,22 @@ import Foundation @MainActor // run on the main actor as it is called from SettingsContainer -public class PackageInstallHelper { +public class PackageInstallHelper: Identifiable { + public let id = UUID() +// public var installationError: LocalizedError? +// public var errorMessage: String? let temporaryKmpFileLocation: URL let temporaryPackageLocation: URL let installPackageLocation: URL let installedPackages: [KeymanPackage] // needed to check for existing package after download let isDownload: Bool // if not download, then the package was opened from disk or dropped - var packageToInstall: KeymanPackage? // the newly downloaded package + public private(set) var packageToInstall: KeymanPackage? // the newly downloaded package var packageToReplace: KeymanPackage? // the package to replace, if it exists + public var packageName: String? { + return packageToInstall?.packageName + } + fileprivate let packageRepository: PackageRepo public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { @@ -48,14 +55,13 @@ public class PackageInstallHelper { } /** - * Indicates that a package is ready to be unzipped and installed + * Indicates that a package is ready to be unzipped and loaded */ public func prepareToInstall(for kmpFileUrl: URL) throws { print ("prepareToInstall \(kmpFileUrl)") do { - try self.unzipDownloadedPackage(for: kmpFileUrl) - try self.handleNewPackage() + try self.unzipPackage(for: kmpFileUrl) } catch { self.cleanupFailedInstallation() print ("package installation failed with error '\(error)' for \(kmpFileUrl)") @@ -63,10 +69,25 @@ public class PackageInstallHelper { } } + /** + * Indicates that a package is ready to be unzipped and installed + */ + public func install() throws { + print ("install \(self.packageToInstall?.packageName ?? "unknown package")") + + do { + try self.handleNewPackage() + } catch { + self.cleanupFailedInstallation() + print ("package installation failed with error '\(self.packageToInstall?.packageName ?? "unknown package")") + throw error + } + } + /** * Unzip and load the downloaded package */ - func unzipDownloadedPackage(for kmpFileUrl: URL) throws { + func unzipPackage(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 @@ -145,14 +166,16 @@ public class PackageInstallHelper { * 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)") + // 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.deleteDownloadedPackage() + try self.deleteUnzippedPackage() } catch { print("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryPackageLocation.lastPathComponent)") } @@ -185,9 +208,9 @@ public class PackageInstallHelper { } /** - * Delete the downloaded package from the temp directory + * Delete the unzipped package in the temp directory */ - func deleteDownloadedPackage() throws { + func deleteUnzippedPackage() throws { try FileManager.default.removeItem(at: self.temporaryPackageLocation) } From 504e8678acefb2e1899c198b1bbfa800920f416c Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Thu, 20 Aug 2026 20:41:25 -0400 Subject: [PATCH 11/23] feat(mac): classified four different package installation types new package package replacement (version matches) package upgrade package downgrade --- mac/Config/Config/MainConfigView.swift | 2 +- mac/Config/Config/PackageInstallView.swift | 12 +-- .../KeymanSettings/SettingsContainer.swift | 26 ++++- .../Persistence/PackageInstallHelper.swift | 99 ++++++++++++++++++- 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 0935923dbbd..b9aa361ff63 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -98,7 +98,7 @@ struct MainConfigView: View { if accepted { print("Processing validated package: \(helper.packageName ?? "unknown package")") do { - try helper.install() + try settings.installPackage() } catch { print("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error.localizedDescription)") } diff --git a/mac/Config/Config/PackageInstallView.swift b/mac/Config/Config/PackageInstallView.swift index f8d8d80ada4..1d653e8dd16 100644 --- a/mac/Config/Config/PackageInstallView.swift +++ b/mac/Config/Config/PackageInstallView.swift @@ -16,11 +16,11 @@ struct PackageInstallView: View { var body: some View { VStack(spacing: 20) { - Text("Install Package?") - .font(.headline) - - Text("Ready to install: \(installHelper.packageName ?? "unknown package")") - .multilineTextAlignment(.center) + if let installationPrompt = installHelper.packageInstallationType?.prompt { + Text(installationPrompt) + .font(.title2) + .multilineTextAlignment(.leading) + } if let readmeFileUrl = installHelper.packageToInstall?.readmeFileUrl { PackageContentWebView(packageFileUrl: readmeFileUrl) @@ -36,7 +36,7 @@ struct PackageInstallView: View { } .keyboardShortcut(.cancelAction) - Button("Accept & Install") { + Button("Install") { completion(true) } .buttonStyle(.borderedProminent) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 4fc30acbcd9..4b16b7fac4c 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -541,7 +541,8 @@ public class SettingsContainer : ObservableObject { } /** - * Attempt to install a package from a KMP file. Called when file is dropped on the Configuration view + * Begin installation of a package from a KMP file. + * Called when a .KMP file is dropped on the Configuration view */ public func initiateKmpFileInstallation(at fileLocation: URL) throws -> PackageInstallHelper? { guard !self.isDownloadInProgress() else { @@ -567,6 +568,29 @@ public class SettingsContainer : ObservableObject { return self.packageInstall } + /** + * Install the package and add it to the installedPackages array and UserDefaults + */ + public func installPackage() throws { + if let install = self.packageInstall { + + try install.installPackage() + + guard let installationType = install.packageInstallationType else { return } + + switch installationType { + case .newPackage: + self.addInstalledPackage() + case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: + self.replaceInstalledPackage() + case .packageNotFound: + throw DropKmpError.installFailed("unknown package installation type") + } + } + + self.packageInstall = nil + } + /** * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index ad01a2062d5..52f39fcc648 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -11,6 +11,29 @@ import Foundation +public enum PackageInstallationType { + case newPackage(String) + case replaceSameVersionPackage(String) + case replaceOlderPackage(String, String, String) + case replaceNewerPackage(String, String, String) + case packageNotFound + + 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)" + case .packageNotFound: + return "No package to install" + } + } +} + @MainActor // run on the main actor as it is called from SettingsContainer public class PackageInstallHelper: Identifiable { public let id = UUID() @@ -22,12 +45,13 @@ public class PackageInstallHelper: Identifiable { let installedPackages: [KeymanPackage] // needed to check for existing package after download let isDownload: Bool // if not download, then the package was opened from disk or dropped public private(set) var packageToInstall: KeymanPackage? // the newly downloaded package - var packageToReplace: KeymanPackage? // the package to replace, if it exists + public private(set) var packageToReplace: KeymanPackage? // the package to replace, if it exists + public private(set) var packageInstallationType: PackageInstallationType? public var packageName: String? { return packageToInstall?.packageName } - + fileprivate let packageRepository: PackageRepo public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { @@ -67,10 +91,12 @@ public class PackageInstallHelper: Identifiable { print ("package installation failed with error '\(error)' for \(kmpFileUrl)") throw error } + + self.packageInstallationType = self.determinePackageInstallationType() } /** - * Indicates that a package is ready to be unzipped and installed + * Install the unzipped package */ public func install() throws { print ("install \(self.packageToInstall?.packageName ?? "unknown package")") @@ -84,6 +110,25 @@ public class PackageInstallHelper: Identifiable { } } + /** + * Install the new package and replace existing package if necessary + */ + public func installPackage() throws { + print ("installPackage \(self.packageToInstall?.packageName ?? "unknown package")") + + guard let installationType = self.packageInstallationType else { return } + + switch installationType { + case .newPackage: + try self.installNewPackage() + case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: + try self.replaceExistingPackageWithNewPackage() + case .packageNotFound: + throw DropKmpError.installFailed("unknown package installation type") + } + } + + /** * Unzip and load the downloaded package */ @@ -93,6 +138,46 @@ public class PackageInstallHelper: Identifiable { // 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 what type of package installation this is: + * - a new package + * - an update of an existing package + * - a downgrade of an existing package + */ + func determinePackageInstallationType() -> PackageInstallationType { + let packageAlreadyInstalled = self.checkForExistingPackage() + + guard let newPackage = self.packageToInstall else { + return PackageInstallationType.packageNotFound + } + + if !packageAlreadyInstalled { + return PackageInstallationType.newPackage(newPackage.packageName) + } else { + if let installedPackage = self.packageToReplace { + let newVersion = newPackage.packageVersion + let existingVersion = installedPackage.packageVersion + + let comparisonResult = newVersion.compare(existingVersion, options: .numeric) + + if comparisonResult == .orderedAscending { + print("package downgrade: new version is older than existing version") + return PackageInstallationType.replaceNewerPackage(newPackage.packageName, existingVersion, newVersion) + } else if comparisonResult == .orderedDescending { + print("package upgrade: new version is newer than existing version") + return PackageInstallationType.replaceOlderPackage(newPackage.packageName, existingVersion, newVersion) + } else { + print("new and existing package versions are identical") + return PackageInstallationType.replaceSameVersionPackage(newPackage.packageName) + } + } + } + + return PackageInstallationType.packageNotFound } /** @@ -156,7 +241,13 @@ public class PackageInstallHelper: Identifiable { */ func replaceExistingPackageWithNewPackage() throws { try self.deleteInstalledPackage() - try self.deleteDownloadedKmpFile() + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + print("replaceExistingPackageWithNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + } try self.movePackageFromTemporaryToInstalled() NotificationCenter.default.post(name: .packageReplaced, object: nil) From 751dc0bfae19e34c587826f335af303068fcc297 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Fri, 21 Aug 2026 09:29:45 -0400 Subject: [PATCH 12/23] feat(mac): package download and drag and drop use common code for installation and confirmation UI added DownloadCoordinator to link webview to SwiftUI views --- mac/Config/Config/ConfigDebugView.swift | 48 ------ mac/Config/Config/DownloadCoordinator.swift | 141 ++++++++++++++++++ mac/Config/Config/InstallKeyboardView.swift | 30 +++- mac/Config/Config/KeyboardSearchView.swift | 131 +--------------- mac/Config/Config/MainConfigView.swift | 6 +- .../KeymanSettings/SettingsContainer.swift | 64 ++++---- .../Persistence/PackageInstallHelper.swift | 10 +- 7 files changed, 219 insertions(+), 211 deletions(-) create mode 100644 mac/Config/Config/DownloadCoordinator.swift diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index ae63e60c354..35e1522f0ea 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -12,12 +12,6 @@ import KeymanSettings struct ConfigDebugView: View { @EnvironmentObject var settings: SettingsContainer @State private var isShowingSheet = false - - // for drag and drop package installation - @State private var dropError: DropKmpError? - @State private var isShowingDropKmpAlert = false - @State private var alertMessage = "" - @State private var isHovering = false var body: some View { VStack { @@ -47,48 +41,6 @@ struct ConfigDebugView: View { .frame(width: 700, height: 500) } - VStack { - Text("Drag a single .kmp archive here") - .font(.system(.body, design: .monospaced)) - .multilineTextAlignment(.center) - .padding() - .frame(width: 350, height: 180) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isHovering ? Color.accentColor : Color.gray, lineWidth: 2) - ) - // Accept URL drops - .dropDestination(for: URL.self) { urls, _ in - // reject drop if it is more than one file - guard let droppedFileUrl = urls.first, urls.count == 1 else { - let error = DropKmpError.tooManyFiles - self.alertMessage = error.localizedDescription - self.isShowingDropKmpAlert = true - return false // the drop failed - } - do { - try settings.processDroppedKmpFile(at: droppedFileUrl) - return true // the drop was successful - } catch { - self.alertMessage = error.localizedDescription - self.isShowingDropKmpAlert = true - return false - - } - } isTargeted: { hovering in - isHovering = hovering - } - } - .padding() - // alert triggers automatically when $dropError becomes non-nil - .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { - Button("OK", role: .cancel) { } - } message: { - Text(alertMessage) - } - ScrollView { VStack(alignment: .leading, spacing: 6) { ForEach(Array(settings.singleKeyboardPackages.enumerated()), id: \.offset) { index, package in diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift new file mode 100644 index 00000000000..13eabf2119e --- /dev/null +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -0,0 +1,141 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-08-21 + * + * For coordination between WKWebview and SwiftUI views + */ + +import WebKit +import Combine +import KeymanSettings + +public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate { + @Published var showInstallSheet = false + @Published var installHelper: PackageInstallHelper? + var downloadFileUrl: URL? = nil + var settings: SettingsContainer? + + 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") + 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) + } + } + } + + public func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + print("webView navigationAction:didBecome called") + download.delegate = self // Assign delegate for file saving + } + + public func webView(_ webView: WKWebView, + navigationResponse: WKNavigationResponse, + didBecome download: WKDownload) { + print("webView navigationResponse:didBecome called") + download.delegate = self + } + + 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") + completionHandler(nil) + return + } + + // notify settings that a keyboard download is beginning and get the URL to + // the temporary folder where it should be downloaded + + do { + if let helper = try keymanSettings.initiateKmpFileDownload(kmpFilename: suggestedFilename) { + self.installHelper = helper + downloadFileUrl = helper.temporaryKmpFileLocation + completionHandler(downloadFileUrl) + } + } catch { + print("could not initiate KMP package download") + completionHandler(nil) + } + +// downloadFileUrl = keymanSettings.preparePackageDownload(kmpFileName: suggestedFilename) + } + + public func downloadDidFinish(_ download: WKDownload) { + DispatchQueue.main.async { + // Trigger the SwiftUI modal sheet + self.showInstallSheet = true + } + + if let downloadFileUrl { + print("Download of \(downloadFileUrl.path()) was successful.") + if let settings { + do { + try settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) + } catch { + // MAC-CONFIG-TODO: communicate failed install to user + } + } + } + } + + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + // MAC-CONFIG-TODO: communicate failed install to user + print("Download failed with error: \(error.localizedDescription)") + } + + 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/InstallKeyboardView.swift b/mac/Config/Config/InstallKeyboardView.swift index 916cac7b121..787214d5efb 100644 --- a/mac/Config/Config/InstallKeyboardView.swift +++ b/mac/Config/Config/InstallKeyboardView.swift @@ -1,13 +1,23 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * Contains webview to search for keyboards and injects + * DownloadCoordinator to bridge back to SwiftUI + */ + import SwiftUI import KeymanSettings struct InstallKeyboardView: View { @EnvironmentObject var settings: SettingsContainer @Environment(\.dismiss) private var dismiss + @StateObject private var downloadCoordinator = DownloadCoordinator() var body: some View { VStack { - KeyboardSearchView() + KeyboardSearchView(coordinator: downloadCoordinator) .environmentObject(settings) .padding() } @@ -19,5 +29,23 @@ struct InstallKeyboardView: View { } } } + .sheet(isPresented: $downloadCoordinator.showInstallSheet) { + if let helper = downloadCoordinator.installHelper { + PackageInstallView(installHelper: helper) { accepted in + if accepted { + print("Processing 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() + } + downloadCoordinator.showInstallSheet = false + dismiss() + } + } + } } } diff --git a/mac/Config/Config/KeyboardSearchView.swift b/mac/Config/Config/KeyboardSearchView.swift index de7925b1083..2d48f9386b9 100644 --- a/mac/Config/Config/KeyboardSearchView.swift +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -9,10 +9,12 @@ import Foundation import SwiftUI +import Combine import WebKit import KeymanSettings struct KeyboardSearchView: NSViewRepresentable { + @ObservedObject var coordinator: DownloadCoordinator @EnvironmentObject var settings: SettingsContainer // note that the EnvironmentObject is not available within init (if we were to implement that) @@ -21,18 +23,13 @@ struct KeyboardSearchView: NSViewRepresentable { // MAC-CONFIG-TODO: build URL rather than hard-code let searchURL = URL(string: "https://keyman.com/go/macos/14.0/download-keyboards/?version=19.0.284")! - /** Creates the Coordinator to handle WebKit delegate methods */ - func makeCoordinator() -> Coordinator { - Coordinator() - } - /** Creates the underlying NSView (WKWebView) for macOS */ func makeNSView(context: Context) -> WKWebView { print("makeNSView called") let webView = WKWebView() // assign the coordinator as the navigation delegate - webView.navigationDelegate = context.coordinator + webView.navigationDelegate = self.coordinator let request = URLRequest(url: searchURL) webView.load(request) @@ -45,129 +42,11 @@ struct KeyboardSearchView: NSViewRepresentable { * as the environment has been loaded by now. */ func updateNSView(_ nsView: WKWebView, context: Context) { - if context.coordinator.settings == nil { - context.coordinator.settings = self.settings + if coordinator.settings == nil { + coordinator.settings = self.settings print("updateNSView, settings intialized for coordinator") } } - - class Coordinator: NSObject, WKNavigationDelegate, WKDownloadDelegate { - var downloadFileUrl: URL? = nil - var settings: SettingsContainer? - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - preferences: WKWebpagePreferences, - decisionHandler: @escaping @MainActor (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { - - print("deciding navigation based on action") - - if let url = navigationAction.request.url { - print("webView navigationAction.request.url: \(url)") - } - - // Trust HTML download attribute if present - if navigationAction.shouldPerformDownload { - print("webView called decisionHandler for download") - decisionHandler(.download, preferences) - return - } - - // MAC-CONFIG-TODO: is this necessary or is download attribute enough to identify - // check if URL ends with a target file extension - if let url = navigationAction.request.url { - if url.pathExtension.lowercased() == KeymanPaths.keymanPackageFileExtension { - decisionHandler(.download, preferences) - print("webView found .kmp, called decisionHandler for download") - return - } - } - - decisionHandler(.allow, preferences) - } - - /** decide whether the navigation should be allowed, canceled or result in a download */ - func webView(_ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void) { - print("deciding navigation based on response") - - if navigationResponse.canShowMIMEType { - decisionHandler(.allow) - } else { - guard let keymanSettings = self.settings else { - print("webView decidePolicyFor:decisionHandler: no settings") - decisionHandler(.cancel) - return - } - - // if a download is already in progress then stop another from starting - if keymanSettings.isDownloadInProgress() { - print("download already in progress, download canceled") - decisionHandler(.cancel) - } else { - decisionHandler(.download) - } - } - } - - func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { - print("webView navigationAction:didBecome called") - download.delegate = self // Assign delegate for file saving - } - - func webView(_ webView: WKWebView, - navigationResponse: WKNavigationResponse, - didBecome download: WKDownload) { - print("webView navigationResponse:didBecome called") - download.delegate = self - } - - func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) { - print("download initiated") - - guard let keymanSettings = self.settings else { - print("tried to access settings before they were intialized in updateNSView") - completionHandler(nil) - return - } - - // notify settings that a keyboard download is beginning and get the URL to - // the temporary folder where it should be downloaded - - downloadFileUrl = keymanSettings.preparePackageDownload(kmpFileName: suggestedFilename) - if let downloadFileUrl { - completionHandler(downloadFileUrl) - } else { - print("could not prepare package for download") - completionHandler(nil) - } - } - - func downloadDidFinish(_ download: WKDownload) { - if let downloadFileUrl { - print("Download of \(downloadFileUrl.path()) was successful.") - if let settings { - do { - try settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) - } catch { - // MAC-CONFIG-TODO: communicate failed install to user - } - } - } - } - - func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { - // MAC-CONFIG-TODO: communicate failed install to user - 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 b9aa361ff63..7eac46f15d4 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -14,7 +14,7 @@ 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 @@ -40,7 +40,7 @@ struct MainConfigView: View { VStack { // the add keyboard button LabelButtonView( - action: { isShowingSheet = true }, + action: { isShowingAddKeyboardSheet = true }, label: "Add Keyboard", systemImage: "plus", font: .title2 @@ -48,7 +48,7 @@ struct MainConfigView: View { .clipShape(.capsule) .padding([.top, .leading, .trailing]) // binds the visibility state to the sheet builder - .sheet(isPresented: $isShowingSheet) { + .sheet(isPresented: $isShowingAddKeyboardSheet) { InstallKeyboardView() .frame(width: 960, height: 390) // MAC-CONFIG-TODO: Make width and height percentages diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 4b16b7fac4c..2694ea55f24 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -477,6 +477,41 @@ public class SettingsContainer : ObservableObject { return packageDownload.temporaryKmpFileLocation } + /** + * Called by the WebView Coordinator before initiating a package download. + * Returns a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + */ + public func initiateKmpFileDownload(kmpFilename: String) throws -> PackageInstallHelper? { + + guard !self.isDownloadInProgress() else { + throw InstallPackageError.downloadInProgress + } + + if let helper = self.preparePackageDownload(kmpFilename: kmpFilename) { + self.packageInstall = helper + +// do { +// try helper.prepareToInstall(for: fileLocation) +// } catch { +// // clear failed download +// self.packageInstall = nil +// throw error +// } + } + + return self.packageInstall + } + + /** + * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + */ + func preparePackageDownload(kmpFilename: String) -> PackageInstallHelper? { + // package name is filename minus .kmp extension + let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") + + return PackageInstallHelper(filename: kmpFilename, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) + } + /** * Called by the WebView Coordinator after the download is complete. * Delegates to the PackageInstallHelper instance to decide whether the package should be installed. @@ -514,32 +549,6 @@ public class SettingsContainer : ObservableObject { // MARK: Drag and drop Package Installation - /** - * Attempt to install a package from a KMP file. Called when file is dropped on the Configuration view - */ - public func processDroppedKmpFile(at fileLocation: URL) throws { - guard !self.isDownloadInProgress() else { - throw InstallPackageError.downloadInProgress - } - - // validate URL and get the location where the package will be installed - try self.validateDroppedFile(from: fileLocation) - // install it -// try self.installDroppedKmpFile(from: fileLocation, to: destinationURL) - - let droppedFilename = fileLocation.lastPathComponent - if let packageDownload = self.preparePackageDrop(kmpFilename: droppedFilename) { - self.packageInstall = packageDownload - do { - try packageDownload.prepareToInstall(for: fileLocation) - } catch { - // clear failed download - self.packageInstall = nil - throw error - } - } - } - /** * Begin installation of a package from a KMP file. * Called when a .KMP file is dropped on the Configuration view @@ -553,7 +562,6 @@ public class SettingsContainer : ObservableObject { try self.validateDroppedFile(from: fileLocation) let kmpFilename = fileLocation.lastPathComponent - if let helper = self.preparePackageDrop(kmpFilename: kmpFilename) { self.packageInstall = helper do { @@ -595,7 +603,7 @@ public class SettingsContainer : ObservableObject { * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. */ - public func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { + func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { // package name is filename minus .kmp extension let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 52f39fcc648..1bebbe3b126 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -21,13 +21,13 @@ public enum PackageInstallationType { public var prompt: LocalizedStringResource { switch self { case .newPackage(let packageName): - return "The package \(packageName) is ready to install" + return "The package '\(packageName)' is ready to install" case .replaceSameVersionPackage(let packageName): - return "The package \(packageName) is ready to re-install" + 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)" + 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)" + return "The package '\(packageName)' is ready to downgrade from version \(existingVersion) to \(newVersion)" case .packageNotFound: return "No package to install" } @@ -39,7 +39,7 @@ public class PackageInstallHelper: Identifiable { public let id = UUID() // public var installationError: LocalizedError? // public var errorMessage: String? - let temporaryKmpFileLocation: URL + public let temporaryKmpFileLocation: URL let temporaryPackageLocation: URL let installPackageLocation: URL let installedPackages: [KeymanPackage] // needed to check for existing package after download From f942bdb839f80a09a7757ac218507f0736d31506 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Fri, 21 Aug 2026 12:36:28 -0400 Subject: [PATCH 13/23] feat(mac): tidied PackageConfirmationView added Keyman colors to installation images --- mac/Config/Config/InstallKeyboardView.swift | 2 +- .../InitialInstallView.swift | 2 +- .../InstallationViews/InitialRepairView.swift | 12 +++++++--- .../RerunInstallerView.swift | 14 +++++++---- .../RestartComputerView.swift | 2 +- mac/Config/Config/MainConfigView.swift | 2 +- ...ew.swift => PackageConfirmationView.swift} | 24 ++++++++++++++----- 7 files changed, 41 insertions(+), 17 deletions(-) rename mac/Config/Config/{PackageInstallView.swift => PackageConfirmationView.swift} (64%) diff --git a/mac/Config/Config/InstallKeyboardView.swift b/mac/Config/Config/InstallKeyboardView.swift index 787214d5efb..53bb4e318f0 100644 --- a/mac/Config/Config/InstallKeyboardView.swift +++ b/mac/Config/Config/InstallKeyboardView.swift @@ -31,7 +31,7 @@ struct InstallKeyboardView: View { } .sheet(isPresented: $downloadCoordinator.showInstallSheet) { if let helper = downloadCoordinator.installHelper { - PackageInstallView(installHelper: helper) { accepted in + PackageConfirmationView(installHelper: helper) { accepted in if accepted { print("Processing validated package: \(helper.packageName ?? "unknown package")") do { diff --git a/mac/Config/Config/InstallationViews/InitialInstallView.swift b/mac/Config/Config/InstallationViews/InitialInstallView.swift index 69bbc12d36a..0c391c26997 100644 --- a/mac/Config/Config/InstallationViews/InitialInstallView.swift +++ b/mac/Config/Config/InstallationViews/InitialInstallView.swift @@ -44,7 +44,7 @@ struct InitialInstallView: View { HStack { Text("Proceed to continue with installation") .font(.title2) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) NavigationButton(action: .advance, onContinue: onContinue) } diff --git a/mac/Config/Config/InstallationViews/InitialRepairView.swift b/mac/Config/Config/InstallationViews/InitialRepairView.swift index 745dabd1a2a..2c61521c2db 100644 --- a/mac/Config/Config/InstallationViews/InitialRepairView.swift +++ b/mac/Config/Config/InstallationViews/InitialRepairView.swift @@ -17,7 +17,8 @@ struct InitialRepairView: View { var body: some View { VStack { - Label("Repairs Required", systemImage: "hand.raised.fill") +// Label("Repairs Required", systemImage: "hand.raised.fill") + Text("Repairs Required") .font(.title) .bold() .frame(maxWidth: .infinity, alignment: .center) @@ -27,13 +28,18 @@ struct InitialRepairView: View { Form { HStack { Spacer() - Image(systemName: "hammer.circle.fill") + Image(systemName: "wrench.and.screwdriver.fill") .font(.system(size: 100)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color("Keyman Blue"), // first color for the wrench + Color("Keyman Orange") // second color for the screwdriver + ) .padding(.bottom, 16) Spacer() } Text("One or more Keyman components or permissions require your attention. Complete the following steps to restore your Keyman installation.") - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) } .formStyle(.grouped) .padding(.top, 50) diff --git a/mac/Config/Config/InstallationViews/RerunInstallerView.swift b/mac/Config/Config/InstallationViews/RerunInstallerView.swift index 9cc1872cecb..e826616da39 100644 --- a/mac/Config/Config/InstallationViews/RerunInstallerView.swift +++ b/mac/Config/Config/InstallationViews/RerunInstallerView.swift @@ -17,6 +17,7 @@ struct RerunInstallerView: View { var body: some View { VStack { +// Label("Missing Keyman Components", systemImage: "hand.raised.fill") Text("Missing Keyman Components") .font(.title) .bold() @@ -27,13 +28,18 @@ struct RerunInstallerView: View { Form { HStack { Spacer() - Image(systemName: "wrench.and.screwdriver.fill") - .font(.system(size: 100)) - .padding(.bottom, 16) + Image(systemName: "wrench.and.screwdriver.fill") + .font(.system(size: 100)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color("Keyman Blue"), // first color for the wrench + Color("Keyman Orange") // second color for the screwdriver + ) + .padding(.bottom, 16) Spacer() } Text("Your Keyman input method is either missing or outdated. Run the Keyman installer to install a new version.") - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .foregroundStyle(.secondary) } .formStyle(.grouped) diff --git a/mac/Config/Config/InstallationViews/RestartComputerView.swift b/mac/Config/Config/InstallationViews/RestartComputerView.swift index c5cab61c28e..493b698a94f 100644 --- a/mac/Config/Config/InstallationViews/RestartComputerView.swift +++ b/mac/Config/Config/InstallationViews/RestartComputerView.swift @@ -26,7 +26,7 @@ struct RestartComputerView: View { .font(.system(size: 100)) .padding(16) Text("Restart your Mac to complete the installation. After restarting, open Keyman Configuration again if it doesn't launch automatically.") - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .padding(.bottom, 8) Spacer() diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 7eac46f15d4..201df6bfddd 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -94,7 +94,7 @@ struct MainConfigView: View { Text(alertMessage) } .sheet(item: $packageInstallHelper) { helper in - PackageInstallView(installHelper: helper) { accepted in + PackageConfirmationView(installHelper: helper) { accepted in if accepted { print("Processing validated package: \(helper.packageName ?? "unknown package")") do { diff --git a/mac/Config/Config/PackageInstallView.swift b/mac/Config/Config/PackageConfirmationView.swift similarity index 64% rename from mac/Config/Config/PackageInstallView.swift rename to mac/Config/Config/PackageConfirmationView.swift index 1d653e8dd16..7dc310597bd 100644 --- a/mac/Config/Config/PackageInstallView.swift +++ b/mac/Config/Config/PackageConfirmationView.swift @@ -10,26 +10,38 @@ import SwiftUI import KeymanSettings -struct PackageInstallView: View { +struct PackageConfirmationView: View { let installHelper: PackageInstallHelper let completion: (Bool) -> Void var body: some View { - VStack(spacing: 20) { + 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(.title2) + .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) @@ -43,6 +55,6 @@ struct PackageInstallView: View { } } .padding() - .frame(width: 540, height: 400) + .frame(width: 580, height: 500) } } From 6b92bd8c94fa1c5861966e33b4b977a5f0945327 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Fri, 21 Aug 2026 14:52:14 -0400 Subject: [PATCH 14/23] feat(mac): renaming for clarity --- ...KeyboardView.swift => AddKeyboardView.swift} | 17 ++++++++++------- mac/Config/Config/ConfigDebugView.swift | 2 +- mac/Config/Config/DownloadCoordinator.swift | 4 ++-- mac/Config/Config/KeyboardSearchView.swift | 2 +- mac/Config/Config/MainConfigView.swift | 9 ++++++--- .../KeymanSettings/SettingsContainer.swift | 15 ++++++++++++--- 6 files changed, 32 insertions(+), 17 deletions(-) rename mac/Config/Config/{InstallKeyboardView.swift => AddKeyboardView.swift} (74%) diff --git a/mac/Config/Config/InstallKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift similarity index 74% rename from mac/Config/Config/InstallKeyboardView.swift rename to mac/Config/Config/AddKeyboardView.swift index 53bb4e318f0..57e150907a4 100644 --- a/mac/Config/Config/InstallKeyboardView.swift +++ b/mac/Config/Config/AddKeyboardView.swift @@ -10,9 +10,9 @@ import SwiftUI import KeymanSettings -struct InstallKeyboardView: View { +struct AddKeyboardView: View { @EnvironmentObject var settings: SettingsContainer - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) private var dismissAddKeyboardView @StateObject private var downloadCoordinator = DownloadCoordinator() var body: some View { @@ -25,15 +25,15 @@ struct InstallKeyboardView: View { // Placement determines where on the bar it sits ToolbarItem(placement: .cancellationAction) { Button("Close") { - dismiss() + dismissAddKeyboardView() } } } - .sheet(isPresented: $downloadCoordinator.showInstallSheet) { + .sheet(isPresented: $downloadCoordinator.showConfirmPackageSheet) { if let helper = downloadCoordinator.installHelper { PackageConfirmationView(installHelper: helper) { accepted in if accepted { - print("Processing validated package: \(helper.packageName ?? "unknown package")") + print("installing validated package: \(helper.packageName ?? "unknown package")") do { try settings.installPackage() } catch { @@ -42,8 +42,11 @@ struct InstallKeyboardView: View { } else { settings.userCanceledPackageInstallation() } - downloadCoordinator.showInstallSheet = false - dismiss() + + // close sheet + downloadCoordinator.showConfirmPackageSheet = false + // close + dismissAddKeyboardView() } } } diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index 35e1522f0ea..be7e7010430 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -36,7 +36,7 @@ struct ConfigDebugView: View { .frame(width: 700, height: 100) // Binds the visibility state to the sheet builder .sheet(isPresented: $isShowingSheet) { - InstallKeyboardView() + AddKeyboardView() .presentationDetents([.medium, .large]) .frame(width: 700, height: 500) } diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index 13eabf2119e..85db15f86c9 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -11,7 +11,7 @@ import Combine import KeymanSettings public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate { - @Published var showInstallSheet = false + @Published var showConfirmPackageSheet = false @Published var installHelper: PackageInstallHelper? var downloadFileUrl: URL? = nil var settings: SettingsContainer? @@ -113,7 +113,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega public func downloadDidFinish(_ download: WKDownload) { DispatchQueue.main.async { // Trigger the SwiftUI modal sheet - self.showInstallSheet = true + self.showConfirmPackageSheet = true } if let downloadFileUrl { diff --git a/mac/Config/Config/KeyboardSearchView.swift b/mac/Config/Config/KeyboardSearchView.swift index 2d48f9386b9..64b617cd1d0 100644 --- a/mac/Config/Config/KeyboardSearchView.swift +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -3,7 +3,7 @@ * * Created by Shawn Schantz on 2026-06-16 * - * Webview to search for Keyman keyboards + * Webview to search for Keyman keyboards/packages */ import Foundation diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 201df6bfddd..9707091adca 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -49,7 +49,7 @@ struct MainConfigView: View { .padding([.top, .leading, .trailing]) // binds the visibility state to the sheet builder .sheet(isPresented: $isShowingAddKeyboardSheet) { - InstallKeyboardView() + AddKeyboardView() .frame(width: 960, height: 390) // MAC-CONFIG-TODO: Make width and height percentages } @@ -95,8 +95,12 @@ struct MainConfigView: View { } .sheet(item: $packageInstallHelper) { helper in PackageConfirmationView(installHelper: helper) { accepted in + + // close PackageConfirmationView sheet before updating list + packageInstallHelper = nil + if accepted { - print("Processing validated package: \(helper.packageName ?? "unknown package")") + print("installing validated package: \(helper.packageName ?? "unknown package")") do { try settings.installPackage() } catch { @@ -105,7 +109,6 @@ struct MainConfigView: View { } else { settings.userCanceledPackageInstallation() } - packageInstallHelper = nil } } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 2694ea55f24..e1a89675756 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -305,7 +305,6 @@ public class SettingsContainer : ObservableObject { * 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 { @@ -581,8 +580,17 @@ public class SettingsContainer : ObservableObject { */ public func installPackage() throws { if let install = self.packageInstall { - try install.installPackage() + + commitPackageInstall() + } + } + + /** + * Update the data model for the installed package. This is separated so that it can be animated in SwiftUI. + */ + func commitPackageInstall() { + if let install = self.packageInstall { guard let installationType = install.packageInstallationType else { return } @@ -592,12 +600,13 @@ public class SettingsContainer : ObservableObject { case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: self.replaceInstalledPackage() case .packageNotFound: - throw DropKmpError.installFailed("unknown package installation type") + print("commitPackageInstall: package not found") } } self.packageInstall = nil } + /** * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. From a58f6f9ffc9980001ed92e3d7008a97120521626 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Fri, 21 Aug 2026 15:21:17 -0400 Subject: [PATCH 15/23] feat(mac): strip obsolete code --- mac/Config/Config/ConfigApp.swift | 4 - mac/Config/Config/ConfigDebugView.swift | 84 -------- mac/Config/Config/KeyboardListDebugView.swift | 34 ---- .../KeymanSettings/SettingsContainer.swift | 191 +----------------- .../Persistence/PackageInstallHelper.swift | 51 ----- 5 files changed, 3 insertions(+), 361 deletions(-) delete mode 100644 mac/Config/Config/ConfigDebugView.swift delete mode 100644 mac/Config/Config/KeyboardListDebugView.swift diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index 2841c45a436..dd6a872dd6e 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -41,10 +41,6 @@ struct ConfigApp: App { } .windowResizability(.contentSize) .defaultSize(width: 600, height: 500) - Window("Config Test", id: "config-debug") { - ConfigDebugView() - .environmentObject(settings) - } Window("Install Test", id: "install-debug") { InstallDebugView() .environmentObject(installation) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift deleted file mode 100644 index be7e7010430..00000000000 --- a/mac/Config/Config/ConfigDebugView.swift +++ /dev/null @@ -1,84 +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) { - AddKeyboardView() - .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/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/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index e1a89675756..090608159a8 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -43,13 +43,6 @@ public extension Notification.Name { static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed") } -// in-app notifications -public extension Notification.Name { - static let newPackageInstalled = Notification.Name("com.keyman.package.installed") - static let packageReplaced = Notification.Name("com.keyman.package.replaced") - static let packageDowngradeRequested = Notification.Name("com.keyman.package.downgrade.requested") -} - // define LocalizedError so that UI can present a localizable message // when the attempt to install a KMP file using drag and drop fails public enum DropKmpError: LocalizedError { @@ -135,9 +128,6 @@ public class SettingsContainer : ObservableObject { // next, apply the settings to the packages // this mainly consists of marking them as enabled or not self.applyUserDefaultsToInstalledPackages() - - // use NotificationCenter to receive keyboard installation notifications - self.registerObservers() } /** @@ -152,42 +142,7 @@ public class SettingsContainer : ObservableObject { self.multiKeyboardPackages = [] self.installedPackages = [] } - - /** - * register observers to handle notifications - */ - func registerObservers() { - // for installation of a new package - NotificationCenter.default.addObserver( - self, selector: #selector(newPackageInstalled(_:)), - name: .newPackageInstalled, object: nil - ) - // for replacement of an existing package - NotificationCenter.default.addObserver( - self, selector: #selector(existingPackageReplaced(_:)), - name: .packageReplaced, object: nil - ) - } - - /** - * called for `newPackageInstalled` notification - */ - @objc func newPackageInstalled(_ notification: Notification) { - print("newPackageInstalled notification received") - self.addInstalledPackage() - self.packageInstall = nil - } - - /** - * called for `packageReplaced` notification - */ - @objc func existingPackageReplaced(_ notification: Notification) { - print("existingPackageReplaced notification received") - self.replaceInstalledPackage() - self.packageInstall = nil - } - /** * Whenever the installedPackages array changes, recreate the two subarrays */ @@ -209,31 +164,6 @@ 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 install = self.packageInstall { - do { - try install.replaceExistingPackageWithNewPackage() - } catch { - print("unable to downgrade package: \(install.packageToInstall?.packageName ?? "unknown")") - } - } - } - - /** - * Called when user chooses to cancel downgrade of package - */ - public func userCanceledPackageDowngrade() { - if let install = self.packageInstall { - print("user cancelled package downgrade") - install.cleanupFailedInstallation() - } - - self.packageInstall = nil - } - /** * Called when user chooses to cancel downgrade of package */ @@ -246,20 +176,6 @@ public class SettingsContainer : ObservableObject { self.packageInstall = nil } - /** - * for debugging: prints UserDefaults values - */ - public func logUserDefaults() { - self.defaultsRepository.logDefaults() - } - - /** - * for debugging: clears all UserDefaults values - */ - public func clearUserDefaults() { - self.defaultsRepository.clearDefaults() - } - /** * for each enabled keyboard in the package being installed, add it to the enabled keyboards set and save it in the UserDefaults */ @@ -289,18 +205,6 @@ 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 */ @@ -457,27 +361,11 @@ public class SettingsContainer : ObservableObject { * 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 self.packageInstall != nil } /** - * Called by the WebView Coordinator before initiating a package download. - * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. - * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. - */ - public func preparePackageDownload(kmpFileName: String) -> URL? { - // package name is filename minus .kmp extension - let packageName = kmpFileName.replacingOccurrences(of: kmpFileExtension, with: "") - - let packageDownload = PackageInstallHelper(filename: kmpFileName, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) - - self.packageInstall = packageDownload - return packageDownload.temporaryKmpFileLocation - } - - /** - * Called by the WebView Coordinator before initiating a package download. + * 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? { @@ -488,14 +376,6 @@ public class SettingsContainer : ObservableObject { if let helper = self.preparePackageDownload(kmpFilename: kmpFilename) { self.packageInstall = helper - -// do { -// try helper.prepareToInstall(for: fileLocation) -// } catch { -// // clear failed download -// self.packageInstall = nil -// throw error -// } } return self.packageInstall @@ -512,7 +392,7 @@ public class SettingsContainer : ObservableObject { } /** - * Called by the WebView Coordinator after the download is complete. + * 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 { @@ -587,7 +467,7 @@ public class SettingsContainer : ObservableObject { } /** - * Update the data model for the installed package. This is separated so that it can be animated in SwiftUI. + * Update the data model for the installed package. */ func commitPackageInstall() { if let install = self.packageInstall { @@ -606,7 +486,6 @@ public class SettingsContainer : ObservableObject { self.packageInstall = nil } - /** * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. @@ -629,29 +508,6 @@ public class SettingsContainer : ObservableObject { } } - /** - * Validate the URL for the file we are dropping and return the installation location - * Throws errors if the URL does not end with .kmp or a package of the same name is already installed - */ - func validateDropUrlForInstallation(from fileLocation: URL) throws -> URL { - // if the file does not end with .kmp, reject it - guard fileLocation.pathExtension.lowercased() == kmpFileExtensionWithoutDot else { - throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) - } - - // if we cannot get a URL to the install location, then reject it (should never happen) - guard let destinationURL = buildInstalledPackageUrl(for: fileLocation) else { - throw DropKmpError.installFailed(fileLocation.lastPathComponent) - } - - // if a package of the same name is installed, reject it - guard !FileManager.default.fileExists(atPath: destinationURL.path) else { - throw DropKmpError.alreadyInstalled(fileLocation.lastPathComponent) - } - - return destinationURL - } - /** * Build the URL where the package will be installed */ @@ -660,45 +516,4 @@ public class SettingsContainer : ObservableObject { let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: kmpFileExtension, with: "") return self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) } - - /** - * Install the package from the dropped kmp file at the specified location - */ -// func installDroppedKmpFile(from droppedFileUrl: URL, to installPackageLocation: URL) throws { -// var newPackage: KeymanPackage? = nil -// -// try self.packageRepository.unzipKmpFile(at: droppedFileUrl, to: installPackageLocation) -// -// do { -// // load the unzipped package and get a reference to it -// newPackage = try self.packageRepository.loadSinglePackage(packageUrl: installPackageLocation) -// } -// catch { -// // the package could not be loaded, so remove it from the installation directory -// self.removeFailedInstallation(at: installPackageLocation) -// -// // re-throw error to notify user of reason installation failed -// throw error -// } -// -// if let installedPackage = newPackage { -// // add the newly installed package to the array and enable its keyboards -// self.installedPackages.append(installedPackage) -// self.addEnabledKeyboards(for: installedPackage) -// } -// } - - /** - * Clean up after a failed drag and drop installation - */ - func removeFailedInstallation(at installPackageLocation: URL) { - do { - if FileManager.default.fileExists(atPath: installPackageLocation.path) { - try FileManager.default.removeItem(at: installPackageLocation) - print("removed uninstalled dropped kmp file at: \(installPackageLocation)") - } - } catch { - print("could not remove uninstalled dropped kmp file: \(error.localizedDescription)") - } - } } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 1bebbe3b126..c25030d3d0c 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -37,8 +37,6 @@ public enum PackageInstallationType { @MainActor // run on the main actor as it is called from SettingsContainer public class PackageInstallHelper: Identifiable { public let id = UUID() -// public var installationError: LocalizedError? -// public var errorMessage: String? public let temporaryKmpFileLocation: URL let temporaryPackageLocation: URL let installPackageLocation: URL @@ -95,21 +93,6 @@ public class PackageInstallHelper: Identifiable { self.packageInstallationType = self.determinePackageInstallationType() } - /** - * Install the unzipped package - */ - public func install() throws { - print ("install \(self.packageToInstall?.packageName ?? "unknown package")") - - do { - try self.handleNewPackage() - } catch { - self.cleanupFailedInstallation() - print ("package installation failed with error '\(self.packageToInstall?.packageName ?? "unknown package")") - throw error - } - } - /** * Install the new package and replace existing package if necessary */ @@ -128,7 +111,6 @@ public class PackageInstallHelper: Identifiable { } } - /** * Unzip and load the downloaded package */ @@ -138,8 +120,6 @@ public class PackageInstallHelper: Identifiable { // 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 - - } /** @@ -180,26 +160,6 @@ public class PackageInstallHelper: Identifiable { return PackageInstallationType.packageNotFound } - /** - * 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. */ @@ -213,13 +173,6 @@ public class PackageInstallHelper: Identifiable { 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) */ @@ -232,8 +185,6 @@ public class PackageInstallHelper: Identifiable { print("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") } } - - NotificationCenter.default.post(name: .newPackageInstalled, object: nil) } /** @@ -249,8 +200,6 @@ public class PackageInstallHelper: Identifiable { } } try self.movePackageFromTemporaryToInstalled() - - NotificationCenter.default.post(name: .packageReplaced, object: nil) } /** From 3064a3d3ef30e9e4c11d6cac9b92c790ebbc33e5 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Fri, 21 Aug 2026 19:07:17 -0400 Subject: [PATCH 16/23] feat(mac): added error handling for failed downloads --- mac/Config/Config/AddKeyboardView.swift | 7 +++ mac/Config/Config/DownloadCoordinator.swift | 45 +++++++++++++------ .../KeymanSettings/SettingsContainer.swift | 16 +++++-- .../Persistence/Data/PackageSource.swift | 2 +- .../Persistence/PackageInstallHelper.swift | 7 +-- 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/mac/Config/Config/AddKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift index 57e150907a4..de1987c692b 100644 --- a/mac/Config/Config/AddKeyboardView.swift +++ b/mac/Config/Config/AddKeyboardView.swift @@ -29,6 +29,13 @@ struct AddKeyboardView: View { } } } + .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 diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index 85db15f86c9..9b21e6fb02d 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -13,6 +13,9 @@ import KeymanSettings public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate { @Published var showConfirmPackageSheet = false @Published var installHelper: PackageInstallHelper? + @Published var loadFailureMessage: String? + @Published var loadPackageFailed = false + var downloadFileUrl: URL? = nil var settings: SettingsContainer? @@ -83,10 +86,14 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega print("webView navigationResponse:didBecome called") download.delegate = self } - + public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) { print("download initiated") - + DispatchQueue.main.async { + self.loadFailureMessage = nil // Reset previous error + self.loadPackageFailed = false + } + guard let keymanSettings = self.settings else { print("tried to access settings before they were intialized in updateNSView") completionHandler(nil) @@ -103,34 +110,46 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega completionHandler(downloadFileUrl) } } catch { - print("could not initiate KMP package download") + print("could not initiate KMP package download, error: \(error)") completionHandler(nil) } - -// downloadFileUrl = keymanSettings.preparePackageDownload(kmpFileName: suggestedFilename) } - + +// func download(_ download: WKDownload, didStart navigationResponse: WKNavigationResponse) { +// print("download did start") +// DispatchQueue.main.async { +// self.loadFailureMessage = nil // Reset previous error +// self.loadPackageFailed = false +// } +// } +// public func downloadDidFinish(_ download: WKDownload) { - DispatchQueue.main.async { - // Trigger the SwiftUI modal sheet - self.showConfirmPackageSheet = true - } - if let downloadFileUrl { print("Download of \(downloadFileUrl.path()) was successful.") if let settings { + do { try settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) + DispatchQueue.main.async { + // Trigger the SwiftUI modal sheet + self.showConfirmPackageSheet = true + } } catch { - // MAC-CONFIG-TODO: communicate failed install to user + DispatchQueue.main.async { + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + } } } } } public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { - // MAC-CONFIG-TODO: communicate failed install to user print("Download failed with error: \(error.localizedDescription)") + DispatchQueue.main.async { + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + } } public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 090608159a8..a2ff120c8b9 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -398,7 +398,13 @@ public class SettingsContainer : ObservableObject { public func packageDownloadComplete(kmpFileUrl: URL) throws { print ("packageDownloadComplete \(kmpFileUrl)") - try self.packageInstall?.packageDownloadComplete(for: kmpFileUrl) + do { + try self.packageInstall?.packageDownloadComplete(for: kmpFileUrl) + } catch { + // clear failed download + self.packageInstall = nil + throw error + } } /** @@ -460,8 +466,12 @@ public class SettingsContainer : ObservableObject { */ public func installPackage() throws { if let install = self.packageInstall { - try install.installPackage() - + do { + try install.installPackage() + } catch { + self.packageInstall = nil + throw error + } commitPackageInstall() } } diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift index dc5702b343d..ffce791ac34 100644 --- a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift +++ b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift @@ -147,7 +147,7 @@ struct Website: Decodable { } struct SystemInfo: Decodable { - let keymanDeveloperVersion: String + let keymanDeveloperVersion: String? let fileVersion: String enum CodingKeys: String, CodingKey { diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index c25030d3d0c..53632f9f1ed 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -83,7 +83,7 @@ public class PackageInstallHelper: Identifiable { print ("prepareToInstall \(kmpFileUrl)") do { - try self.unzipPackage(for: kmpFileUrl) + try self.unzipAndLoadPackage(for: kmpFileUrl) } catch { self.cleanupFailedInstallation() print ("package installation failed with error '\(error)' for \(kmpFileUrl)") @@ -114,10 +114,11 @@ public class PackageInstallHelper: Identifiable { /** * Unzip and load the downloaded package */ - func unzipPackage(for kmpFileUrl: URL) throws { + func unzipAndLoadPackage(for kmpFileUrl: URL) throws { + // unzip to the temp directory try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) - // load the unzipped package from the temporary location and save a reference to it + // load the unzipped package from the temp directory and save a reference to it let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) self.packageToInstall = newPackage } From 69b43443ccd547f1722622589ac93cba9dd986fc Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Fri, 21 Aug 2026 21:48:58 -0400 Subject: [PATCH 17/23] feat(mac): added progress indicator for downloads --- mac/Config/Config/AddKeyboardView.swift | 43 +++++- mac/Config/Config/DownloadCoordinator.swift | 122 +++++++++++------- mac/Config/Installation/InputMethodUtil.swift | 3 + .../KeymanSettings/SettingsContainer.swift | 12 ++ 4 files changed, 132 insertions(+), 48 deletions(-) diff --git a/mac/Config/Config/AddKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift index de1987c692b..0069cf91596 100644 --- a/mac/Config/Config/AddKeyboardView.swift +++ b/mac/Config/Config/AddKeyboardView.swift @@ -10,17 +10,56 @@ import SwiftUI import KeymanSettings +struct VisualEffectBlur: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .hudWindow // Matches native dark/light HUD styling + view.blendingMode = .withinWindow + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + struct AddKeyboardView: View { @EnvironmentObject var settings: SettingsContainer @Environment(\.dismiss) private var dismissAddKeyboardView @StateObject private var downloadCoordinator = DownloadCoordinator() var body: some View { - VStack { + ZStack { KeyboardSearchView(coordinator: downloadCoordinator) .environmentObject(settings) .padding() + + if downloadCoordinator.isDownloading { + // Dim the background slightly to focus on the progress panel + Color.black.opacity(0.2) + .transition(.opacity) + + VStack(spacing: 16) { + Text("Downloading File...") + .font(.headline) + + // Native progress bar bound to the coordinator's value (0.0 to 1.0) + ProgressView(value: downloadCoordinator.downloadProgress, total: 1.0) + .progressViewStyle(.linear) + .frame(width: 250) + + Text("\(Int(downloadCoordinator.downloadProgress * 100))%") + .font(.body) + .foregroundColor(.secondary) + } + .padding(24) + // Gives it a beautiful native translucent macOS look + .background(VisualEffectBlur()) + .cornerRadius(12) + .shadow(radius: 10) + .transition(.scale.combined(with: .opacity)) + } } + .animation(.default, value: downloadCoordinator.isDownloading) .toolbar { // Placement determines where on the bar it sits ToolbarItem(placement: .cancellationAction) { @@ -30,7 +69,7 @@ struct AddKeyboardView: View { } } .alert("Package Installation Failed", isPresented: $downloadCoordinator.loadPackageFailed) { - Button("OK", role: .cancel) { } + Button("OK", role: .cancel) { } } message: { if let message = downloadCoordinator.loadFailureMessage { Text(message) diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index 9b21e6fb02d..577d795a28e 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -10,19 +10,29 @@ 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 showDownloadSheet = false + @Published var isDownloading = false + // progress is between 0.0 and 1.0 + @Published var downloadProgress: Double = 0.0 @Published var showConfirmPackageSheet = false @Published var installHelper: PackageInstallHelper? @Published var loadFailureMessage: String? @Published var loadPackageFailed = false - - var downloadFileUrl: URL? = nil + + var downloadFileUrl: URL? var settings: SettingsContainer? + private var progressObserver: NSKeyValueObservation? public func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - preferences: WKWebpagePreferences, - decisionHandler: @escaping @MainActor (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { + decidePolicyFor navigationAction: WKNavigationAction, + preferences: WKWebpagePreferences, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { print("deciding navigation based on action") @@ -52,8 +62,8 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega /** 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) { + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void) { print("deciding navigation based on response") if navigationResponse.canShowMIMEType { @@ -76,85 +86,105 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega } public func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { - print("webView navigationAction:didBecome called") + 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("webView navigationResponse:didBecome called") + navigationResponse: WKNavigationResponse, + didBecome download: WKDownload) { + print("📍 didBecome called via navigationResponse") download.delegate = self + + setupDownloadTracking(download) + } + + // Common setup function to attach the delegate and the KVO progress observer + private func setupDownloadTracking(_ download: WKDownload) { + download.delegate = self + + // reset progress states + self.isDownloading = true + self.downloadProgress = 0.0 + + progressObserver = download.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] _, change in + guard let newValue = change.newValue else { return } + + Task { @MainActor [weak self] in + self?.downloadProgress = newValue + print("Download Progress: \(Int(newValue * 100))%") + } + } } public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) { print("download initiated") - DispatchQueue.main.async { - self.loadFailureMessage = nil // Reset previous error - self.loadPackageFailed = false - } - + 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 + // 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.isDownloading = true + self.downloadProgress = 0.0 + self.loadFailureMessage = nil // Reset previous error + self.loadPackageFailed = false + self.installHelper = helper - downloadFileUrl = helper.temporaryKmpFileLocation - completionHandler(downloadFileUrl) + let targetUrl = helper.temporaryKmpFileLocation + self.downloadFileUrl = targetUrl + + completionHandler(targetUrl) } } catch { print("could not initiate KMP package download, error: \(error)") completionHandler(nil) } } - -// func download(_ download: WKDownload, didStart navigationResponse: WKNavigationResponse) { -// print("download did start") -// DispatchQueue.main.async { -// self.loadFailureMessage = nil // Reset previous error -// self.loadPackageFailed = false -// } -// } -// + public func downloadDidFinish(_ download: WKDownload) { + self.isDownloading = false + self.showDownloadSheet = true + self.progressObserver = nil + if let downloadFileUrl { print("Download of \(downloadFileUrl.path()) was successful.") if let settings { - do { try settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) - DispatchQueue.main.async { - // Trigger the SwiftUI modal sheet - self.showConfirmPackageSheet = true - } + // Trigger the SwiftUI modal sheet + self.showConfirmPackageSheet = true } catch { - DispatchQueue.main.async { - self.loadPackageFailed = true - self.loadFailureMessage = error.localizedDescription - } + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription } } } } - + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { print("Download failed with error: \(error.localizedDescription)") - DispatchQueue.main.async { - self.loadPackageFailed = true - self.loadFailureMessage = error.localizedDescription + self.isDownloading = false + self.progressObserver = nil + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + 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() + // The web process crashed. Reload the webview safely here. + print("WebKit process terminated unexpectedly: reloading content...") + webView.reload() } } diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/Config/Installation/InputMethodUtil.swift index 273868a4fb1..eaa2d9cc97f 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/Config/Installation/InputMethodUtil.swift @@ -247,6 +247,9 @@ public class InputMethodUtil { NSWorkspace.shared.openApplication(at: inputMethodUrl, configuration: openConfig) { (app, error) in if let error = error { print("Could not launch Keyman input method at \(inputMethodUrl), due to error: \(error.localizedDescription), code: \(error._code)") + Thread.callStackSymbols.forEach { symbol in + print(symbol) + } } } } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index a2ff120c8b9..d8dbfe1f943 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -176,6 +176,18 @@ public class SettingsContainer : ObservableObject { self.packageInstall = nil } + /** + * Called when user chooses to cancel downgrade of package + */ + public func packageInstallationFailed() { + if let install = self.packageInstall { + print("packageInstallationFailed") + install.cleanupFailedInstallation() + } + + self.packageInstall = nil + } + /** * for each enabled keyboard in the package being installed, add it to the enabled keyboards set and save it in the UserDefaults */ From 7c5bc212ab62ba7fd2783066adedbd9d5f71ec7f Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Sat, 22 Aug 2026 18:54:47 -0400 Subject: [PATCH 18/23] feat(mac): added internalError for debugging removed packageNotFound installationType --- mac/Config/Config/DownloadCoordinator.swift | 32 ++++++---- .../KeymanSettings/SettingsContainer.swift | 15 +---- .../Persistence/PackageInstallHelper.swift | 64 ++++++------------- 3 files changed, 44 insertions(+), 67 deletions(-) diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index 577d795a28e..285a5b837d1 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -3,7 +3,12 @@ * * Created by Shawn Schantz on 2026-08-21 * - * For coordination between WKWebview and SwiftUI views + * 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 @@ -16,7 +21,6 @@ import KeymanSettings @MainActor public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate { - @Published var showDownloadSheet = false @Published var isDownloading = false // progress is between 0.0 and 1.0 @Published var downloadProgress: Double = 0.0 @@ -71,6 +75,8 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega } else { guard let keymanSettings = self.settings else { print("webView decidePolicyFor:decisionHandler: no settings") + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.internalError.localizedDescription decisionHandler(.cancel) return } @@ -78,6 +84,8 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega // if a download is already in progress then stop another from starting if keymanSettings.isDownloadInProgress() { print("download already in progress, download canceled") + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.downloadInProgress.localizedDescription decisionHandler(.cancel) } else { decisionHandler(.download) @@ -124,6 +132,8 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega 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 } @@ -134,33 +144,30 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega do { if let helper = try keymanSettings.initiateKmpFileDownload(kmpFilename: suggestedFilename) { - self.isDownloading = true - self.downloadProgress = 0.0 self.loadFailureMessage = nil // Reset previous error self.loadPackageFailed = false self.installHelper = helper - let targetUrl = helper.temporaryKmpFileLocation - self.downloadFileUrl = targetUrl - completionHandler(targetUrl) + completionHandler(helper.temporaryKmpFileLocation) } } catch { - print("could not initiate KMP package download, error: \(error)") + 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.showDownloadSheet = true self.progressObserver = nil - if let downloadFileUrl { - print("Download of \(downloadFileUrl.path()) was successful.") + if let downloadDestination = installHelper?.temporaryKmpFileLocation { + print("Download of \(downloadDestination.path()) was successful.") if let settings { do { - try settings.packageDownloadComplete(kmpFileUrl: downloadFileUrl) + try settings.packageDownloadComplete(kmpFileUrl: downloadDestination) // Trigger the SwiftUI modal sheet self.showConfirmPackageSheet = true } catch { @@ -177,6 +184,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega self.progressObserver = nil self.loadPackageFailed = true self.loadFailureMessage = error.localizedDescription + self.installHelper = nil if let settings { settings.packageInstallationFailed() } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index d8dbfe1f943..a9ba652df92 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -27,10 +27,12 @@ import ZIPFoundation public enum InstallPackageError: LocalizedError { case downloadInProgress - + case internalError // due to invalid state, should never occur + public var errorDescription: String? { switch self { case .downloadInProgress: return "A download is already in progress." + case .internalError: return "An internal error occurred." } } } @@ -501,8 +503,6 @@ public class SettingsContainer : ObservableObject { self.addInstalledPackage() case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: self.replaceInstalledPackage() - case .packageNotFound: - print("commitPackageInstall: package not found") } } @@ -529,13 +529,4 @@ public class SettingsContainer : ObservableObject { throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) } } - - /** - * Build the URL where the package will be installed - */ - func buildInstalledPackageUrl(for draggedKmpFile: URL) -> URL? { - // package name is filename minus .kmp extension - let packageName = draggedKmpFile.lastPathComponent.replacingOccurrences(of: kmpFileExtension, with: "") - return self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) - } } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 53632f9f1ed..0067315ac03 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -16,7 +16,6 @@ public enum PackageInstallationType { case replaceSameVersionPackage(String) case replaceOlderPackage(String, String, String) case replaceNewerPackage(String, String, String) - case packageNotFound public var prompt: LocalizedStringResource { switch self { @@ -28,8 +27,6 @@ public enum PackageInstallationType { 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)" - case .packageNotFound: - return "No package to install" } } } @@ -89,8 +86,6 @@ public class PackageInstallHelper: Identifiable { print ("package installation failed with error '\(error)' for \(kmpFileUrl)") throw error } - - self.packageInstallationType = self.determinePackageInstallationType() } /** @@ -99,15 +94,17 @@ public class PackageInstallHelper: Identifiable { public func installPackage() throws { print ("installPackage \(self.packageToInstall?.packageName ?? "unknown package")") - guard let installationType = self.packageInstallationType else { return } + // 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() - case .packageNotFound: - throw DropKmpError.installFailed("unknown package installation type") } } @@ -119,8 +116,10 @@ public class PackageInstallHelper: Identifiable { try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) // load the unzipped package from the temp directory and save a reference to it - let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) - self.packageToInstall = newPackage + self.packageToInstall = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) + + // now that we know what we are installing, determine the type of install + self.packageInstallationType = self.determinePackageInstallationType() } /** @@ -131,13 +130,18 @@ public class PackageInstallHelper: Identifiable { */ func determinePackageInstallationType() -> PackageInstallationType { let packageAlreadyInstalled = self.checkForExistingPackage() + var installationType: PackageInstallationType = .newPackage("unknown package") + // If there is no new package, return bogus value of .newPackage. + // Without a package, the installation will fail elsewhere and the + // type of installation is completely irrelevant. guard let newPackage = self.packageToInstall else { - return PackageInstallationType.packageNotFound + print("error: packageToInstall not set when determining package installation type") + return installationType } if !packageAlreadyInstalled { - return PackageInstallationType.newPackage(newPackage.packageName) + installationType = PackageInstallationType.newPackage(newPackage.packageName) } else { if let installedPackage = self.packageToReplace { let newVersion = newPackage.packageVersion @@ -147,18 +151,18 @@ public class PackageInstallHelper: Identifiable { if comparisonResult == .orderedAscending { print("package downgrade: new version is older than existing version") - return PackageInstallationType.replaceNewerPackage(newPackage.packageName, existingVersion, newVersion) + installationType = PackageInstallationType.replaceNewerPackage(newPackage.packageName, existingVersion, newVersion) } else if comparisonResult == .orderedDescending { print("package upgrade: new version is newer than existing version") - return PackageInstallationType.replaceOlderPackage(newPackage.packageName, existingVersion, newVersion) + installationType = PackageInstallationType.replaceOlderPackage(newPackage.packageName, existingVersion, newVersion) } else { print("new and existing package versions are identical") - return PackageInstallationType.replaceSameVersionPackage(newPackage.packageName) + installationType = PackageInstallationType.replaceSameVersionPackage(newPackage.packageName) } } } - - return PackageInstallationType.packageNotFound + + return installationType } /** @@ -254,30 +258,4 @@ public class PackageInstallHelper: Identifiable { func deleteUnzippedPackage() 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 - } } From 95ae54ed233b7faaf88c97a95bce6ee59f945b7e Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 24 Aug 2026 16:32:22 -0400 Subject: [PATCH 19/23] feat(mac): added font validation and installation wait to unzip package before determining installation directory because the package name must be used for the final installation directory name --- mac/Config/Config/AddKeyboardView.swift | 28 ++- mac/Config/Config/DownloadCoordinator.swift | 23 +- .../InstallationViews/GradientDivider.swift | 2 +- mac/Config/Config/MainConfigView.swift | 8 +- .../KeymanSettings/SettingsContainer.swift | 48 ++--- .../Persistence/Data/PackageSource.swift | 17 +- .../Sources/Persistence/KeymanPaths.swift | 23 +- .../Persistence/PackageInstallHelper.swift | 198 +++++++++++++++--- 8 files changed, 261 insertions(+), 86 deletions(-) diff --git a/mac/Config/Config/AddKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift index 0069cf91596..c054aabe8ec 100644 --- a/mac/Config/Config/AddKeyboardView.swift +++ b/mac/Config/Config/AddKeyboardView.swift @@ -11,15 +11,15 @@ import SwiftUI import KeymanSettings struct VisualEffectBlur: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.material = .hudWindow // Matches native dark/light HUD styling - view.blendingMode = .withinWindow - view.state = .active - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .hudWindow // Matches native dark/light HUD styling + view.blendingMode = .withinWindow + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } struct AddKeyboardView: View { @@ -52,7 +52,7 @@ struct AddKeyboardView: View { .foregroundColor(.secondary) } .padding(24) - // Gives it a beautiful native translucent macOS look + // translucent macOS look .background(VisualEffectBlur()) .cornerRadius(12) .shadow(radius: 10) @@ -64,10 +64,18 @@ struct AddKeyboardView: View { // 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: { diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index 285a5b837d1..dc85491aae0 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -29,9 +29,9 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega @Published var loadFailureMessage: String? @Published var loadPackageFailed = false - var downloadFileUrl: URL? var settings: SettingsContainer? private var progressObserver: NSKeyValueObservation? + private var activeDownload: WKDownload? public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, @@ -82,10 +82,10 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega } // if a download is already in progress then stop another from starting - if keymanSettings.isDownloadInProgress() { + if keymanSettings.isInstallationInProgress() { print("download already in progress, download canceled") self.loadPackageFailed = true - self.loadFailureMessage = InstallPackageError.downloadInProgress.localizedDescription + self.loadFailureMessage = InstallPackageError.packageInstallationAlreadyInProgress.localizedDescription decisionHandler(.cancel) } else { decisionHandler(.download) @@ -113,6 +113,9 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega 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 @@ -127,6 +130,20 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega } } + /** + * 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") 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/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 9707091adca..956c903d56c 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -50,8 +50,10 @@ struct MainConfigView: View { // binds the visibility state to the sheet builder .sheet(isPresented: $isShowingAddKeyboardSheet) { AddKeyboardView() - .frame(width: 960, height: 390) + // disable escape key for closing view to avoid issues with canceling downloads + .interactiveDismissDisabled(true) // MAC-CONFIG-TODO: Make width and height percentages + .frame(width: 960, height: 390) } Form { @@ -70,7 +72,7 @@ struct MainConfigView: View { // accepts URL drops .dropDestination(for: URL.self) { urls, _ in // reject drop if it is more than one file - guard let droppedFileUrl = urls.first, urls.count == 1 else { + guard let droppedFileUrl = urls.first, urls.count < 2 else { let error = DropKmpError.tooManyFiles self.alertMessage = error.localizedDescription self.isShowingDropKmpAlert = true @@ -110,6 +112,8 @@ struct MainConfigView: View { 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 diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index a9ba652df92..8aec88ae4f6 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -26,12 +26,16 @@ import Combine import ZIPFoundation public enum InstallPackageError: LocalizedError { - case downloadInProgress + case packageInstallationAlreadyInProgress + case fontCopyError + case fontRegistrationError case internalError // due to invalid state, should never occur public var errorDescription: String? { switch self { - case .downloadInProgress: return "A download is already in progress." + case .packageInstallationAlreadyInProgress: return "A download 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." } } @@ -63,8 +67,8 @@ public enum DropKmpError: LocalizedError { } } -private let kmpFileExtension = ".kmp" -private let kmpFileExtensionWithoutDot = "kmp" +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 { @@ -85,7 +89,7 @@ public class SettingsContainer : ObservableObject { @Published public private(set) var multiKeyboardPackages: [KeymanPackage] // when a new package is being installed, it is tracked here - public private(set) var packageInstall: PackageInstallHelper? = nil + fileprivate var packageInstall: PackageInstallHelper? = nil fileprivate let packageRepository: PackageRepo fileprivate let defaultsRepository: DefaultsRepo @@ -170,10 +174,8 @@ public class SettingsContainer : ObservableObject { * Called when user chooses to cancel downgrade of package */ public func userCanceledPackageInstallation() { - if let install = self.packageInstall { - print("user cancelled package installation") - install.cleanupFailedInstallation() - } + print("user cancelled package installation") + self.packageInstall?.cleanupFailedInstallation() self.packageInstall = nil } @@ -182,10 +184,8 @@ public class SettingsContainer : ObservableObject { * Called when user chooses to cancel downgrade of package */ public func packageInstallationFailed() { - if let install = self.packageInstall { - print("packageInstallationFailed") - install.cleanupFailedInstallation() - } + print("packageInstallationFailed") + self.packageInstall?.cleanupFailedInstallation() self.packageInstall = nil } @@ -374,7 +374,7 @@ public class SettingsContainer : ObservableObject { /** * check whether a download is already in progress */ - public func isDownloadInProgress() -> Bool { + public func isInstallationInProgress() -> Bool { return self.packageInstall != nil } @@ -384,8 +384,8 @@ public class SettingsContainer : ObservableObject { */ public func initiateKmpFileDownload(kmpFilename: String) throws -> PackageInstallHelper? { - guard !self.isDownloadInProgress() else { - throw InstallPackageError.downloadInProgress + guard !self.isInstallationInProgress() else { + throw InstallPackageError.packageInstallationAlreadyInProgress } if let helper = self.preparePackageDownload(kmpFilename: kmpFilename) { @@ -399,10 +399,7 @@ public class SettingsContainer : ObservableObject { * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. */ func preparePackageDownload(kmpFilename: String) -> PackageInstallHelper? { - // package name is filename minus .kmp extension - let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") - - return PackageInstallHelper(filename: kmpFilename, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) + return PackageInstallHelper(filename: kmpFilename, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) } /** @@ -413,7 +410,7 @@ public class SettingsContainer : ObservableObject { print ("packageDownloadComplete \(kmpFileUrl)") do { - try self.packageInstall?.packageDownloadComplete(for: kmpFileUrl) + try self.packageInstall?.prepareToInstall(for: kmpFileUrl) } catch { // clear failed download self.packageInstall = nil @@ -453,8 +450,8 @@ public class SettingsContainer : ObservableObject { * Called when a .KMP file is dropped on the Configuration view */ public func initiateKmpFileInstallation(at fileLocation: URL) throws -> PackageInstallHelper? { - guard !self.isDownloadInProgress() else { - throw InstallPackageError.downloadInProgress + guard !self.isInstallationInProgress() else { + throw InstallPackageError.packageInstallationAlreadyInProgress } // validate the URL of the KMP file @@ -514,10 +511,7 @@ public class SettingsContainer : ObservableObject { * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. */ func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { - // package name is filename minus .kmp extension - let packageName = kmpFilename.replacingOccurrences(of: kmpFileExtension, with: "") - - return PackageInstallHelper(filename: kmpFilename, packageName: packageName, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) + return PackageInstallHelper(filename: kmpFilename, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) } /** diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift index ffce791ac34..f4e2dfaa095 100644 --- a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift +++ b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift @@ -29,11 +29,7 @@ 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 { @@ -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 { diff --git a/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift index 5244858eaa4..59bbf6d666e 100644 --- a/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift +++ b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift @@ -50,11 +50,32 @@ public struct KeymanPaths { static private let preKeyman19PackagesDirectoryName = "Keyman-Keyboards" static private let keymanSubdirectoryName = 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 +113,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() } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 0067315ac03..bbda370ece9 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -10,6 +10,7 @@ */ import Foundation +import CoreText public enum PackageInstallationType { case newPackage(String) @@ -36,9 +37,11 @@ public class PackageInstallHelper: Identifiable { public let id = UUID() public let temporaryKmpFileLocation: URL let temporaryPackageLocation: URL - let installPackageLocation: URL let installedPackages: [KeymanPackage] // needed to check for existing package after download let isDownload: Bool // if not download, then the package was opened from disk or dropped + + // 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? @@ -49,17 +52,16 @@ public class PackageInstallHelper: Identifiable { fileprivate let packageRepository: PackageRepo - public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { + public init(filename: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { self.packageRepository = packageRepo self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) - self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: packageName) - self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName) + + // filename minus .kmp extension + let directoryName = filename.replacingOccurrences(of: kmpFileExtension, with: "") + self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: directoryName) self.installedPackages = installedPackages self.isDownload = isDownload - // cannot be initialized until after download when packageName of new package is known - self.packageToReplace = nil - // if any packages are remaining from an earlier download, delete them self.packageRepository.cleanupTempDirectory() } @@ -72,7 +74,7 @@ public class PackageInstallHelper: Identifiable { try self.prepareToInstall(for: kmpFileUrl) } - + /** * Indicates that a package is ready to be unzipped and loaded */ @@ -80,7 +82,18 @@ public class PackageInstallHelper: Identifiable { print ("prepareToInstall \(kmpFileUrl)") do { - try self.unzipAndLoadPackage(for: kmpFileUrl) + // 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 + + // now that the package is loaded, we can build the installation directory from the packageName + self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: package.packageName) + + // now that we know what we are installing, determine the type of install + self.packageInstallationType = self.determinePackageInstallationType() } catch { self.cleanupFailedInstallation() print ("package installation failed with error '\(error)' for \(kmpFileUrl)") @@ -108,20 +121,24 @@ public class PackageInstallHelper: Identifiable { } } - /** - * Unzip and load the downloaded package - */ - func unzipAndLoadPackage(for kmpFileUrl: URL) throws { - // unzip to the temp directory - try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) - - // load the unzipped package from the temp directory and save a reference to it - self.packageToInstall = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) - - // now that we know what we are installing, determine the type of install - self.packageInstallationType = self.determinePackageInstallationType() - } - +// /** +// * Unzip and load the downloaded package +// */ +// func unzipAndLoadPackage(for kmpFileUrl: URL) throws { +// // unzip to the temp directory +// try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) +// +// // load the unzipped package from the temp directory and save a reference to it +// let package = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) +// self.packageToInstall = package +// +// // now that the package is loaded, we can build the installation directory from the packageName +// self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: package.packageName) +// +// // now that we know what we are installing, determine the type of install +// self.packageInstallationType = self.determinePackageInstallationType() +// } +// /** * Decides what type of package installation this is: * - a new package @@ -165,6 +182,122 @@ public class PackageInstallHelper: Identifiable { return installationType } + /** + * Install all fonts found in the package (files with an extension of .ttf or .otf). + * The fonts have been copied to the installation directory, so they sit at `installPackageLocation` + */ + func installFontsForPackage() throws { + let fileManager = FileManager.default + + guard let installLocation = self.installPackageLocation else { + print("error: installPackageLocation not set when installing fonts") + throw InstallPackageError.internalError + } + + let fileUrls = try fileManager.contentsOfDirectory( + at: installLocation, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + for fontUrl in fileUrls { + let ext = fontUrl.pathExtension.lowercased() + if ext == "ttf" || ext == "otf" { + if self.validateFont(at: fontUrl) { + try self.copyFontToFontsDirectory(at: fontUrl) + try self.registerFontWithSystem(at: fontUrl) + } else { + print("error: the font \(fontUrl.lastPathComponent) is not valid") + } + } + } + } + + /** + * 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. + */ + 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) + } + + do { + print("added font: \(fontDestinationUrl.lastPathComponent)") + try fileManager.copyItem(at: fontUrl, to: fontDestinationUrl) + } catch { + print("Error copying font: \(error.localizedDescription)") + throw InstallPackageError.fontCopyError + } + } + + /** + * 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: \(cfError.localizedDescription)") + 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 + } + } + /** * Check whether a package of the same name is already installed which may be replaced. */ @@ -183,6 +316,8 @@ public class PackageInstallHelper: Identifiable { */ func installNewPackage() throws { try self.movePackageFromTemporaryToInstalled() + try self.installFontsForPackage() + if (self.isDownload) { do { try self.deleteDownloadedKmpFile() @@ -205,6 +340,7 @@ public class PackageInstallHelper: Identifiable { } } try self.movePackageFromTemporaryToInstalled() + try self.installFontsForPackage() } /** @@ -230,18 +366,22 @@ public class PackageInstallHelper: Identifiable { * Delete the existing installed package that matches the downloaded package */ func deleteInstalledPackage() throws { - try FileManager.default.removeItem(at: self.installPackageLocation) + if let installLocation = self.installPackageLocation { + try FileManager.default.removeItem(at: installLocation) + } } /** * 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 + 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 + } } } From bb2613314b412fada7921aef0ac190df019d7480 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 24 Aug 2026 19:57:30 -0400 Subject: [PATCH 20/23] feat(mac): improve font installation error handling revise comments --- mac/Config/Config/DownloadCoordinator.swift | 2 +- .../KeymanSettings/SettingsContainer.swift | 4 +- .../Persistence/PackageInstallHelper.swift | 70 ++++++++----------- 3 files changed, 32 insertions(+), 44 deletions(-) diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index dc85491aae0..13b192fd69e 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -81,7 +81,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega return } - // if a download is already in progress then stop another from starting + // if an installation is already in progress then stop another from starting if keymanSettings.isInstallationInProgress() { print("download already in progress, download canceled") self.loadPackageFailed = true diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 8aec88ae4f6..3088b4fbcc3 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -33,7 +33,7 @@ public enum InstallPackageError: LocalizedError { public var errorDescription: String? { switch self { - case .packageInstallationAlreadyInProgress: return "A download is already in progress." + case .packageInstallationAlreadyInProgress: return "A package installatino 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." @@ -372,7 +372,7 @@ public class SettingsContainer : ObservableObject { // MARK: Package Download and Installation /** - * check whether a download is already in progress + * check whether an installation is already in progress */ public func isInstallationInProgress() -> Bool { return self.packageInstall != nil diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index bbda370ece9..394789c78c9 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -121,24 +121,6 @@ public class PackageInstallHelper: Identifiable { } } -// /** -// * Unzip and load the downloaded package -// */ -// func unzipAndLoadPackage(for kmpFileUrl: URL) throws { -// // unzip to the temp directory -// try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) -// -// // load the unzipped package from the temp directory and save a reference to it -// let package = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) -// self.packageToInstall = package -// -// // now that the package is loaded, we can build the installation directory from the packageName -// self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: package.packageName) -// -// // now that we know what we are installing, determine the type of install -// self.packageInstallationType = self.determinePackageInstallationType() -// } -// /** * Decides what type of package installation this is: * - a new package @@ -184,30 +166,40 @@ public class PackageInstallHelper: Identifiable { /** * Install all fonts found in the package (files with an extension of .ttf or .otf). - * The fonts have been copied to the installation directory, so they sit at `installPackageLocation` + * 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() throws { + func installFontsForPackage() { let fileManager = FileManager.default guard let installLocation = self.installPackageLocation else { print("error: installPackageLocation not set when installing fonts") - throw InstallPackageError.internalError + return } - let fileUrls = try fileManager.contentsOfDirectory( - at: installLocation, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) + 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 self.validateFont(at: fontUrl) { + // 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) - } else { - print("error: the font \(fontUrl.lastPathComponent) is not valid") + } catch { + print("error: the font \(fontUrl.lastPathComponent) could not be installed with error: \(String(describing: error))") } } } @@ -225,25 +217,21 @@ public class PackageInstallHelper: Identifiable { /** * 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) } - do { - print("added font: \(fontDestinationUrl.lastPathComponent)") - try fileManager.copyItem(at: fontUrl, to: fontDestinationUrl) - } catch { - print("Error copying font: \(error.localizedDescription)") - throw InstallPackageError.fontCopyError - } + try fileManager.copyItem(at: fontUrl, to: fontDestinationUrl) + print("added font: \(fontDestinationUrl.lastPathComponent)") } /** @@ -275,7 +263,7 @@ public class PackageInstallHelper: Identifiable { } // if it's any other error, capture it to throw later - print("registerFontWithSystem failed for \(fontUrl.lastPathComponent), error: \(cfError.localizedDescription)") + print("registerFontWithSystem failed for \(fontUrl.lastPathComponent), error: \(String(describing: cfError))") registrationError = InstallPackageError.fontRegistrationError } @@ -315,9 +303,6 @@ public class PackageInstallHelper: Identifiable { * Install the newly downloaded package (no existing package to replace) */ func installNewPackage() throws { - try self.movePackageFromTemporaryToInstalled() - try self.installFontsForPackage() - if (self.isDownload) { do { try self.deleteDownloadedKmpFile() @@ -325,6 +310,9 @@ public class PackageInstallHelper: Identifiable { print("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") } } + + try self.movePackageFromTemporaryToInstalled() + try self.installFontsForPackage() } /** From dd049266cec21c8a5ab6fa8c3a9a1ed4cc69caad Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 24 Aug 2026 20:29:48 -0400 Subject: [PATCH 21/23] feat(mac): comment revision --- mac/Config/Config/DownloadCoordinator.swift | 2 +- .../Sources/KeymanSettings/SettingsContainer.swift | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift index 13b192fd69e..8d54ad60624 100644 --- a/mac/Config/Config/DownloadCoordinator.swift +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -83,7 +83,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega // if an installation is already in progress then stop another from starting if keymanSettings.isInstallationInProgress() { - print("download already in progress, download canceled") + print("installation already in progress, download canceled") self.loadPackageFailed = true self.loadFailureMessage = InstallPackageError.packageInstallationAlreadyInProgress.localizedDescription decisionHandler(.cancel) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 3088b4fbcc3..7ed7ac5808a 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -33,7 +33,7 @@ public enum InstallPackageError: LocalizedError { public var errorDescription: String? { switch self { - case .packageInstallationAlreadyInProgress: return "A package installatino is already in progress." + 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." @@ -507,8 +507,7 @@ public class SettingsContainer : ObservableObject { } /** - * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. - * Returns a URL to the temporary location where the package is to be downloaded as a .kmp file. + * 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) From f006f0512d6f9d8558bd41aac4529a8dfd306500 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 25 Aug 2026 09:25:49 -0400 Subject: [PATCH 22/23] feat(mac): remove dependence on name of .kmp file promote delete package code to main view to avoid race condition --- mac/Config/Config/ConfigApp.swift | 3 +- mac/Config/Config/MainConfigView.swift | 57 +++++++++++-- mac/Config/Config/PackageRowView.swift | 52 +++++------- .../Sources/KeymanSettings/PackageRepo.swift | 2 +- .../KeymanSettings/SettingsContainer.swift | 5 +- .../Persistence/PackageInstallHelper.swift | 83 ++++++++++--------- .../Persistence/PackageRepository.swift | 5 +- .../Tests/KeymanSettingsTests/RepoStubs.swift | 2 +- 8 files changed, 131 insertions(+), 78 deletions(-) diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index dd6a872dd6e..637e0ee693c 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -18,8 +18,9 @@ struct ConfigApp: App { var body: some Scene { Window("Configuration", id: "main-config") { MainConfigView() +// .background(Color(.underPageBackgroundColor)) .frame( - minWidth: 600, maxWidth: 800, + minWidth: 600, maxWidth: 1000, minHeight: 400, maxHeight: .infinity ) .environmentObject(settings) diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 956c903d56c..2bec05dc8db 100644 --- a/mac/Config/Config/MainConfigView.swift +++ b/mac/Config/Config/MainConfigView.swift @@ -26,6 +26,23 @@ struct MainConfigView: View { @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 @@ -34,7 +51,7 @@ struct MainConfigView: View { packageSelectedForHelpUrl = url selectedTab = 1 } - + var body: some View { TabView (selection: $selectedTab) { VStack { @@ -56,16 +73,44 @@ struct MainConfigView: View { .frame(width: 960, height: 390) } - 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) + .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 + } + } // 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) @@ -106,6 +151,8 @@ struct MainConfigView: View { 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 { diff --git a/mac/Config/Config/PackageRowView.swift b/mac/Config/Config/PackageRowView.swift index b4705151d1a..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,11 +51,13 @@ 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 { @@ -120,23 +122,15 @@ public struct PackageRowView: View { } } } + .listRowBackground( + Rectangle() + .fill(cardColor) // native Mac card color = Color(.controlBackgroundColor) + ) } } // animate changes in the package list .animation(.easeInOut, value: packages) - // binds the visibilty state to the alert builder - .alert("Are you sure you want to delete the keyboard \"\(selectedPackage?.packageName ?? "")\"?", - isPresented: $isShowingDeleteAlert, - 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.") - } + .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/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift index 8b8a710b501..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 buildInstallationUrlForPackageName(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 7ed7ac5808a..438444729da 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -241,7 +241,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) } @@ -434,6 +434,8 @@ public class SettingsContainer : ObservableObject { */ 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) @@ -480,6 +482,7 @@ public class SettingsContainer : ObservableObject { do { try install.installPackage() } catch { + self.packageInstall?.cleanupFailedInstallation() self.packageInstall = nil throw error } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index 394789c78c9..e28c1ddec48 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -56,9 +56,8 @@ public class PackageInstallHelper: Identifiable { self.packageRepository = packageRepo self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) - // filename minus .kmp extension - let directoryName = filename.replacingOccurrences(of: kmpFileExtension, with: "") - self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: directoryName) + // 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 @@ -76,7 +75,8 @@ public class PackageInstallHelper: Identifiable { } /** - * Indicates that a package is ready to be unzipped and loaded + * 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)") @@ -89,11 +89,18 @@ public class PackageInstallHelper: Identifiable { let package = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) self.packageToInstall = package - // now that the package is loaded, we can build the installation directory from the packageName - self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: package.packageName) + // 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() + self.packageInstallationType = self.determinePackageInstallationType(newPackage: package) } catch { self.cleanupFailedInstallation() print ("package installation failed with error '\(error)' for \(kmpFileUrl)") @@ -127,37 +134,26 @@ public class PackageInstallHelper: Identifiable { * - an update of an existing package * - a downgrade of an existing package */ - func determinePackageInstallationType() -> PackageInstallationType { - let packageAlreadyInstalled = self.checkForExistingPackage() - var installationType: PackageInstallationType = .newPackage("unknown package") - - // If there is no new package, return bogus value of .newPackage. - // Without a package, the installation will fail elsewhere and the - // type of installation is completely irrelevant. - guard let newPackage = self.packageToInstall else { - print("error: packageToInstall not set when determining package installation type") - return installationType - } + func determinePackageInstallationType(newPackage: KeymanPackage) -> PackageInstallationType { + var installationType: PackageInstallationType = .newPackage(newPackage.packageName) + let packageAlreadyInstalled = self.packageToReplace != nil - if !packageAlreadyInstalled { - installationType = PackageInstallationType.newPackage(newPackage.packageName) - } else { - if let installedPackage = self.packageToReplace { - let newVersion = newPackage.packageVersion - let existingVersion = installedPackage.packageVersion - - let comparisonResult = newVersion.compare(existingVersion, options: .numeric) - - if comparisonResult == .orderedAscending { - print("package downgrade: new version is older than existing version") - installationType = PackageInstallationType.replaceNewerPackage(newPackage.packageName, existingVersion, newVersion) - } else if comparisonResult == .orderedDescending { - print("package upgrade: new version is newer than existing version") - installationType = PackageInstallationType.replaceOlderPackage(newPackage.packageName, existingVersion, newVersion) - } else { - print("new and existing package versions are identical") - installationType = PackageInstallationType.replaceSameVersionPackage(newPackage.packageName) - } + // 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) } } @@ -293,12 +289,23 @@ public class PackageInstallHelper: Identifiable { var packageExists = false if let package = self.installedPackages.first(where: { $0.packageName == self.packageToInstall?.packageName }) { - self.packageToReplace = package packageExists = true } return packageExists } + /** + * 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) */ diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index f5079680441..02e43251355 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -160,11 +160,12 @@ public class PackageRepository: PackageRepo { public func getUnzipDestinationUrl(for packageName: String) -> URL { return self.pathUtil.keyman19TempDirectory.appendingPathComponent(packageName) } + /** * build the URL where the specified package will be installed */ - public func buildInstallationUrlForPackageName(packageName: String) -> URL { - return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(packageName) + public func buildInstallationUrlForPackageName(directoryName: String) -> URL { + return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(directoryName) } /** diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift index 28ee3c4f01d..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 buildInstallationUrlForPackageName(packageName: String) -> URL { + func buildInstallationUrlForPackageName(directoryName: String) -> URL { return URL(fileURLWithPath: "") } From fa24b00e7aab086528bc6834dc68c0e496ab35fd Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 25 Aug 2026 20:47:22 -0400 Subject: [PATCH 23/23] feat(mac): reload packages after migrate moved InputMethodUtil to Settings --- mac/Config/Config/AddKeyboardView.swift | 29 ++++++------ mac/Config/Config/ConfigApp.swift | 13 ++--- mac/Config/Config/KeyboardSearchView.swift | 5 +- mac/Config/Config/MainConfigView.swift | 19 ++++++-- .../Installation/InstallationCheck.swift | 2 +- .../Installation/InstallationContainer.swift | 6 ++- .../KeymanSettings/ConfigAppUtil.swift | 2 + .../KeymanSettings}/InputMethodUtil.swift | 34 ++++++++------ .../KeymanSettings/SettingsContainer.swift | 47 +++++++++++++++---- .../Sources/Model/KeymanPackage.swift | 2 +- .../Sources/Persistence/KeymanPaths.swift | 28 +---------- .../Persistence/PackageInstallHelper.swift | 17 +------ .../Sources/Util/ConfigLogger.swift | 2 +- 13 files changed, 108 insertions(+), 98 deletions(-) rename mac/{Config/Installation => KeymanSettings/Sources/KeymanSettings}/InputMethodUtil.swift (92%) diff --git a/mac/Config/Config/AddKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift index c054aabe8ec..3a337a667e4 100644 --- a/mac/Config/Config/AddKeyboardView.swift +++ b/mac/Config/Config/AddKeyboardView.swift @@ -3,25 +3,13 @@ * * Created by Shawn Schantz on 2026-06-16 * - * Contains webview to search for keyboards and injects - * DownloadCoordinator to bridge back to SwiftUI + * Contains webview to search for keyboards + * Injects DownloadCoordinator to bridge back to SwiftUI */ import SwiftUI import KeymanSettings -struct VisualEffectBlur: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.material = .hudWindow // Matches native dark/light HUD styling - view.blendingMode = .withinWindow - view.state = .active - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} - struct AddKeyboardView: View { @EnvironmentObject var settings: SettingsContainer @Environment(\.dismiss) private var dismissAddKeyboardView @@ -106,3 +94,16 @@ struct AddKeyboardView: View { } } } + +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 637e0ee693c..a35fac4843e 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -18,7 +18,6 @@ struct ConfigApp: App { var body: some Scene { Window("Configuration", id: "main-config") { MainConfigView() -// .background(Color(.underPageBackgroundColor)) .frame( minWidth: 600, maxWidth: 1000, minHeight: 400, maxHeight: .infinity @@ -33,18 +32,20 @@ struct ConfigApp: App { } } // the size of the window when first opened - // .defaultSize(width: 1024, height: 768) .defaultSize(width: 800, height: 600) .windowResizability(.contentSize) + Window("Installation", id: "install") { MainInstallView() .environmentObject(installation) } .windowResizability(.contentSize) .defaultSize(width: 600, height: 500) - Window("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/KeyboardSearchView.swift b/mac/Config/Config/KeyboardSearchView.swift index 64b617cd1d0..aae6afa2bbd 100644 --- a/mac/Config/Config/KeyboardSearchView.swift +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -19,9 +19,6 @@ struct KeyboardSearchView: NSViewRepresentable { // 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 underlying NSView (WKWebView) for macOS */ func makeNSView(context: Context) -> WKWebView { @@ -31,7 +28,7 @@ struct KeyboardSearchView: NSViewRepresentable { // assign the coordinator as the navigation delegate webView.navigationDelegate = self.coordinator - let request = URLRequest(url: searchURL) + let request = URLRequest(url: settings.keyboardSearchUrl) webView.load(request) return webView } diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift index 2bec05dc8db..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 @@ -69,9 +68,8 @@ struct MainConfigView: View { AddKeyboardView() // disable escape key for closing view to avoid issues with canceling downloads .interactiveDismissDisabled(true) - // MAC-CONFIG-TODO: Make width and height percentages - .frame(width: 960, height: 390) - } + .frame(minWidth: 800, minHeight: 600) + } List { // the view for single keyboard packages @@ -85,6 +83,9 @@ struct MainConfigView: View { showHelpTab(for: url) }) } .listStyle(.inset) + + // confirmation dialog for deleting a package + .confirmationDialog( "Are you sure you want to delete the Keyman package '\(packageNameToDelete)'?", isPresented: Binding( @@ -111,6 +112,9 @@ struct MainConfigView: View { 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) @@ -134,12 +138,17 @@ struct MainConfigView: View { } isTargeted: { hovering in isHovering = hovering } - // alert triggers automatically when $isShowingDropKmpAlert is true + + // 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 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 92% rename from mac/Config/Installation/InputMethodUtil.swift rename to mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift index eaa2d9cc97f..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 { @@ -258,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 { @@ -295,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/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 438444729da..eab49aea78e 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -49,6 +49,12 @@ public extension Notification.Name { static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed") } +// in-app notifications +public extension Notification.Name { + // sent from InstallationContainer to SettingsContainer + static let dataMigrated = Notification.Name("com.keyman.data.migrated") +} + // 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 { @@ -98,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 = [] @@ -116,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.") @@ -134,6 +149,8 @@ public class SettingsContainer : ObservableObject { // next, apply the settings to the packages // this mainly consists of marking them as enabled or not self.applyUserDefaultsToInstalledPackages() + + self.registerObservers() } /** @@ -148,7 +165,24 @@ public class SettingsContainer : ObservableObject { self.multiKeyboardPackages = [] self.installedPackages = [] } - + + /** + * register observers to receive + */ + func registerObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(self.reloadPackages), name: .dataMigrated, object: nil) + } + + /** + * 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 public func reloadPackages() { + self.loadPackages() + self.applyUserDefaultsToInstalledPackages() + } + /** * Whenever the installedPackages array changes, recreate the two subarrays */ @@ -293,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() } /** diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index cedbdeb12de..3387ff3ac33 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -261,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/KeymanPaths.swift b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift index 59bbf6d666e..3cc1767b49a 100644 --- a/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift +++ b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift @@ -35,20 +35,11 @@ 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 @@ -248,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) } /** @@ -271,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/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift index e28c1ddec48..eda824f9733 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -136,7 +136,6 @@ public class PackageInstallHelper: Identifiable { */ func determinePackageInstallationType(newPackage: KeymanPackage) -> PackageInstallationType { var installationType: PackageInstallationType = .newPackage(newPackage.packageName) - let packageAlreadyInstalled = self.packageToReplace != nil // if we are replacing an existing package, then determine what type of replacement this is if let existingPackage = self.packageToReplace { @@ -282,18 +281,6 @@ public class PackageInstallHelper: Identifiable { } } - /** - * 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 }) { - packageExists = true - } - return packageExists - } - /** * If a package of the same name exists, return it. */ @@ -319,7 +306,7 @@ public class PackageInstallHelper: Identifiable { } try self.movePackageFromTemporaryToInstalled() - try self.installFontsForPackage() + self.installFontsForPackage() } /** @@ -335,7 +322,7 @@ public class PackageInstallHelper: Identifiable { } } try self.movePackageFromTemporaryToInstalled() - try self.installFontsForPackage() + self.installFontsForPackage() } /** 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