mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-25 17:17:43 +00:00
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
feat(mac): remove obsolete configuration UI 🍎
459 lines
18 KiB
Swift
459 lines
18 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
|
|
import OSLog
|
|
|
|
// The data model version number is updated by the input method.
|
|
// We only read the value here, and if it is current, then there is no need
|
|
// to trigger data migration. If data migration is necessary, then it must be
|
|
// performed by the input method because the input method alone has access
|
|
// to the data before it was shared in an app group beginning with Keyman 19.
|
|
let dataModelVersionStoreInGroupContainer = 2
|
|
|
|
public enum InstallationPhase: String {
|
|
case inputMethodMissing
|
|
case inputMethodOutdated
|
|
case evaluatingInstallation
|
|
case newInstallation
|
|
case installationInProgress
|
|
case installationComplete
|
|
case installationRepairNeeded
|
|
|
|
// indicates whether the installation contains tasks to complete installation
|
|
public var hasTasks: Bool {
|
|
switch self {
|
|
case .newInstallation,
|
|
.installationInProgress,
|
|
.installationRepairNeeded:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
enum InstallationStateCondition: String {
|
|
case stale
|
|
case new
|
|
case inProgress
|
|
}
|
|
|
|
@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 classification 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
|
|
}
|
|
|
|
return .installationInProgress
|
|
}
|
|
}
|
|
|
|
// In case installationState (optional) == nil --
|
|
// though we 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
|
|
|
|
if self.isMigrationNeeded() {
|
|
_ = self.migrateData()
|
|
}
|
|
|
|
let installState = InstallationCheck.readInstallationState(from: defaultsRepo)
|
|
|
|
if (keymanExists && keymanIsCurrent) {
|
|
// the input method is valid, examine the installation state recorded on disk
|
|
//
|
|
let installationStateCondition = InstallationCheck.evaluateInstallationState(state: installState, for: keymanVersion);
|
|
Logger.app.log("installationStateCondition: \(installationStateCondition.rawValue, privacy: .public)")
|
|
|
|
switch installationStateCondition {
|
|
case .inProgress:
|
|
self.installationState = installState // resume with the existing installation
|
|
case .new:
|
|
self.isEvaluatingNewInstallation = true // evaluate before creating a new InstallationState
|
|
case .stale:
|
|
self.clearInstallationState() // delete the existing installation state from the UserDefaults
|
|
self.isEvaluatingNewInstallation = true // evaluate before creating a new InstallationState
|
|
}
|
|
}
|
|
|
|
self.registerObservers()
|
|
}
|
|
|
|
/**
|
|
* Check the user defaults to see whether a migration is needed
|
|
*/
|
|
public func isMigrationNeeded() -> Bool {
|
|
var migrationNeeded = true
|
|
|
|
if self.defaultsRepository.readDataModelVersion() >= dataModelVersionStoreInGroupContainer {
|
|
migrationNeeded = false
|
|
}
|
|
|
|
Logger.app.debug("isMigrationNeeded: \(migrationNeeded)")
|
|
return migrationNeeded
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
Logger.app.debug("migrateData migration suceeded: \(success)")
|
|
|
|
// check whether
|
|
if success {
|
|
NotificationCenter.default.post(name: .dataMigrated, object: nil)
|
|
}
|
|
return success
|
|
}
|
|
|
|
/**
|
|
* Check the condition of the InstallationState as recorded in the UserDefaults.
|
|
* Determine whether it is `stale` and should be deleted
|
|
* (which then is treated as a new installation) or
|
|
* is `inProgress` and should be loaded and used, or
|
|
* is `new`. All new installations are re-evaluated to determine
|
|
* what tasks must be executed to complete the installation.
|
|
*/
|
|
static func evaluateInstallationState(state: InstallationState?, for version: String) -> InstallationStateCondition {
|
|
|
|
// If the installationState does not exist, then this is a new installation.
|
|
// The installationState will be created when evaluation is complete.
|
|
guard state != nil else { return .new }
|
|
|
|
var condition = InstallationStateCondition.inProgress
|
|
|
|
if let installState = state {
|
|
// If the installationState remains from a different install, mark it as stale.
|
|
// It will be deleted and we will evaluate for a new installation.
|
|
if installState.keymanVersion != version {
|
|
condition = .stale
|
|
} else if installState.isNew {
|
|
condition = .new
|
|
} 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
|
|
condition = .inProgress
|
|
}
|
|
}
|
|
|
|
return condition
|
|
}
|
|
|
|
/**
|
|
* 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.isReadyForRepairCheckAtStartup()) {
|
|
self.inputMethodUtil.doAsyncAccessibilityCheck(forceInputMethodRestart: false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if we should check at app startup whether the installation needs repair.
|
|
* Simply returns true if the installation is complete.
|
|
*/
|
|
func isReadyForRepairCheckAtStartup() -> Bool {
|
|
let readyForRepairCheck = self.installationState?.isComplete == true
|
|
|
|
Logger.app.debug("isReadyForRepairCheckAtStartup: \(readyForRepairCheck, privacy: .public)")
|
|
|
|
return readyForRepairCheck
|
|
}
|
|
|
|
/**
|
|
* Check whether the input method and configuration app are the same version.
|
|
* Because the version of the config app will not be sent when build locally, this can be overridden,
|
|
* for testing purposes, by specifying `kTestConfigVersion` in config app's standard UserDefaults
|
|
*/
|
|
static func isVersionCurrent(inputMethodVersion: String, configurationVersion: String) -> Bool {
|
|
Logger.app.log("isVersionCurrent, comparing input method version: \(inputMethodVersion, privacy: .public) and config app version: \(configurationVersion, privacy: .public)")
|
|
return inputMethodVersion == configurationVersion
|
|
}
|
|
|
|
/**
|
|
* register the observer to listen for the response from the input method which
|
|
* checks the current state of Accessibility permissions
|
|
*/
|
|
func registerObservers() {
|
|
Logger.app.debug("InstallationCheck registerObservers")
|
|
|
|
DistributedNotificationCenter.default().addObserver(
|
|
self,
|
|
selector: #selector(self.handleAccessibilityResponse(_:)),
|
|
name: NSNotification.Name.accessibilityStateResponse,
|
|
object: nil // Observe notifications from any sender
|
|
)
|
|
// TODO: add timeout in case response is not received
|
|
// see issue #16579
|
|
}
|
|
|
|
/**
|
|
* called when `NSNotification.Name.accessibilityStateResponse` is received
|
|
*/
|
|
@objc func handleAccessibilityResponse(_ notification: Notification) {
|
|
var installCompleted = false
|
|
|
|
// Extract message from the notification if available
|
|
if let message = notification.object as? String {
|
|
let permissionGranted = self.processAccessibilityResponse(with: message)
|
|
|
|
Logger.app.debug("handleAccessibilityResponse, message: \(message, privacy: .public)")
|
|
|
|
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 {
|
|
Logger.app.debug("handleAccessibilityResponse, 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))
|
|
Logger.app.debug("processAccessibilityResponse received message: \(message, privacy: .public), time: \(Date().formatted(timeStyle), privacy: .public)")
|
|
|
|
// 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(isRepair: false, with: 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 {
|
|
Logger.app.debug("completeNewInstallationEvaluation: created new installation state")
|
|
|
|
var fullTaskList = neededTasks
|
|
|
|
// add prepareNewInstall InstallationTask
|
|
fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .prepareNewInstall))
|
|
|
|
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) {
|
|
Logger.app.log("checkForRepair completed: repair is required")
|
|
self.applyRepairedInstallationState(state: state)
|
|
} else {
|
|
Logger.app.log("checkForRepair completed: no repair needed")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the currently saved installation state as an object
|
|
*/
|
|
func readInstallationState() -> InstallationState? {
|
|
return InstallationCheck.readInstallationState(from: self.defaultsRepository)
|
|
}
|
|
|
|
/**
|
|
* Read the currently saved installation state as an object
|
|
*/
|
|
static func readInstallationState(from repo: DefaultsRepo) -> InstallationState? {
|
|
guard let installationMap = repo.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(isRepair: Bool, with 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))
|
|
|
|
// when repairing, prompt to restart to ensure that the input method has been loaded by the system
|
|
if (isRepair) {
|
|
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
|
|
|
|
var repairTasks = self.determineInstallationTasksNeeded(isRepair: true, with: accessibilityPermissionGranted)
|
|
if !repairTasks.isEmpty {
|
|
// add prepareNewRepair
|
|
repairTasks.insert(InstallationTask.createNewInstallationTask(type: .prepareNewRepair))
|
|
repairInstallationState = InstallationState(version: self.inputMethodVersion, isRepair: true, tasks: repairTasks)
|
|
}
|
|
|
|
return repairInstallationState
|
|
}
|
|
}
|