/* * Keyman is copyright (C) SIL Global. MIT License. * * Created by Shawn Schantz on 2026-06-01 * * Class for evaluating the current state of Keyman to determine is complete or in need of repair * and what remaining tasks are needed to complete the installation. * * If the Keyman input method exists in the Input Methods folder and is is the correct version, * and the installation is marked as completed, then it will be checked to see if it is completely * valid or in need of repair. If so, 'createRepairInstallationState()' creates a new * InstallationState object that replaces the completed one. */ import Foundation import KeymanSettings public enum InstallationPhase { case inputMethodMissing case inputMethodOutdated case evaluatingInstallation case newInstallation case installationInProgress case installationComplete case installationRepairNeeded public var hasTasks: Bool { switch self { case .newInstallation, .installationInProgress, .installationRepairNeeded: return true default: return false } } } @MainActor public class InstallationCheck { public var installationState: InstallationState? // with isEvaluatingNewInstallation==true, we are awaiting // message from input method to determine what tasks are needed public var isEvaluatingNewInstallation: Bool fileprivate let isInputMethodInstalled: Bool fileprivate let isInputMethodCurrent: Bool fileprivate let inputMethodVersion: String fileprivate let configurationVersion: String fileprivate let defaultsRepository: DefaultsRepo fileprivate let inputMethodUtil: InputMethodUtil // a simple representation of the install state // provided so UI knows what to present to the user public var installationPhase: InstallationPhase { if !self.isInputMethodInstalled { return .inputMethodMissing } else if !self.isInputMethodCurrent { return .inputMethodOutdated } else if self.isEvaluatingNewInstallation { return .evaluatingInstallation } if let state = self.installationState { if state.isComplete { return .installationComplete } else { if state.isNew { return .newInstallation } else if state.isRepair { return .installationRepairNeeded } else { return .installationInProgress } } } else { // in case that installationState (optional) == nil // will never reach this case because if it is nil // we return inputMethodMissing or inputMethodOutdated return .newInstallation } } public init(defaultsRepo: DefaultsRepo, inputMethodUtil: InputMethodUtil) { self.defaultsRepository = defaultsRepo self.inputMethodUtil = inputMethodUtil self.isEvaluatingNewInstallation = false self.configurationVersion = ConfigAppUtil.configAppVersion() var keymanIsCurrent = false var keymanVersion: String = "unknown" let keymanExists = inputMethodUtil.keymanInputMethodExists() if keymanExists { keymanVersion = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown" keymanIsCurrent = InstallationCheck.isVersionCurrent(inputMethodVersion: keymanVersion, configurationVersion: self.configurationVersion) } self.isInputMethodInstalled = keymanExists self.isInputMethodCurrent = keymanIsCurrent self.inputMethodVersion = keymanVersion // MAC-CONFIG_TODO: break this out as a separate method if (self.isInputMethodInstalled && self.isInputMethodCurrent) { // if we have a valid input method, look at the installation state if let installState = self.loadState() { // if a stale installationState remains from a different version, then delete it if installState.keymanVersion != self.inputMethodVersion { print("removing stale installation state \(installState.keymanVersion) because the current version is \(self.inputMethodVersion)") self.clearInstallationState() // this is a new installation self.isEvaluatingNewInstallation = true } else if installState.isNew { // for a new installation, do not create the installationState until the evaluation is complete self.isEvaluatingNewInstallation = true } else { // If we're already in progress or completed or doing a repair, pick up where we left off // Note that a completed installation will be checked for repairs self.installationState = installState self.isEvaluatingNewInstallation = false } } else { // if the installationState does not exist, then this is a new installation // do not create the installationState until the evaluation is complete self.isEvaluatingNewInstallation = true } } self.registerObservers() } /** * Should be called immediately after init to evaluate what is needed for installation * or, if the installation is complete, whether it needs repairs. * When the notification from the input method is received and the evaluation is done, * the installation can move out of the `evaluatingInstallation` phase */ public func startInstallationEvaluation() { // call the input method to check whether Accessibility permission has been granted if (self.isInputMethodInstalled && self.isInputMethodCurrent) && (self.isEvaluatingNewInstallation || self.installationState?.isComplete == true) { self.inputMethodUtil.doAsyncAccessibilityCheck() } } static func isVersionCurrent(inputMethodVersion: String, configurationVersion: String) -> Bool { // return inputMethodVersion == configurationVersion // MAC-CONFIG_TODO: temporarily hard-coded to true for testing with local config app builds return true } /** * register the observer to listen for the response from the input method which * checks the current state of Accessibility permissions */ func registerObservers() { print("InstallationCheck registerObservers") DistributedNotificationCenter.default().addObserver( self, selector: #selector(self.handleAccessibilityResponse(_:)), name: NSNotification.Name.accessibilityStateResponse, object: nil // Observe notifications from any sender ) // MAC-CONFIG_TODO: add timeout? } /** * called when `NSNotification.Name.accessibilityStateResponse` is received */ @objc func handleAccessibilityResponse(_ notification: Notification) { var installCompleted = false print("handleAccessibilityResponse") // Extract message from the notification if available if let message = notification.object as? String { let permissionGranted = self.processAccessibilityResponse(with: message) if let state = self.installationState { installCompleted = state.isComplete } if self.isEvaluatingNewInstallation { // if evaluating the current state for a new installation, // complete the evaluation using the results of the permission check self.completeNewInstallationEvaluation(accessibilityPermissionGranted: permissionGranted) } else if installCompleted { // if this is a completed install, check whether repairs are needed self.checkForRepair(accessibilityPermissionGranted: permissionGranted) } else { // otherwise, this is for an install step, post results if permissionGranted { NotificationCenter.default.post(name: .accessibilityGranted, object: nil) } else { NotificationCenter.default.post(name: .accessibilityNotGranted, object: nil) } } } else { print("accessibilityStateResponse received but did not include message") } } /** * Process the distributed notification message that we received from the Keyman input method. */ func processAccessibilityResponse(with message: String) -> Bool { let timeStyle = Date.FormatStyle() .hour(.twoDigits(amPM: Date.FormatStyle.Symbol.Hour.AMPMStyle.abbreviated)) .minute(.twoDigits) .second(.twoDigits) .secondFraction(.fractional(3)) print("processAccessibilityResponse received message: \(message), time: \(Date().formatted(timeStyle))") // if the message indicates that access was granted, then return true return !message.isEmpty && message == kAccessibilityPermissionGrantedMessage } /** * Save the new InstallationState and notify observers to start new installation */ func applyNewInstallationState(state: InstallationState) { self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) self.installationState = state NotificationCenter.default.post(name: .startNewInstallation, object: state) } /** * Save the new InstallationState for handling repairs and notify observers */ func applyRepairedInstallationState(state: InstallationState) { self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) self.installationState = state NotificationCenter.default.post(name: .startInstallationRepair, object: state) } /** * Load the installation state and the tasks required to complete the installation of Keyman. * This is accomplished by one of the following: * 1. reading the saved installation which is either * completed or * in progress * 2. creating a new installation * */ public func loadState() -> InstallationState? { var installationState: InstallationState? = nil guard self.isInputMethodInstalled && self.isInputMethodCurrent else { return nil } if let savedInstallationState = readInstallationState() { installationState = savedInstallationState } return installationState } /** * Clear the installation state from the UserDefaults */ func clearInstallationState() { self.defaultsRepository.deleteInstallationState() } /** * Using the accessibility state returned from the input method, build the new task list * and determine what is actually required for the new installation. */ func completeNewInstallationEvaluation(accessibilityPermissionGranted: Bool) { // see what tasks remain based on the evaluation let neededTasks = determineInstallationTasksNeeded(for: accessibilityPermissionGranted) let newState = self.createNewInstallationState(with: neededTasks) self.applyNewInstallationState(state: newState) } /** * Creates a InstallationState object describing a new installation */ func createNewInstallationState(with neededTasks: Set) -> InstallationState { print("completeNewInstallationEvaluation: created new installation state") var fullTaskList = neededTasks // add prepareNewInstall, requestRestart and confirmRestart InstallationTask fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .prepareNewInstall)) fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .requestRestart)) fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .confirmRestart)) let installationState = InstallationState(version: self.inputMethodVersion, tasks: fullTaskList) return installationState } /** * Determine whether the completed installation has been altered in some way and needs repair. * If repair is needed, then call `applyRepairedInstallationState` with the new `InstallationState` */ func checkForRepair(accessibilityPermissionGranted: Bool) { // check whether the installation requires repair if let state = self.createRepairInstallationState(accessibilityPermissionGranted: accessibilityPermissionGranted) { print("checkForRepair completed: repair is required") self.applyRepairedInstallationState(state: state) } else { print("checkForRepair completed: no repair needed") } } /** * Read the currently saved installation state as an object */ func readInstallationState() -> InstallationState? { guard let installationMap = self.defaultsRepository.readInstallationState() else { return nil } return InstallationState(from: installationMap) } /** * The provided parameter `accessibilityPermissionGranted` was returned asynchronously from the input method. * Use it and other info to see what tasks are needed to complete installation. */ func determineInstallationTasksNeeded(for accessibilityPermissionGranted: Bool) -> Set { var newTasks = Set() // add task to request Accessibility permission if needed if !accessibilityPermissionGranted { newTasks.insert(InstallationTask.createNewInstallationTask(type: .requestAccess)) newTasks.insert(InstallationTask.createNewInstallationTask(type: .confirmAccess)) } // add enable input method and restart mac tasks if needed if !self.inputMethodUtil.isKeymanInputMethodEnabled() { newTasks.insert(InstallationTask.createNewInstallationTask(type: .enableInputMethod)) // prompt user to restart after enabling the input method newTasks.insert(InstallationTask.createNewInstallationTask(type: .requestRestart)) newTasks.insert(InstallationTask.createNewInstallationTask(type: .confirmRestart)) } return newTasks } /** * The provided parameter `accessibilityPermissionGranted` was returned asynchronously from the input method. * Check the installation to see of it is valid -- something may have been tampered with after installation was completed. * If the installation needs repair, create the info needed for repairing the installation. */ func createRepairInstallationState(accessibilityPermissionGranted: Bool) -> InstallationState? { var repairInstallationState: InstallationState? = nil let repairTasks = self.determineInstallationTasksNeeded(for: accessibilityPermissionGranted) if !repairTasks.isEmpty { repairInstallationState = InstallationState(version: self.inputMethodVersion, isRepair: true, tasks: repairTasks) } return repairInstallationState } }