mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-25 17:17:43 +00:00
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run
479 lines
17 KiB
Swift
479 lines
17 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
|
|
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<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()
|
|
|
|
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()
|
|
}
|
|
}
|