mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-26 17:47:40 +00:00
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run
also fixed bug where InstallState not initialzied when installation evaluation is not happening
413 lines
14 KiB
Swift
413 lines
14 KiB
Swift
/*
|
|
* Keyman is copyright (C) SIL Global. MIT License.
|
|
*
|
|
* Created by Shawn Schantz on 2026-02-24
|
|
*
|
|
* Manages the steps for completing the installation of the Keyman input method
|
|
*
|
|
*/
|
|
import SwiftUI
|
|
import Combine
|
|
import KeymanSettings
|
|
|
|
// in-app notifications sent
|
|
public extension Notification.Name {
|
|
static let startNewInstallation = Notification.Name("start.new.installation")
|
|
static let startInstallationRepair = Notification.Name("start.installation.repair")
|
|
static let installationRepairStarted = Notification.Name("installation.repair.started")
|
|
}
|
|
|
|
@MainActor // run on the main actor since data is published directly to the UI
|
|
public class InstallationContainer : ObservableObject {
|
|
public var installationPhase: InstallationPhase {
|
|
return self.installationCheck.installationPhase
|
|
}
|
|
|
|
// installationState describes the remaining tasks to complete the installation
|
|
@Published public var installationState: InstallationState?
|
|
|
|
fileprivate let installationCheck: InstallationCheck
|
|
fileprivate let defaultsRepository: DefaultsRepo
|
|
fileprivate let inputMethodUtil: InputMethodUtil
|
|
|
|
public init() {
|
|
let defaultsRepo: DefaultsRepository
|
|
// create the settings repository, gaining access to the app group UserDefaults
|
|
do {
|
|
defaultsRepo = try DefaultsRepository(suiteName: KeymanPaths.groupId)
|
|
print("Found group container")
|
|
} catch UserDefaultsError.unknownSuite {
|
|
fatalError("Group container not found.")
|
|
} catch {
|
|
fatalError("Unable to access settings in group container.")
|
|
}
|
|
|
|
self.defaultsRepository = defaultsRepo
|
|
|
|
do {
|
|
try inputMethodUtil = InputMethodUtil()
|
|
} catch {
|
|
fatalError("Unable to access group container path for InputMethodUtil: \(error.localizedDescription).")
|
|
}
|
|
|
|
|
|
self.installationCheck = InstallationCheck(defaultsRepo: defaultsRepo, inputMethodUtil: inputMethodUtil)
|
|
|
|
// if installation is still being evaluated, set the installation state later when it is done
|
|
// otherwise, set it immediately
|
|
if self.installationCheck.isEvaluatingInstallation {
|
|
self.installationState = nil
|
|
} else {
|
|
self.installationState = self.installationCheck.installationState
|
|
print("installation phase = \(self.installationCheck.installationPhase), hasTasks = \(self.installationCheck.installationPhase.hasTasks)")
|
|
}
|
|
|
|
self.registerObservers()
|
|
|
|
self.installationCheck.startInstallationEvaluation()
|
|
}
|
|
|
|
/**
|
|
* register observers to learn of results of InstallationState evaluation
|
|
*/
|
|
func registerObservers() {
|
|
print("InstallationContainer registerObservers")
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(self.handleStartNewInstallation(_:)),
|
|
name: NSNotification.Name.startNewInstallation,
|
|
object: nil // Observe notifications from any sender
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(self.handleStartInstallationRepair(_:)),
|
|
name: NSNotification.Name.startInstallationRepair,
|
|
object: nil // Observe notifications from any sender
|
|
)
|
|
}
|
|
|
|
/**
|
|
* called when `NSNotification.Name.startNewInstallation` is received
|
|
*/
|
|
@objc func handleStartNewInstallation(_ notification: Notification) {
|
|
// now that the installation has been evaluated
|
|
// we can set the installationState
|
|
|
|
if let newState = notification.object as? InstallationState {
|
|
self.installationState = newState
|
|
|
|
// the evaluation is done
|
|
self.installationCheck.isEvaluatingInstallation = false
|
|
print("handleStartNewInstallation, new state provided")
|
|
} else {
|
|
print("handleStartNewInstallation received but did not include new InstallationState")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* called when `NSNotification.Name.startInstallationRepair` is received
|
|
*/
|
|
@objc func handleStartInstallationRepair(_ notification: Notification) {
|
|
print("handleStartInstallationRepair")
|
|
// Extract message from the notification if available
|
|
if let newState = notification.object as? InstallationState {
|
|
self.installationState = newState
|
|
|
|
// now that the repair has been determined, we can notify that a repair is needed
|
|
// so that the repair UI can presented to the user
|
|
|
|
NotificationCenter.default.post(name: .installationRepairStarted, object: nil, userInfo: nil)
|
|
} else {
|
|
print("handleStartInstallationRepair received but did not include new InstallationState")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the Accessibility permission has been granted by the user for the Keyman input method.
|
|
* This is an optional return value because it is only set in response to a call to `checkAccessibilityPermissionGranted`
|
|
* and is not populated until an asynchronous message is received in response.
|
|
*/
|
|
public func isAccessibilityPermissionGranted() -> Bool? {
|
|
return self.inputMethodUtil.accessibilityPermissionGranted
|
|
}
|
|
|
|
/**
|
|
* return trues if every installation task has been completed
|
|
*/
|
|
public func isInstallationComplete() -> Bool {
|
|
guard let state = self.installationState else { return false }
|
|
|
|
return state.isComplete
|
|
}
|
|
|
|
/**
|
|
* Returns the current incompleted installation task, if there is one.
|
|
* Note that this function determines the order in which the tasks are executed as they are stored in an unsorted Set.
|
|
*/
|
|
public func currentTask() -> InstallationTask? {
|
|
guard let state = self.installationState else { return nil }
|
|
guard self.installationPhase.hasTasks else {
|
|
print("the installation phase \(self.installationPhase) has no tasks");
|
|
return nil
|
|
}
|
|
|
|
let incompleteTasks = state.tasks.filter { !$0.isComplete }
|
|
|
|
if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .prepareNewInstall }) {
|
|
return incompleteTask
|
|
} else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .enableInputMethod }) {
|
|
return incompleteTask
|
|
} else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .requestAccess }) {
|
|
return incompleteTask
|
|
} else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .restartMac }) {
|
|
return incompleteTask
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/**
|
|
* Executes the specified installation task.
|
|
*/
|
|
func executeTask(_ task: InstallationTask) {
|
|
guard let state = self.installationState else { return }
|
|
guard self.installationPhase.hasTasks else {
|
|
print("the installation phase \(self.installationPhase) has no tasks");
|
|
return
|
|
}
|
|
|
|
var completedTask = false
|
|
|
|
switch task.taskType {
|
|
case .prepareNewInstall:
|
|
completedTask = self.migrateData()
|
|
case .enableInputMethod:
|
|
completedTask = self.enableKeymanInputMethod()
|
|
case .requestAccess:
|
|
completedTask = self.requestAccessibility()
|
|
case .restartMac:
|
|
completedTask = self.notifyUserPromptedToRestart()
|
|
}
|
|
|
|
if completedTask {
|
|
print("executeTask: \(task.taskType.rawValue) completed")
|
|
state.updateTaskAsCompleted(task: task.taskType)
|
|
self.writeInstallationState()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes the next installation task which is incomplete, if there is one remaining.
|
|
*/
|
|
public func executeNextInstallationTask() {
|
|
if let installTask = self.currentTask() {
|
|
self.executeTask(installTask)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the Keyman input method as a subprocess to migrate data to the shared space and immediately exit
|
|
*/
|
|
public func migrateData() -> Bool {
|
|
let success = self.inputMethodUtil.invokeKeymanInputMethodMigration()
|
|
print("migration suceeded: \(success)")
|
|
|
|
return success
|
|
}
|
|
|
|
/**
|
|
* Save the installation state
|
|
*/
|
|
func writeInstallationState() {
|
|
guard let state = self.installationState else { return }
|
|
|
|
self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary())
|
|
}
|
|
|
|
/**
|
|
* Write the time that the user was requested to restart their machine
|
|
*/
|
|
func writeRestartRequestTime() {
|
|
guard let state = self.installationState else { return }
|
|
|
|
state.dateRestartRequested = Date()
|
|
self.writeInstallationState()
|
|
}
|
|
|
|
/**
|
|
* Read the time that the user was requested to restart their machine
|
|
*/
|
|
func readRestartRequestTime() -> Date? {
|
|
guard let state = self.installationState else { return nil }
|
|
|
|
return state.dateRestartRequested
|
|
}
|
|
|
|
/**
|
|
* Notify that the user has been prompted to restart the machine.
|
|
*/
|
|
public func notifyUserPromptedToRestart() -> Bool {
|
|
self.writeRestartRequestTime()
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Check whether the user has restarted by comparing the latest startup time to the time we requested the user to restart
|
|
*/
|
|
public func validateUserHasRestarted() -> Bool {
|
|
var hasRestarted = false
|
|
|
|
guard let state = self.installationState else { return false }
|
|
|
|
if let timeRestartRequested = state.dateRestartRequested {
|
|
if let mostRecentStartupTime = self.getMostRecentRestartTime() {
|
|
hasRestarted = mostRecentStartupTime > timeRestartRequested
|
|
print("mostRecentStartupTime: \(mostRecentStartupTime), timeRestartRequested: \(timeRestartRequested)")
|
|
}
|
|
}
|
|
print("validateRestarted: \(hasRestarted)")
|
|
return hasRestarted
|
|
}
|
|
|
|
// MAC-CONFIG-TODO: delete test code
|
|
/**
|
|
* for testing purposes, replace the InstallationState with a new object set for a new installation
|
|
*/
|
|
func forceResetInstallation() {
|
|
self.installationState = InstallationCheck(defaultsRepo: self.defaultsRepository, inputMethodUtil: self.inputMethodUtil).createInstallationStateForNewInstallation()
|
|
}
|
|
|
|
/**
|
|
* for testing purposes, validate the installation
|
|
*/
|
|
func forceValidateInstallation() {
|
|
self.installationCheck.startValidation()
|
|
}
|
|
|
|
/**
|
|
* return the last time the system was booted
|
|
*/
|
|
func getMostRecentRestartTime() -> Date? {
|
|
var timeSince1970 = timeval()
|
|
var size = MemoryLayout<timeval>.size
|
|
|
|
// Query the kernel for the boot time
|
|
let result = sysctlbyname("kern.boottime", &timeSince1970, &size, nil, 0)
|
|
|
|
if result == 0 {
|
|
// Convert the timeval (seconds since 1970) into a Swift Date
|
|
return Date(timeIntervalSince1970: Double(timeSince1970.tv_sec) + Double(timeSince1970.tv_usec) / 1_000_000.0)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/**
|
|
* used to report on some current state
|
|
*/
|
|
public func debug() {
|
|
var permissionString = "unknown"
|
|
if let permissionGranted = self.isAccessibilityPermissionGranted() {
|
|
permissionString = permissionGranted ? "granted" : "denied"
|
|
}
|
|
|
|
let version = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown"
|
|
let enabled = inputMethodUtil.isKeymanInputMethodEnabled()
|
|
let running = inputMethodUtil.isKeymanInputMethodRunning()
|
|
|
|
print("Keyman status, version: \(version), enabled: \(enabled), running: \(running), permissionGranted: \(permissionString)")
|
|
}
|
|
|
|
|
|
/**
|
|
* register may need to happen before enabling
|
|
*/
|
|
public func registerKeymanInputMethod() -> Bool {
|
|
let success = self.inputMethodUtil.registerKeymanInputMethod()
|
|
print("registerKeymanInputMethod suceeded: \(success)")
|
|
|
|
return success
|
|
}
|
|
|
|
/**
|
|
* set Keyman as the current input method (same effect as choosing Keyman in the input source menu)
|
|
*/
|
|
public func selectKeymanInputMethod() -> Bool {
|
|
let success = self.inputMethodUtil.selectKeymanInputMethod()
|
|
print("selectKeymanInputMethod suceeded: \(success)")
|
|
|
|
return success
|
|
}
|
|
|
|
/**
|
|
* Verify that the input method has been correctly installed.
|
|
* This may be superflous as we are verifying this before creating the installation tasks
|
|
*/
|
|
public func verifyInputMethod() -> Bool {
|
|
// MAC-CONFIG-TODO: currently does nothing, already verified when InstallationCheck is created
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* true if the system recognizes Keyman as an enabled input method
|
|
*/
|
|
public func isKeymanInputMethodEnabled() -> Bool {
|
|
return inputMethodUtil.isKeymanInputMethodEnabled()
|
|
}
|
|
|
|
/**
|
|
* ask the system to enable the Keyman input method
|
|
* register it first, just to be safe
|
|
*/
|
|
public func enableKeymanInputMethod() -> Bool {
|
|
var success = self.inputMethodUtil.registerInputMethod(bundleId: KeymanPaths.keymanBundleId)
|
|
if success {
|
|
success = self.inputMethodUtil.enableKeymanInputMethod()
|
|
}
|
|
|
|
print("enableKeymanInputMethod suceeded: \(success)")
|
|
return success
|
|
}
|
|
|
|
/**
|
|
* call Keyman as a separate process with an argument that checks whether accessibility has been granted by the user
|
|
*/
|
|
public func checkAccessibilityPermissionGranted() {
|
|
self.inputMethodUtil.doAsyncAccessibilityCheck()
|
|
}
|
|
|
|
/**
|
|
* Call Keyman as a separate process with an argument that requests the system to prompt the user to grant accessibility.
|
|
* To learn the result, we must poll with `checkAccessibilityPermissionGranted()`
|
|
*/
|
|
public func requestAccessibility() -> Bool {
|
|
var requested = false
|
|
|
|
requested = self.inputMethodUtil.invokeKeymanInputMethodRequestAccess()
|
|
print("requestAccessibility called, requested: \(requested)")
|
|
|
|
return requested
|
|
}
|
|
|
|
/**
|
|
* kill the Keyman Input Method process
|
|
*/
|
|
public func killKeymanInputMethod() -> Bool {
|
|
return self.inputMethodUtil.killKeymanInputMethod()
|
|
}
|
|
|
|
/**
|
|
* disable Keyman as an Input Method
|
|
*/
|
|
public func disableKeymanInputMethod() -> Bool {
|
|
return self.inputMethodUtil.disableKeymanInputMethod()
|
|
}
|
|
|
|
/**
|
|
* uninstall the Keyman Input Method
|
|
* not functional with default security settings!
|
|
*/
|
|
public func uninstall() {
|
|
self.inputMethodUtil.uninstallKeyman()
|
|
}
|
|
}
|