Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions mac/Config/Config.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@
D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
D88532ED3040EF85009D61EE /* Exceptions for "Config" folder in "Config" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = D88F03C52F50ED5000C02A31 /* Config */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
D87D6F492FAF95400083A95E /* Installation */ = {
isa = PBXFileSystemSynchronizedRootGroup;
Expand All @@ -41,6 +51,9 @@
};
D88F03C82F50ED5000C02A31 /* Config */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D88532ED3040EF85009D61EE /* Exceptions for "Config" folder in "Config" target */,
);
path = Config;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -420,7 +433,8 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "$(COPYRIGHT_STRING)";
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © SIL Global.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
Expand All @@ -433,12 +447,18 @@
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Keyman Mac Config Dev";
REGISTER_APP_GROUPS = YES;
SENTRY_ENVIRONMENT = local;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 6.0;
TIER = alpha;
VERSION_GIT_TAG = "release@19.0.267-alpha-local";
VERSION_RELEASE = 19.0;
VERSION_TAG = "-alpha-local";
VERSION_WITH_TAG = "19.0.267-alpha-local";
};
name = Debug;
};
Expand All @@ -459,7 +479,8 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "$(COPYRIGHT_STRING)";
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © SIL Global.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
Expand All @@ -472,12 +493,18 @@
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Keyman Mac Config Dist";
REGISTER_APP_GROUPS = YES;
SENTRY_ENVIRONMENT = local;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 6.0;
TIER = alpha;
VERSION_GIT_TAG = "release@19.0.267-alpha-local";
VERSION_RELEASE = 19.0;
VERSION_TAG = "-alpha-local";
VERSION_WITH_TAG = "19.0.267-alpha-local";
};
name = Release;
};
Expand Down
12 changes: 12 additions & 0 deletions mac/Config/Config/ConfigApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@

import SwiftUI
import KeymanSettings
import OSLog

extension Logger {
private static let configSubsystem = ConfigAppUtil.configBundleId
static let package = Logger(subsystem: configSubsystem, category: "package")
static let download = Logger(subsystem: configSubsystem, category: "download")
static let ui = Logger(subsystem: configSubsystem, category: "ui")
}

