feat(mac): used shared webview for help and readme
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run

added default and min size for config view
This commit is contained in:
Shawn Schantz 2026-08-19 22:02:23 -04:00
parent f194a15c7b
commit 52d41e7932
8 changed files with 267 additions and 118 deletions

View file

@ -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 = "<group>";
};

View file

@ -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)

View file

@ -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())
}
}
}
}

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)
}