spiegel-keyman/mac/Config/Installation/InstallationCheck.swift
Shawn Schantz c9ef73c47d feat(mac): create new InstallationState whenever task is completed
this ensures that the changes to this published
property will result in a SwiftUI notification

also made a couple changes in response to devin.ai
code review
2026-07-27 12:41:10 -04:00

379 lines
15 KiB
Swift

/*
* 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()
}
/**
* Determine whether validation is needed.
*/
func isValidationNeeded() -> Bool {
// no need to validate if the input method is not current
guard (self.isInputMethodInstalled && self.isInputMethodCurrent) else { return false }
// or if the current state is not saved (should never encounter this)
guard let currentInstallationState = self.installationState else { return false }
// only need to validate if this installation has been completed
return currentInstallationState.isComplete
}
/**
* Determine whether the completed installation has been altered in some way and needs repair.
* If repair is needed, then a new InstallationState object will be returned.
*/
func startValidation() {
// call the input method to check whether Accessibility permission has been granted
self.inputMethodUtil.doAsyncAccessibilityCheck()
}
/**
* 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<InstallationTask>) -> 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<InstallationTask> {
var newTasks = Set<InstallationTask>()
// 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
}
}