mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-26 17:47:40 +00:00
instead, if keyman input method is running, then request or query access using notifications also change text that prompts user to restart restart should not be necessary in any normal use case, as it happens at install time
484 lines
17 KiB
Swift
484 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 = self.migrateData()
|
|
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.inputMethodUtil.invokeKeymanInputMethodMigration()
|
|
Logger.app.debug("migration suceeded: \(success)")
|
|
|
|
// check whether
|
|
if success {
|
|
NotificationCenter.default.post(name: .dataMigrated, object: nil)
|
|
}
|
|
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()
|
|
}
|
|
}
|