feat(mac): input method now receives correct version number

handle situation where input method has been moved
or renamed before doing a version number check
This commit is contained in:
Shawn Schantz 2026-06-11 17:10:20 -04:00
parent ccd5f18fc6
commit de4c736cfe
5 changed files with 87 additions and 64 deletions

View file

@ -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
}
/**

View file

@ -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<InstallationTask>()
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

View file

@ -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

View file

@ -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<InstallationTask>()
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<InstallationTask>) {
init(exists: Bool = true, version: String, dateRestartRequested: Date? = nil, isRepair: Bool = false, tasks: Set<InstallationTask>) {
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<String, Any>) {
// 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

View file

@ -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() {