diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index e8ed3eb54fb..d94e5d5f21d 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -16,17 +16,12 @@ struct ConfigApp: App { @Environment(\.openWindow) private var openWindow var body: some Scene { - Window("Configuration", id: "config") { - ConfigView() + Window("Config Test", id: "config-debug") { + ConfigDebugView() .environmentObject(settings) - .task { - if !installation.isInstallationComplete() { - openWindow(id: "install") - } - } } - Window("Installation", id: "install") { - InstallView() + Window("Install Test", id: "install-debug") { + InstallDebugView() .environmentObject(installation) } } diff --git a/mac/Config/Config/ConfigView.swift b/mac/Config/Config/ConfigDebugView.swift similarity index 94% rename from mac/Config/Config/ConfigView.swift rename to mac/Config/Config/ConfigDebugView.swift index 2763e1eb2ab..cb1692e956d 100644 --- a/mac/Config/Config/ConfigView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -3,13 +3,13 @@ * * Created by Shawn Schantz on 2026-02-26 * - * Main view used for configuring Keyman + * View for debugging Keyman configuration */ import SwiftUI import KeymanSettings -struct ConfigView: View { +struct ConfigDebugView: View { @EnvironmentObject var settings: SettingsContainer @State private var isShowingSheet = false @@ -86,7 +86,7 @@ struct ConfigView: View { .labelStyle(.iconOnly) .buttonStyle(.borderless) } - KeyboardListView(packageId: package.id, keyboards: package.keyboards) + KeyboardListDebugView(packageId: package.id, keyboards: package.keyboards) } } } @@ -99,6 +99,6 @@ struct ConfigView: View { #Preview { let settings = SettingsContainer() - ConfigView() + ConfigDebugView() .environmentObject(settings) } diff --git a/mac/Config/Config/InstallView.swift b/mac/Config/Config/InstallDebugView.swift similarity index 65% rename from mac/Config/Config/InstallView.swift rename to mac/Config/Config/InstallDebugView.swift index 789d1a1c743..f6f67dc2615 100644 --- a/mac/Config/Config/InstallView.swift +++ b/mac/Config/Config/InstallDebugView.swift @@ -8,22 +8,29 @@ import SwiftUI -struct InstallView: View { +struct InstallDebugView: View { @EnvironmentObject var installation: InstallationContainer - + @State private var taskText: String = "[task]" + var body: some View { VStack { HStack { Image(systemName: "gear") .imageScale(.large) .foregroundColor(.accentColor) - if let nextTask = installation.nextTask() { - Text("Next task = \(nextTask.taskType.rawValue)") - } + Text("Current task = \(taskText)") + .onAppear() { + if let installTask = installation.currentTask() { + taskText = installTask.taskType.rawValue + } + } } HStack { Button("Next...") { - installation.executeNextInstallationTask() + installation.executeCurrentInstallationTask() + if let installTask = installation.currentTask() { + taskText = installTask.taskType.rawValue + } } .disabled(installation.isInstallationComplete()) Button("Migrate Data") { @@ -54,6 +61,12 @@ struct InstallView: View { Button("Check Restart") { _ = installation.validateUserHasRestarted() } + Button("Set Displayed Complete") { + let beforeDisplayed = installation.getHasDisplayedInstallationComplete() + installation.setHasDisplayedInstallationComplete() + let afterDisplayed = installation.getHasDisplayedInstallationComplete() + print("hasDisplayedInstallComplete = \(beforeDisplayed) -> \(afterDisplayed)") + } Button("debug") { installation.debug() } @@ -66,22 +79,17 @@ struct InstallView: View { Button("Uninstall") { installation.uninstall() } - Button("Force Reset Installation") { - installation.forceResetInstallation() - } - Button("Force Validate Installation") { - installation.forceValidateInstallation() - } Spacer() } .padding() } .padding() +// .onReceive(NotificationCenter.default.publisher(for: .inputMethodMissing), perform: {_ in print("input method missing")}) } } #Preview { let installation = InstallationContainer() - InstallView() + InstallDebugView() .environmentObject(installation) } diff --git a/mac/Config/Config/KeyboardListView.swift b/mac/Config/Config/KeyboardListDebugView.swift similarity index 96% rename from mac/Config/Config/KeyboardListView.swift rename to mac/Config/Config/KeyboardListDebugView.swift index c01b523094c..50c357ff66d 100644 --- a/mac/Config/Config/KeyboardListView.swift +++ b/mac/Config/Config/KeyboardListDebugView.swift @@ -10,7 +10,7 @@ import SwiftUI import KeymanSettings import Combine -struct KeyboardListView: View { +struct KeyboardListDebugView: View { @EnvironmentObject var settings: SettingsContainer @State var packageId: UUID @State var keyboards: [Keyboard] diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/Config/Installation/InputMethodUtil.swift index 73d7e054995..ab8f0588e2d 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/Config/Installation/InputMethodUtil.swift @@ -52,14 +52,6 @@ public class InputMethodUtil { return FileManager.default.fileExists(atPath: inputMethodUrl.path) } - /** - * true if the Keyman input method of the correct version exists in the correct location - */ - public func isKeymanInputMethodCurrent() -> Bool { - // MAC-CONFIG-TODO: implement with version check - return true - } - /** * Returns version number string of Keyman input method */ @@ -177,6 +169,11 @@ public class InputMethodUtil { var success = false do { print("invokeKeymanInputMethodRequestAccess()") + + // because we are launching Keyman with a specific command line argument + // for this request, we must kill it first + _ = self.killKeymanInputMethod() + try self.launchKeymanInputMethodAsSeparateProcess(argument: kAccessCommand) success = true } catch { @@ -187,14 +184,19 @@ public class InputMethodUtil { } /** - * Calls Keyman input to check whether it has accessibility permission granted. + * Calls Keyman input method to check whether it has accessibility permission granted. * The actual result is not returned from Keyman when called as a separate process. - * After this function is called, listen to the `NotificationCenter` for the notification named - * `com.keyman.accessibility.state` + * After this function is called, listen to the `DistributedNotificationCenter` for the notification named + * `accessibilityStateResponse` * It contains a message with a value of `granted` or `not-granted` */ func invokeKeymanInputMethodCheckAccess() throws { print("invokeKeymanInputMethodCheckAccess()") + + // because we are launching Keyman with a specific command line argument + // for this request, we must kill it first + _ = self.killKeymanInputMethod() + try self.launchKeymanInputMethodAsSeparateProcess(argument: kCheckCommand) } @@ -250,11 +252,8 @@ public class InputMethodUtil { } /** - * Special care is needed with this code because `processAccessCheckResult(with:)` is bound to the Main Actor, - * but it is called from a closure which may not run on the Main Actor. - * DistributedNotificationCenter is not fully updated for concurrency, so specifying `.main` for the `OperationQueue` - * does not ensure that the closure is running on the main actor. - * To ensure this, a Task is defined around the call to `processAccessCheckResult`. + * Calls Keyman input method to check whether it has accessibility permission granted. + * Receives response as distributed notification named `accessibilityStateResponse` */ func doAsyncAccessibilityCheck() { do { @@ -273,7 +272,7 @@ public class InputMethodUtil { /** * Kill the application with the specified bundle Id - * This is only permitted when running oustide sandbox + * This is only permitted when running outside sandbox */ func killApplication(bundleId: String) -> Bool { let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift index 1eaf159913a..55c2139488d 100644 --- a/mac/Config/Installation/InstallationCheck.swift +++ b/mac/Config/Installation/InstallationCheck.swift @@ -15,44 +15,174 @@ import Foundation import KeymanSettings +public enum InstallationPhase { + 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 { + case stale + case new + case inProgress +} + @MainActor public class InstallationCheck { - public let isInputMethodInstalled: Bool - public let isInputMethodCurrent: Bool 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() - if inputMethodUtil.keymanInputMethodExists() { - self.isInputMethodInstalled = true - self.inputMethodVersion = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown" - } else { - self.isInputMethodInstalled = false - self.inputMethodVersion = "unknown" + 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.configurationVersion = ConfigAppUtil.configAppVersion() - self.isInputMethodCurrent = InstallationCheck.isVersionCurrent(inputMethodVersion: self.inputMethodVersion, configurationVersion: self.configurationVersion) - - self.installationState = self.loadState() + self.isInputMethodInstalled = keymanExists + self.isInputMethodCurrent = keymanIsCurrent + self.inputMethodVersion = keymanVersion + + 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); + print("installationStateCondition: \(installationStateCondition)") + + 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 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 self.isValidationNeeded() { - self.startValidation() + 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.installationState?.isComplete == true) { + self.inputMethodUtil.doAsyncAccessibilityCheck() + } + } + + /** + * 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 { - // return inputMethodVersion == configurationVersion - // MAC-CONFIG_TODO: temporarily hard-coded to true for testing with local config app builds - return true + print("isVersionCurrent, comparing input method version: \(inputMethodVersion) and config app version: \(configurationVersion)") + return inputMethodVersion == configurationVersion } /** @@ -64,23 +194,44 @@ public class InstallationCheck { DistributedNotificationCenter.default().addObserver( self, selector: #selector(self.handleAccessibilityResponse(_:)), - name: NSNotification.Name.accessibilityQueryResponse, + name: NSNotification.Name.accessibilityStateResponse, object: nil // Observe notifications from any sender ) // MAC-CONFIG_TODO: add timeout? } - + /** - * called when `NSNotification.Name.accessibilityQueryResponse` is received + * 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) - self.completeValidation(accessibilityPermissionGranted: permissionGranted) + + 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("accessibilityQueryResponse received but did not include message") + print("accessibilityStateResponse received but did not include message") } } @@ -99,13 +250,22 @@ public class InstallationCheck { 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 prepareToRepair(newState: InstallationState) { - self.defaultsRepository.writeInstallationState(newState.toUserDefaultsDictionary()) - self.installationState = newState - NotificationCenter.default.post(name: .installationRepairNeeded, object: newState) + func applyRepairedInstallationState(state: InstallationState) { + self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) + self.installationState = state + NotificationCenter.default.post(name: .startInstallationRepair, object: state) } /** @@ -115,116 +275,127 @@ public class InstallationCheck { * completed or * in progress * 2. creating a new installation + * */ public func loadState() -> InstallationState? { - var installationState: InstallationState + var installationState: InstallationState? = nil guard self.isInputMethodInstalled && self.isInputMethodCurrent else { return nil } if let savedInstallationState = readInstallationState() { - installationState = savedInstallationState - } else { - // if installation could not be read, then - installationState = self.createInstallationStateForNewInstallation() + installationState = savedInstallationState } return installationState } /** - * Determine whether validation is needed. + * Clear the installation state from the UserDefaults */ - 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 + func clearInstallationState() { + self.defaultsRepository.deleteInstallationState() } /** - * 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() - } - - /** - * Determine whether the completed installation has been altered in some way and needs repair. - * If repair is needed, then call `prepareToRepair` with the new `InstallationState` - */ - func completeValidation(accessibilityPermissionGranted: Bool) { - // check whether the installation requires repair - if let newInstallationState = self.createRepairInstallationState(accessibilityPermissionGranted: accessibilityPermissionGranted) { - self.prepareToRepair(newState: newInstallationState) - } else { - print("completeValidation: no repair needed") - } - } - - /** - * Read the currently saved installation state as an object + * 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 readInstallationState() -> InstallationState? { - guard let installationMap = self.defaultsRepository.readInstallationState() else { - return nil - } - - return InstallationState(from: installationMap) + 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 createInstallationStateForNewInstallation() -> InstallationState { - let installationState = InstallationState(version: self.inputMethodVersion, tasks: self.createNewInstallationTasks()) - self.defaultsRepository.writeInstallationState(installationState.toUserDefaultsDictionary()) + 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)) + + // MAC-CONFIG_TODO: should we always restart for a new install or only when enabling input method? + fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .requestRestart)) + fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .confirmRestart)) + let installationState = InstallationState(version: self.inputMethodVersion, tasks: fullTaskList) return installationState } - + /** - * Creates a the set of tasks required for a new installation + * 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 createNewInstallationTasks() -> Set { - var taskList = Set() - taskList.insert(InstallationTask(task: .migrateData, completed: false)) - taskList.insert(InstallationTask(task: .enableInputMethod, completed: false)) - taskList.insert(InstallationTask(task: .requestAccess, completed: false)) - taskList.insert(InstallationTask(task: .restartMac, completed: false)) - return taskList + 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? { + 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) +} + /** - * Check the installation to see of it is valid -- something may have been tampered with after installation was completed. - * The provided parameter `accessibilityPermissionGranted` was already returned asynchronously from the input method. - * If the installation needs repair, create the info needed for repairing the installation. + * 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 createRepairInstallationState(accessibilityPermissionGranted: Bool) -> InstallationState? { - var repairInstallationState: InstallationState? = nil - var repairTasks = Set() + func determineInstallationTasksNeeded(for accessibilityPermissionGranted: Bool) -> Set { + var newTasks = Set() + // add task to request Accessibility permission if needed if !accessibilityPermissionGranted { - repairTasks.insert(InstallationTask.createNewInstallationTask(type: .requestAccess)) + 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() { - repairTasks.insert(InstallationTask.createNewInstallationTask(type: .enableInputMethod)) + newTasks.insert(InstallationTask.createNewInstallationTask(type: .enableInputMethod)) - // also need to restart after enabling the input method - repairTasks.insert(InstallationTask.createNewInstallationTask(type: .restartMac)) + // 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 + var repairTasks = self.determineInstallationTasksNeeded(for: accessibilityPermissionGranted) if !repairTasks.isEmpty { + // add prepareNewRepair + repairTasks.insert(InstallationTask.createNewInstallationTask(type: .prepareNewRepair)) repairInstallationState = InstallationState(version: self.inputMethodVersion, isRepair: true, tasks: repairTasks) } diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift index e5f9cdb7c57..6ee46a1c93d 100644 --- a/mac/Config/Installation/InstallationContainer.swift +++ b/mac/Config/Installation/InstallationContainer.swift @@ -10,21 +10,26 @@ import SwiftUI import Combine import KeymanSettings +// in-app notifications sent public extension Notification.Name { - static let installationRepairNeeded = Notification.Name("com.keyman.installation.repair.needed") + 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 { - // if the installer was run, then installed and current should be true - public let isInputMethodInstalled : Bool - public let isInputMethodCurrent : Bool - // for convenience, combination of Installed and Current - public var isCurrentInputMethodInstalled : Bool { - isInputMethodInstalled && isInputMethodCurrent - } - // installationState describes the remaining tasks to complete the installation - @Published public var installationState: InstallationState? + public var installationPhase: InstallationPhase { + return self.installationCheck.installationPhase + } + + var installationState: InstallationState? { + return self.installationCheck.installationState + } fileprivate let installationCheck: InstallationCheck fileprivate let defaultsRepository: DefaultsRepo @@ -49,43 +54,107 @@ public class InstallationContainer : ObservableObject { } catch { fatalError("Unable to access group container path for InputMethodUtil: \(error.localizedDescription).") } - - self.installationCheck = InstallationCheck(defaultsRepo: defaultsRepo, inputMethodUtil: inputMethodUtil) - self.isInputMethodInstalled = self.installationCheck.isInputMethodInstalled - self.isInputMethodCurrent = self.installationCheck.isInputMethodCurrent - self.installationState = self.installationCheck.installationState - self.registerObservers() + 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.validateConfirmRestart() { + self.registerObservers() + self.installationCheck.startInstallationEvaluation() + } } - /** - * register the observer to listen for a notification from the InstallationCheck to - * repair the current installation + * register observers to learn of results of InstallationState evaluation */ func registerObservers() { print("InstallationContainer registerObservers") NotificationCenter.default.addObserver( self, - selector: #selector(self.handleRepairNeeded(_:)), - name: NSNotification.Name.installationRepairNeeded, + 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) { + print("handleStartNewInstallation received") + // the evaluation is done + self.installationCheck.isEvaluatingNewInstallation = false } /** - * called when `NSNotification.Name.installationRepairNeeded` is received + * called when `NSNotification.Name.startInstallationRepair` is received */ - @objc func handleRepairNeeded(_ notification: Notification) { - print("handleRepairNeeded") - // Extract message from the notification if available - if let newState = notification.object as? InstallationState { - self.installationState = newState + @objc func handleStartInstallationRepair(_ notification: Notification) { + print("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 } + + // 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) { + NotificationCenter.default.post(name: .checkAccessibilityFailure, object: nil, userInfo: nil) + } + + /** + * If the current task is confirmRestart, mark it as complete if the user has restarted. + */ + func validateConfirmRestart() -> Bool { + guard let task = self.currentTask() else { return false } + guard self.installationState != nil else { return false } + + if task.taskType == .confirmRestart && self.validateUserHasRestarted() { + // the confirmAccess task can now be marked as completed + self.updateTaskAsCompleted(taskType: .confirmRestart) + return true } else { - print("handleRepairNeeded received but did not include new InstallationState") + return false } } - + /** * 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` @@ -105,23 +174,31 @@ public class InstallationContainer : ObservableObject { } /** - * Returns the next incompleted installation task, if there is one remaining. + * 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 nextTask() -> InstallationTask? { + public func currentTask() -> InstallationTask? { guard let state = self.installationState else { return nil } + guard self.installationPhase.hasTasks else { + print("the installation phase \(self.installationPhase) has no tasks"); + return nil + } let incompleteTasks = state.tasks.filter { !$0.isComplete } - if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .verifyInputMethod }) { + if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .prepareNewInstall }) { return incompleteTask - } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .migrateData }) { + } 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 == .restartMac }) { + } 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 } @@ -132,36 +209,58 @@ public class InstallationContainer : ObservableObject { * Executes the specified installation task. */ func executeTask(_ task: InstallationTask) { - guard let state = self.installationState else { return } + guard self.installationState != nil else { return } + guard self.installationPhase.hasTasks else { + print("the installation phase \(self.installationPhase) has no tasks"); + return + } var completedTask = false switch task.taskType { - case .verifyInputMethod: - completedTask = self.verifyInputMethod() - case .migrateData: + case .prepareNewInstall: completedTask = self.migrateData() + case .prepareNewRepair: + completedTask = true case .enableInputMethod: completedTask = self.enableKeymanInputMethod() case .requestAccess: completedTask = self.requestAccessibility() - case .restartMac: + 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.validateUserHasRestarted() } if completedTask { - print("executeTask: \(task.taskType.rawValue) completed") - state.updateTaskAsCompleted(task: task.taskType) + 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) { + print("executeTask: \(taskType.rawValue) completed") + if let existingState = self.installationState { + let updatedState = InstallationState.createCopyWithCompletedTask(from: existingState, with: taskType) + self.installationCheck.installationState = updatedState self.writeInstallationState() } } - + /** - * Executes the next installation task which is incomplete, if there is one remaining. + * Executes the current incomplete installation task, if one remains. */ - public func executeNextInstallationTask() { - if let nextTask = self.nextTask() { - self.executeTask(nextTask) + public func executeCurrentInstallationTask() { + if let installTask = self.currentTask() { + self.executeTask(installTask) } } @@ -183,17 +282,40 @@ public class InstallationContainer : ObservableObject { 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() { - guard let state = self.installationState else { return } - - state.dateRestartRequested = Date() - self.writeInstallationState() + 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 */ @@ -229,20 +351,6 @@ public class InstallationContainer : ObservableObject { return hasRestarted } - /** - * for testing purposes, replace the InstallationState with a new object set for a new installation - */ - func forceResetInstallation() { - self.installationState = InstallationCheck(defaultsRepo: self.defaultsRepository, inputMethodUtil: self.inputMethodUtil).createInstallationStateForNewInstallation() - } - - /** - * for testing purposes, validate the installation - */ - func forceValidateInstallation() { - self.installationCheck.startValidation() - } - /** * return the last time the system was booted */ @@ -276,7 +384,6 @@ public class InstallationContainer : ObservableObject { print("Keyman status, version: \(version), enabled: \(enabled), running: \(running), permissionGranted: \(permissionString)") } - /** * register may need to happen before enabling @@ -298,15 +405,6 @@ public class InstallationContainer : ObservableObject { return success } - /** - * Verify that the input method has been correctly installed. - * This may be superflous as we are verifying this before creating the installation tasks - */ - public func verifyInputMethod() -> Bool { - // MAC-CONFIG-TODO: currently does nothing, already verified when InstallationCheck is created - return true - } - /** * true if the system recognizes Keyman as an enabled input method */ @@ -329,38 +427,19 @@ public class InstallationContainer : ObservableObject { } /** - * first kill the Keyman input method if it is running - * second, call Keyman as a separate process with an argument that checks whether accessibility has been granted by the user - * and listen for message from Keyman to indicate the result + * call Keyman as a separate process with an argument that checks whether accessibility has been granted by the user */ public func checkAccessibilityPermissionGranted() { - if (inputMethodUtil.isKeymanInputMethodRunning()) { - let killed = self.inputMethodUtil.killKeymanInputMethod() - print("checkAccessibilityPermission, Keyman input method killed: \(killed)") - } else { - print("checkAccessibilityPermission, Keyman input method not running") - } - self.inputMethodUtil.doAsyncAccessibilityCheck() } /** - * First kill the Keyman input method if it is running. - * Second, call Keyman as a separate process with an argument that requests the system to prompt the user to grant accessibility. - * The Keyman input method cannot send a message to indicate success, because it does not know itself when the user has - * finished making the change in Settings. - * To learn the result, we must poll with `isAccessibilityGranted()` + * 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 - - if (inputMethodUtil.isKeymanInputMethodRunning()) { - let killed = self.inputMethodUtil.killKeymanInputMethod() - print("requestAccessibility, Keyman input method killed: \(killed)") - } else { - print("requestAccessibility, Keyman input method not running") - } - + requested = self.inputMethodUtil.invokeKeymanInputMethodRequestAccess() print("requestAccessibility called, requested: \(requested)") diff --git a/mac/Config/Installation/InstallationState.swift b/mac/Config/Installation/InstallationState.swift index 9f076d86be7..11d04fdf218 100644 --- a/mac/Config/Installation/InstallationState.swift +++ b/mac/Config/Installation/InstallationState.swift @@ -15,25 +15,69 @@ public class InstallationState { let kVersionKey = "version" let kDateRestartRequestedKey = "dateRestartRequested" let kRepairKey = "isRepair" - + let kDisplayedInstallComplete = "displayedInstallComplete" + public let keymanVersion: String public var dateRestartRequested: Date? // indicates whether we are repairing a previous installation or doing a full installation public let isRepair: Bool + // indicates whether the installation complete message has been displayed to the user + public var hasDisplayedInstallComplete: Bool // this list of tasks that make up this installation public var tasks: Set + /** + * The installation is complete if all its tasks are complete. + * Returns true if the tasks list is empty, but it should never be empty. + */ public var isComplete: Bool { - tasks.allSatisfy(\.isComplete) + return tasks.allSatisfy(\.isComplete) } - - init(version: String, dateRestartRequested: Date? = nil, isRepair: Bool = false, tasks: Set) { + + /** + * The installation is new if this is not a repair and no tasks are complete. + * Returns true if the tasks list is empty, but it should never be empty. + */ + public var isNew: Bool { + return !isRepair && tasks.allSatisfy{ !$0.isComplete } + } + + /** + * The installation has at least one task complete and at least one remaining to complete. + * It does not matter if this is a repair or not. + */ + public var isInProgress: Bool { + return tasks.contains(where: { !$0.isComplete }) && tasks.contains(where: { $0.isComplete}) + } + + init(version: String, dateRestartRequested: Date? = nil, isRepair: Bool = false, hasDisplayedInstallComplete: Bool = false, tasks: Set) { self.keymanVersion = version self.dateRestartRequested = dateRestartRequested self.isRepair = isRepair + self.hasDisplayedInstallComplete = hasDisplayedInstallComplete self.tasks = tasks } + /** + * create a new InstallationState with an existing InstallationState and a task to mark as completed + */ + public static func createCopyWithCompletedTask (from state: InstallationState, with completedTask: InstallationTaskType) -> InstallationState { + var newTaskList = state.tasks + let completedTask = InstallationTask(task: completedTask, completed: true) + newTaskList.update(with: completedTask) + + return InstallationState(version: state.keymanVersion, dateRestartRequested: state.dateRestartRequested, isRepair: state.isRepair, hasDisplayedInstallComplete: state.hasDisplayedInstallComplete, tasks: newTaskList) + } + + /** + * create a new InstallationState with an existing InstallationState and a task to mark as completed + */ + public static func createCopy (from state: InstallationState) -> InstallationState { + let newTaskList = state.tasks + + return InstallationState(version: state.keymanVersion, dateRestartRequested: state.dateRestartRequested, isRepair: state.isRepair, hasDisplayedInstallComplete: state.hasDisplayedInstallComplete, tasks: newTaskList) + } + /** * initialize using the dictionary from UserDefaults */ @@ -41,14 +85,15 @@ public class InstallationState { self.keymanVersion = dictionary[kVersionKey] as? String ?? "" self.dateRestartRequested = dictionary[kDateRestartRequestedKey] as? Date self.isRepair = dictionary[kRepairKey] as? Bool ?? false + self.hasDisplayedInstallComplete = dictionary[kDisplayedInstallComplete] as? Bool ?? false var installationTasks = Set() // for every task flag found in dictionary, insert a task in the tasks array - if let taskFlag = dictionary[InstallationTaskType.verifyInputMethod.rawValue] as? Bool { - installationTasks.insert(InstallationTask(task: .verifyInputMethod, completed: taskFlag)) + if let taskFlag = dictionary[InstallationTaskType.prepareNewInstall.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .prepareNewInstall, completed: taskFlag)) } - if let taskFlag = dictionary[InstallationTaskType.migrateData.rawValue] as? Bool { - installationTasks.insert(InstallationTask(task: .migrateData, completed: taskFlag)) + if let taskFlag = dictionary[InstallationTaskType.prepareNewRepair.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .prepareNewRepair, completed: taskFlag)) } if let taskFlag = dictionary[InstallationTaskType.enableInputMethod.rawValue] as? Bool { installationTasks.insert(InstallationTask(task: .enableInputMethod, completed: taskFlag)) @@ -56,10 +101,16 @@ public class InstallationState { if let taskFlag = dictionary[InstallationTaskType.requestAccess.rawValue] as? Bool { installationTasks.insert(InstallationTask(task: .requestAccess, completed: taskFlag)) } - if let taskFlag = dictionary[InstallationTaskType.restartMac.rawValue] as? Bool { - installationTasks.insert(InstallationTask(task: .restartMac, completed: taskFlag)) + if let taskFlag = dictionary[InstallationTaskType.confirmAccess.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .confirmAccess, completed: taskFlag)) } - + if let taskFlag = dictionary[InstallationTaskType.requestRestart.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .requestRestart, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.confirmRestart.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .confirmRestart, completed: taskFlag)) + } + self.tasks = installationTasks } @@ -74,19 +125,12 @@ public class InstallationState { dictionary[kDateRestartRequestedKey] = dateRestartRequested } dictionary[kRepairKey] = self.isRepair - + dictionary[kDisplayedInstallComplete] = self.hasDisplayedInstallComplete + for task in self.tasks { dictionary[task.taskType.rawValue] = task.isComplete } return dictionary } - - /** - * update the task of the specified type as completed - */ - public func updateTaskAsCompleted(task: InstallationTaskType) { - let completedTask = InstallationTask(task: task, completed: true) - self.tasks.update(with: completedTask) - } } diff --git a/mac/Config/Installation/InstallationTask.swift b/mac/Config/Installation/InstallationTask.swift index 67b777a9f9f..3db1f5b135a 100644 --- a/mac/Config/Installation/InstallationTask.swift +++ b/mac/Config/Installation/InstallationTask.swift @@ -12,11 +12,13 @@ import KeymanSettings // String raw value for each task type is used as key for Dictionary // when storing the task state in the UserDefaults public enum InstallationTaskType: String { - case verifyInputMethod = "verifyInputMethod" // verify the new input method was installed - case migrateData = "migrateData" // only called for new install + case prepareNewInstall = "prepareNewInstall" // only added for new installs + case prepareNewRepair = "prepareRepair" // only added for repairs case enableInputMethod = "enableInputMethod" // triggers register and enable case requestAccess = "requestAccess" // causes prompt to user to grant Accessibility - case restartMac = "restartMac" // prompts user to restart machine + case confirmAccess = "confirmAccess" // confirms that Accessibility was granted + case requestRestart = "requestRestart" // prompts user to restart machine + case confirmRestart = "confirmRestart" // confirms that the machine was restarted } public struct InstallationTask: Hashable, Equatable { diff --git a/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift index 194c71d91a1..49530d95080 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift @@ -13,8 +13,15 @@ public struct ConfigAppUtil { * returns the short version string from the bundle of the Config app */ static public func configAppVersion() -> String { - return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let kConfigTestVersionKey = "testConfigVersion" + + if let configTestVersion = UserDefaults.standard.string(forKey: kConfigTestVersionKey) { + // for testing only, if a test version string is found in the standard UserDefaults of the config app + // (not in the group container UserDefaults), then use it instead + return configTestVersion + } else { + // get the actual version number from the application bundle + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + } } - - } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index a833715d53f..f15b1d22b30 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -27,7 +27,9 @@ import ZIPFoundation // distributed notifications public extension Notification.Name { - static let accessibilityQueryResponse = Notification.Name("com.keyman.accessibility.state") + // sent from input method, received by InstallationCheck + static let accessibilityStateResponse = Notification.Name("com.keyman.accessibility.state") + // sent from config app (DefaultsRepository), received by input method static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed") } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift index 52669a817e0..013d942fb39 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift @@ -101,11 +101,7 @@ public class PackageDownload { * Send a notification that an attempt to downgrade a package has been detected */ func sendNotificationToConfirmPackageDowngrade() { - NotificationCenter.default.post( - name: .packageDowngradeRequested, - object: nil, - userInfo: nil - ) + NotificationCenter.default.post(name: .packageDowngradeRequested, object: nil) } /** @@ -115,11 +111,7 @@ public class PackageDownload { try self.movePackageFromTemporaryToInstalled() try self.deleteDownloadedKmpFile() - NotificationCenter.default.post( - name: .newPackageInstalled, - object: nil, - userInfo: nil - ) + NotificationCenter.default.post(name: .newPackageInstalled, object: nil) } /** @@ -130,11 +122,7 @@ public class PackageDownload { try self.deleteDownloadedKmpFile() try self.movePackageFromTemporaryToInstalled() - NotificationCenter.default.post( - name: .packageReplaced, - object: nil, - userInfo: nil - ) + NotificationCenter.default.post(name: .packageReplaced, object: nil) } /**