diff --git a/mac/Config/Config.xcodeproj/project.pbxproj b/mac/Config/Config.xcodeproj/project.pbxproj index 3b8bb555a0..d4a05511f9 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 49f8d2c649..2841c45a43 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 d4f603364e..0000000000 --- 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 92d9946d49..0935923dbb 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 0000000000..3881ecdd5d --- /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 0000000000..f8d8d80ada --- /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 22865ae15e..4fc30acbcd 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 06c4f9e305..ad01a2062d 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) }