/* * 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 import OSLog // 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") static let accessibilityGranted = Notification.Name("installation.accessibility.granted") static let accessibilityNotGranted = Notification.Name("installation.accessibility.not.granted") static let checkAccessibilitySuccess = Notification.Name("accessibility.success") static let checkAccessibilityFailure = Notification.Name("accessibility.failure") } @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 } var installationState: InstallationState? { return self.installationCheck.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: InputMethodUtil.groupId) Logger.app.log("found group container") } catch UserDefaultsError.unknownSuite { Logger.app.error("group container not found: \(UserDefaultsError.unknownSuite)") fatalError("Group container not found.") } catch { Logger.app.error("unable to access settings in group container: \(error as NSError, privacy: .public)") 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 we can now confirm that the user restarted (the final task), then the installation // will be complete and there is no need to evaluate the state. // Otherwise, evaluate the installation to prepare for a new installation or check for repairs. if self.confirmRestartRequired() { self.confirmUserRestarted() } else { self.registerObservers() self.installationCheck.startInstallationEvaluation() } } /** * register observers to learn of results of InstallationState evaluation */ func registerObservers() { Logger.app.debug("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 ) NotificationCenter.default.addObserver( self, selector: #selector(self.handleAccessibilityGranted(_:)), name: NSNotification.Name.accessibilityGranted, object: nil // Observe notifications from any sender ) NotificationCenter.default.addObserver( self, selector: #selector(self.handleAccessibilityNotGranted(_:)), name: NSNotification.Name.accessibilityNotGranted, object: nil // Observe notifications from any sender ) } /** * called when `NSNotification.Name.startNewInstallation` is received */ @objc func handleStartNewInstallation(_ notification: Notification) { Logger.app.debug("handleStartNewInstallation received") // the evaluation is done self.installationCheck.isEvaluatingNewInstallation = false } /** * called when `NSNotification.Name.startInstallationRepair` is received */ @objc func handleStartInstallationRepair(_ notification: Notification) { Logger.app.debug("handleStartInstallationRepair received") // notify observers NotificationCenter.default.post(name: .installationRepairStarted, object: nil, userInfo: nil) } /** * called when `NSNotification.Name.accessibilityGranted` is received */ @objc func handleAccessibilityGranted(_ notification: Notification) { guard self.installationState != nil else { return } Logger.app.debug("handleAccessibilityGranted received") // the confirmAccess task can now be marked as completed if let task = self.currentTask() { if task.taskType == .confirmAccess { self.updateTaskAsCompleted(taskType: .confirmAccess) } } NotificationCenter.default.post(name: .checkAccessibilitySuccess, object: nil, userInfo: nil) } /** * called when `NSNotification.Name.accessibilityNotGranted` is received */ @objc func handleAccessibilityNotGranted(_ notification: Notification) { Logger.app.debug("handleAccessibilityNotGranted received") NotificationCenter.default.post(name: .checkAccessibilityFailure, object: nil, userInfo: nil) } /** * If the current task is confirmRestart, mark it as complete if the user has restarted their mac. */ func confirmUserRestarted() { guard let task = self.currentTask() else { return } guard self.installationState != nil else { return } if task.taskType == .confirmRestart && self.checkUserHasRestarted() { // the confirmAccess task can now be marked as completed self.updateTaskAsCompleted(taskType: .confirmRestart) } } /** * Check whether waiting to confirm that the user restarted. */ func confirmRestartRequired() -> Bool { guard let task = self.currentTask() else { return false } guard self.installationState != nil else { return false } return task.taskType == .confirmRestart } /** * 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 { Logger.app.error("the installation phase \(self.installationPhase.rawValue, privacy: .public) has no tasks") LogUtil.errorBreadcrumb("the installation phase \(self.installationPhase.rawValue) has no tasks", category: .app) 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 == .prepareNewRepair }) { 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 == .confirmAccess }) { return incompleteTask } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .requestRestart }) { return incompleteTask } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .confirmRestart }) { return incompleteTask } return nil } /** * Executes the specified installation task. */ func executeTask(_ task: InstallationTask) { guard self.installationState != nil else { return } guard self.installationPhase.hasTasks else { Logger.app.error("executeTask: the installation phase \(self.installationPhase.rawValue) has no tasks") LogUtil.errorBreadcrumb("executeTask: the installation phase \(self.installationPhase.rawValue) has no tasks", category: .app) return } var completedTask = false switch task.taskType { case .prepareNewInstall: completedTask = true case .prepareNewRepair: completedTask = true case .enableInputMethod: completedTask = self.enableKeymanInputMethod() case .requestAccess: completedTask = self.requestAccessibility() case .confirmAccess: self.checkAccessibilityPermissionGranted() // this task is completed asynchronously when the response is returned from the input method completedTask = false case .requestRestart: completedTask = self.notifyUserPromptedToRestart() case .confirmRestart: completedTask = self.checkUserHasRestarted() } if completedTask { self.updateTaskAsCompleted(taskType: task.taskType) } } /** * Marks the specified task as completed and saves it to the UserDefaults. * Note that this actually creates a copy of the InstallationState object and updates * the property in InstallationCheck with the new reference. */ public func updateTaskAsCompleted(taskType: InstallationTaskType) { Logger.app.debug("executeTask: \(taskType.rawValue, privacy: .public) completed") if let existingState = self.installationState { let updatedState = InstallationState.createCopyWithCompletedTask(from: existingState, with: taskType) self.installationCheck.installationState = updatedState self.writeInstallationState() } } /** * Executes the current incomplete installation task, if one remains. */ public func executeCurrentInstallationTask() { 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.installationCheck.migrateData() Logger.app.debug("migration suceeded: \(success)") return success } /** * Save the installation state */ func writeInstallationState() { guard let state = self.installationState else { return } self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) } /** * Record that the installation complete view has been shown to the user */ public func setHasDisplayedInstallationComplete() { if let existingState = self.installationState { let updatedState = InstallationState.createCopy(from: existingState) updatedState.hasDisplayedInstallComplete = true self.installationCheck.installationState = updatedState self.writeInstallationState() } } /** * Return whether the installation complete view has been shown to the user */ func getHasDisplayedInstallationComplete() -> Bool { guard let state = self.installationState else { return false } return state.hasDisplayedInstallComplete } /** * Write the time that the user was requested to restart their machine */ func writeRestartRequestTime() { if let existingState = self.installationState { let updatedState = InstallationState.createCopy(from: existingState) updatedState.dateRestartRequested = Date() self.installationCheck.installationState = updatedState 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 checkUserHasRestarted() -> 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 Logger.app.debug("mostRecentStartupTime: \(mostRecentStartupTime), timeRestartRequested: \(timeRestartRequested)") } } Logger.app.debug("validateRestarted: \(hasRestarted)") return hasRestarted } /** * return the last time the system was booted */ func getMostRecentRestartTime() -> Date? { var timeSince1970 = timeval() var size = MemoryLayout.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() Logger.app.debug("Keyman status, version: \(version, privacy: .private), enabled: \(enabled), running: \(running), permissionGranted: \(permissionString)") } /** * register may need to happen before enabling */ public func registerKeymanInputMethod() -> Bool { let success = self.inputMethodUtil.registerKeymanInputMethod() Logger.app.debug("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() Logger.app.debug("selectKeymanInputMethod suceeded: \(success)") return success } /** * 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.registerKeymanInputMethod() if success { success = self.inputMethodUtil.enableKeymanInputMethod() } Logger.app.debug("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(forceInputMethodRestart: false) } /** * 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() Logger.app.debug("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() } }