diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/Config/Installation/InputMethodUtil.swift index ae5f804bac..590ebc1a3e 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/Config/Installation/InputMethodUtil.swift @@ -17,6 +17,11 @@ extension Notification.Name { static let accessCheck = Notification.Name("com.keyman.accessibility.state") } +public enum KeymanVersionCheckError: Error { + case inputMethodNotFound + case versionNotFound +} + public enum KeymanInvocationError: Error { case inputMethodNotFound case inputMethodCouldNotBeInvoked @@ -38,7 +43,19 @@ public class InputMethodUtil { public init() { self.pathUtil = KeymanPaths() } - + + /** + * true if the Keyman input method exists at `~/Library/Input Methods` + */ + public func keymanInputMethodExists() -> Bool { + guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { + print("Keyman input method not found, failed to create input method url") + return false + } + + return FileManager.default.fileExists(atPath: inputMethodUrl.path) + } + /** * true if the Keyman input method of the correct version exists in the correct location */ @@ -50,8 +67,8 @@ public class InputMethodUtil { /** * Returns version number string of Keyman input method */ - public func getKeymanInputMethodVersion() -> String? { - return self.appVersion(applicationName: keymanInputMethodApplicationName) + public func getKeymanInputMethodVersion() throws -> String { + return try self.appVersionForInputMethod(applicationName: keymanInputMethodApplicationName) } /** @@ -122,37 +139,26 @@ public class InputMethodUtil { } /** - * Returns version number string for application with specified name + * Returns version number string for the specifed app located at `~/Library/Input Methods` */ - func appVersion(applicationName: String) -> String? { - var version: String? = nil - - if let location = pathUtil.buildInputMethodPathUrl(fileName: applicationName) { - print("app location: \(location.absoluteString)") - if let keyputBundle = Bundle(url: location) { - print("app bundle path: \(keyputBundle.bundlePath)") - if let infoDictionary = keyputBundle.infoDictionary { - print("infoDictionary key count: \(infoDictionary.count)") - if let appVersion = infoDictionary["CFBundleShortVersionString"] as? String { - print("app '\(applicationName)', appVersion: \(appVersion)") - version = appVersion - } - } - } else { - print("cannot create KeyPut bundle") - } - /* - if let keyputBundle = Bundle(url: location), let infoDictionary = keyputBundle.infoDictionary, let appVersion = infoDictionary["CFBundleShortVersionString"] as? String { - ConfigLogger.shared.testLogger.debug("App Version: \(appVersion)") - } else { - ConfigLogger.shared.testLogger.debug("App Version not found") - } - */ - } else { - print("app '\(applicationName)' not found") + func appVersionForInputMethod(applicationName: String) throws -> String { + guard let location = pathUtil.buildInputMethodPathUrl(fileName: applicationName) else { + throw KeymanVersionCheckError.inputMethodNotFound } - return version + guard let keymanBundle = Bundle(url: location) else { + throw KeymanVersionCheckError.inputMethodNotFound + } + + guard let infoDictionary = keymanBundle.infoDictionary else { + throw KeymanVersionCheckError.versionNotFound + } + + guard let appVersionString = infoDictionary["CFBundleShortVersionString"] as? String else { + throw KeymanVersionCheckError.versionNotFound + } + + return appVersionString } /** diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift index 0922b7dbe2..c23c1c126f 100644 --- a/mac/Config/Installation/InstallationCheck.swift +++ b/mac/Config/Installation/InstallationCheck.swift @@ -13,19 +13,32 @@ import KeymanSettings public class InstallationCheck { - fileprivate let keymanVersion: String + fileprivate let inputMethodVersion: String fileprivate let defaultsRepository: DefaultsRepo fileprivate let inputMethodUtil: InputMethodUtil + fileprivate let inputMethodExists: Bool - public init(version: String, defaultsRepo: DefaultsRepo, inputMethodUtil: InputMethodUtil) { - self.keymanVersion = version + public init(defaultsRepo: DefaultsRepo, inputMethodUtil: InputMethodUtil) { self.defaultsRepository = defaultsRepo self.inputMethodUtil = inputMethodUtil + let exists = inputMethodUtil.keymanInputMethodExists() + + if exists { + self.inputMethodExists = true + self.inputMethodVersion = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown" + } else { + self.inputMethodExists = false + self.inputMethodVersion = "unknown" + } } public func evaluate() -> InstallationState { var installationState: InstallationState + guard self.inputMethodExists else { + return createInstallationStateForNonexistentInputMethod() + } + if let savedInstallationState = readInstallationState() { // check whether the installation requires repair if let repairInstallationState = self.createRepairInstallationState(savedInstallationState: savedInstallationState) { @@ -34,8 +47,7 @@ public class InstallationCheck { } else { installationState = savedInstallationState } - } else - { + } else { // if installation could not be read, then installationState = self.createInstallationStateForNewInstallation() } @@ -61,12 +73,23 @@ public class InstallationCheck { func checkForNewInstallation() { // MAC-CONFIG-TODO: impelement version checks } - + + /** + * Create InstallationState for non-existent input method (installer needs to be run) + * Note that this is not persisted, because a non-existent installation doesn't really have state. + * Instead this is used to indicate to the user that they need to run the installer and try again. + */ + func createInstallationStateForNonexistentInputMethod() -> InstallationState { + var taskList = Set() + taskList.insert(InstallationTask(task: .verifyInputMethod, completed: false)) + return InstallationState(exists: false, version: "unknown", tasks: taskList) + } + /** * Creates a InstallationState object describing a new installation */ func createInstallationStateForNewInstallation() -> InstallationState { - let installationState = InstallationState(version: self.keymanVersion, tasks: self.createNewInstallationTasks()) + let installationState = InstallationState(version: self.inputMethodVersion, tasks: self.createNewInstallationTasks()) self.defaultsRepository.writeInstallationState(installationState.toUserDefaultsDictionary()) return installationState @@ -112,7 +135,7 @@ public class InstallationCheck { } if !repairTasks.isEmpty { - repairInstallationState = InstallationState(version: self.keymanVersion, tasks: repairTasks) + repairInstallationState = InstallationState(version: self.inputMethodVersion, tasks: repairTasks) } return repairInstallationState diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift index 8ebf062029..d07c5e5f71 100644 --- a/mac/Config/Installation/InstallationContainer.swift +++ b/mac/Config/Installation/InstallationContainer.swift @@ -34,8 +34,8 @@ public class InstallationContainer : ObservableObject { self.defaultsRepository = defaultsRepo inputMethodUtil = InputMethodUtil() notificationCenter = DistributedNotificationCenter.default() - - self.installationState = InstallationCheck(version: "1.0.0", defaultsRepo: defaultsRepo, inputMethodUtil: inputMethodUtil).evaluate() + + self.installationState = InstallationCheck(defaultsRepo: defaultsRepo, inputMethodUtil: inputMethodUtil).evaluate() self.registerObservers() } @@ -212,7 +212,7 @@ public class InstallationContainer : ObservableObject { * for testing purposes, replace the InstallationState with a new object set for a new installation */ func resetInstallation() { - self.installationState = InstallationCheck(version: "1.0.0", defaultsRepo: self.defaultsRepository, inputMethodUtil: self.inputMethodUtil).createInstallationStateForNewInstallation() + self.installationState = InstallationCheck(defaultsRepo: self.defaultsRepository, inputMethodUtil: self.inputMethodUtil).createInstallationStateForNewInstallation() } /** @@ -234,11 +234,11 @@ public class InstallationContainer : ObservableObject { } public func debug() { - let version = inputMethodUtil.getKeymanInputMethodVersion() + let version = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown" let enabled = inputMethodUtil.isKeymanInputMethodEnabled() let running = inputMethodUtil.isKeymanInputMethodRunning() let accessGranted = self.accessIsGranted() - print("Keyman status, version: \(version ?? ""), enabled: \(enabled), running: \(running), accessGranted: \(accessGranted)") + print("Keyman status, version: \(version), enabled: \(enabled), running: \(running), accessGranted: \(accessGranted)") } @@ -314,11 +314,12 @@ 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 requests the system to prompt the user to grant accessibility - * listen for message from Keyman to indicate the result + * 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()` */ - // MAC-CONFIG-TODO: do we get an accurate message from Keyman Input Method public func requestAccessibility() -> Bool { var requested = false diff --git a/mac/Config/Installation/InstallationState.swift b/mac/Config/Installation/InstallationState.swift index 106c8b68f4..50866b330e 100644 --- a/mac/Config/Installation/InstallationState.swift +++ b/mac/Config/Installation/InstallationState.swift @@ -16,6 +16,7 @@ public class InstallationState { let kDateRestartRequestedKey = "dateRestartRequested" let kRepairKey = "isRepair" + public let inputMethodExists: Bool public let keymanVersion: String public var dateRestartRequested: Date? // indicates whether we are repairing a previous installation or doing a full installation @@ -27,20 +28,8 @@ public class InstallationState { tasks.allSatisfy(\.isComplete) } - /** - * Create InstallationState for new install - */ - static func createForNewInstall(version: String) -> InstallationState { - var installationTasks = Set() - installationTasks.insert(InstallationTask(task: .migrateData, completed: false)) - installationTasks.insert(InstallationTask(task: .enableInputMethod, completed: false)) - installationTasks.insert(InstallationTask(task: .requestAccess, completed: false)) - installationTasks.insert(InstallationTask(task: .restartMac, completed: false)) - - return InstallationState(version: version, tasks: installationTasks) - } - - init(version: String, dateRestartRequested: Date? = nil, isRepair: Bool = false, tasks: Set) { + init(exists: Bool = true, version: String, dateRestartRequested: Date? = nil, isRepair: Bool = false, tasks: Set) { + self.inputMethodExists = exists self.keymanVersion = version self.dateRestartRequested = dateRestartRequested self.isRepair = isRepair @@ -51,6 +40,8 @@ public class InstallationState { * initialize using the dictionary from UserDefaults */ init?(from dictionary: Dictionary) { + // assume that the input method exists, as we only attempt to load this data when it does + self.inputMethodExists = true self.keymanVersion = dictionary[kVersionKey] as? String ?? "" self.dateRestartRequested = dictionary[kDateRestartRequestedKey] as? Date self.isRepair = dictionary[kRepairKey] as? Bool ?? false diff --git a/mac/installer/build.sh b/mac/installer/build.sh index 7892d47cde..a38a34e9fa 100755 --- a/mac/installer/build.sh +++ b/mac/installer/build.sh @@ -62,7 +62,8 @@ function archive_apps() { -configuration Release \ -archivePath "${KEYMAN_BUILD_PATH}/Keyman.xcarchive" \ ARCHS=\"arm64 x86_64\" \ - ONLY_ACTIVE_ARCH=NO + ONLY_ACTIVE_ARCH=NO \ + PRODUCT_VERSION=$KEYMAN_VERSION # xcodebuild for x86_64 and arm64 (universal binary) mac_xcodebuild archive -workspace "$KEYMAN_WORKSPACE_PATH" \ @@ -70,7 +71,8 @@ function archive_apps() { -configuration Release \ -archivePath "${KEYMAN_BUILD_PATH}/Config.xcarchive" \ ARCHS=\"arm64 x86_64\" \ - ONLY_ACTIVE_ARCH=NO + ONLY_ACTIVE_ARCH=NO \ + PRODUCT_VERSION=$KEYMAN_VERSION } function export_apps() {