@main
struct ConfigApp: App {
@StateObject var settings = SettingsContainer()
@StateObject var installation = InstallationContainer()
@Environment(\.openWindow) private var openWindow

init() {
print("tier: \(ConfigAppUtil.appTier)")
}

var body: some Scene {
Window("Configuration", id: "main-config") {
MainConfigView()
Expand Down
137 changes: 57 additions & 80 deletions mac/Config/Config/DownloadCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Created by Shawn Schantz on 2026-08-21
*
* For coordination between WKWebview and SwiftUI views.
* Implements WKNavigationDelegate and WKDownloadDelegate to trigger downloads
* Implements WKNavigationDelegate and WKDownloadDelegate to support 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
Expand All @@ -14,6 +14,7 @@
import WebKit
import Combine
import KeymanSettings
import OSLog

// safe to designate the whole Coordinator class as @MainActor with Swift 6.0
// when delegate calls come on a background thread, Swift 6 will
Expand All @@ -24,95 +25,72 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
@Published var isDownloading = false
// progress is between 0.0 and 1.0
@Published var downloadProgress: Double = 0.0

@Published var showConfirmPackageSheet = false
@Published var installHelper: PackageInstallHelper?

@Published var loadFailureMessage: String?
@Published var loadPackageFailed = false

var settings: SettingsContainer?
private var progressObserver: NSKeyValueObservation?
private var activeDownload: WKDownload?

public func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
preferences: WKWebpagePreferences,
decisionHandler: @escaping @MainActor (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) {

print("deciding navigation based on action")

if let url = navigationAction.request.url {
print("webView navigationAction.request.url: \(url)")
}

// Swift regex literals, introduced in macOS 13+ and using instead of NSRegularExpression
private static let regexInstall = /^http(s)?:\/\/keyman(-staging)?\.com(\.localhost)?\/keyboards\/install\/([^?\/]+)(\?(.+))?$/
private static let regexRoot = /^http(s)?:\/\/keyman(-staging)?\.com(\.localhost)?\/keyboards([\/?].*)?$/
private static let regexGo = /^http(s)?:\/\/keyman(-staging)?\.com(\.localhost)?\/go\/macos\/[^\/]+\/download-keyboards/

public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) {

// Trust HTML download attribute if present
if navigationAction.shouldPerformDownload {
print("webView called decisionHandler for download")
decisionHandler(.download, preferences)
guard let urlString = navigationAction.request.url?.absoluteString else {
decisionHandler(.cancel)
return
}
Logger.download.info("received url: \(urlString, privacy: .public)")

// 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
// if the url matches the install url pattern, then cancel the request,
// build the standard URLRequest for a package installation and send it
if let match = try? DownloadCoordinator.regexInstall.firstMatch(in: urlString) {
decisionHandler(.cancel)

// get the package id (though it appears to be identifying a keyboard in the URL)
let matchPackageId = String(match.4)
if let downloadUrl = self.settings?.buildDownloadPackageUrl(for: matchPackageId) {
Logger.download.info("package install, download url = \(downloadUrl.absoluteString, privacy: .public)")

let newRequest = URLRequest(url: downloadUrl)

DispatchQueue.main.async {
webView.startDownload(using: newRequest) { download in
Logger.download.info("download initiated to \(newRequest.url?.absoluteString ?? "nil", privacy: .public)")
download.delegate = self
self.setupDownloadTracking(download)
}
}
}
}

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 {
else if urlString.contains(DownloadCoordinator.regexRoot) ||
urlString.contains(DownloadCoordinator.regexGo) {
Logger.download.info("requested root or go url: load in webview")

decisionHandler(.allow)
} else {
guard let keymanSettings = self.settings else {
print("webView decidePolicyFor:decisionHandler: no settings")
self.loadPackageFailed = true
self.loadFailureMessage = InstallPackageError.internalError.localizedDescription
decisionHandler(.cancel)
return
}

// if an installation is already in progress then stop another from starting
if keymanSettings.isInstallationInProgress() {
print("installation already in progress, download canceled")
self.loadPackageFailed = true
self.loadFailureMessage = InstallPackageError.packageInstallationAlreadyInProgress.localizedDescription
decisionHandler(.cancel)
} else {
decisionHandler(.download)
}
else {
Logger.download.info("default case, open in external browser")

decisionHandler(.cancel)
if let targetUrl = URL(string: urlString) {
NSWorkspace.shared.open(targetUrl)
}
}
}

public func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
print("📍 didBecome called via navigationAction")
download.delegate = self // Assign delegate for file saving

setupDownloadTracking(download)
}

public func webView(_ webView: WKWebView,
navigationResponse: WKNavigationResponse,
didBecome download: WKDownload) {
print("📍 didBecome called via navigationResponse")
download.delegate = self

setupDownloadTracking(download)
}

// Common setup function to attach the delegate and the KVO progress observer

/**
* Setup the observer to track progress of the download.
*/
private func setupDownloadTracking(_ download: WKDownload) {
download.delegate = self

// record download in case we need to cancel
self.activeDownload = download

Expand All @@ -125,7 +103,6 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega

Task { @MainActor [weak self] in
self?.downloadProgress = newValue
print("Download Progress: \(Int(newValue * 100))%")
}
}
}
Expand All @@ -145,10 +122,10 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
}

public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) {
print("download initiated")
Logger.download.debug("download initiated")

guard let keymanSettings = self.settings else {
print("tried to access settings before they were intialized in updateNSView")
Logger.download.error("tried to access settings before they were intialized")
self.loadPackageFailed = true
self.loadFailureMessage = InstallPackageError.internalError.localizedDescription
completionHandler(nil)
Expand All @@ -160,7 +137,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega

do {
if let helper = try keymanSettings.initiateKmpFileDownload(kmpFilename: suggestedFilename) {

Logger.download.info("download suggested filename: \(suggestedFilename, privacy: .public)")
self.loadFailureMessage = nil // Reset previous error
self.loadPackageFailed = false

Expand All @@ -169,7 +146,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
completionHandler(helper.temporaryKmpFileLocation)
}
} catch {
print("Could not initiate package download, error: \(error)")
Logger.download.error("could not initiate package download, error: \(String(describing: error), privacy: .public)")
self.loadPackageFailed = true
self.loadFailureMessage = error.localizedDescription
completionHandler(nil)
Expand All @@ -181,7 +158,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
self.progressObserver = nil

if let downloadDestination = installHelper?.temporaryKmpFileLocation {
print("Download of \(downloadDestination.path()) was successful.")
Logger.download.info("download of \(downloadDestination.path, privacy: .public) was successful.")
if let settings {
do {
try settings.packageDownloadComplete(kmpFileUrl: downloadDestination)
Expand All @@ -196,7 +173,7 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
}

public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
print("Download failed with error: \(error.localizedDescription)")
Logger.download.error("download failed with error: \(String(describing: error), privacy: .public)")
self.isDownloading = false
self.progressObserver = nil
self.loadPackageFailed = true
Expand All @@ -208,8 +185,8 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
}

public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// The web process crashed. Reload the webview safely here.
print("WebKit process terminated unexpectedly: reloading content...")
// The web process crashed. Reload the webview safely.
Logger.download.error("webkit process terminated unexpectedly: reloading content")
webView.reload()
}
}
21 changes: 21 additions & 0 deletions mac/Config/Config/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Keyman</key>
<dict>
<key>SentryEnvironment</key>
<string>$(SENTRY_ENVIRONMENT)</string>
<key>Tier</key>
<string>$(TIER)</string>
<key>VersionGitTag</key>
<string>$(VERSION_GIT_TAG)</string>
<key>VersionRelease</key>
<string>$(VERSION_RELEASE)</string>
<key>VersionTag</key>
<string>$(VERSION_TAG)</string>
<key>VersionWithTag</key>
<string>$(VERSION_WITH_TAG)</string>
</dict>
</dict>
</plist>
15 changes: 0 additions & 15 deletions mac/Keyman.xcworkspace/xcshareddata/swiftpm/Package.resolved

This file was deleted.

Loading