Merge pull request #16247 from keymanapp/feat/mac/expand-package
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

feat(mac): expand Keyman package data model 🍎
This commit is contained in:
Shawn Schantz 2026-08-06 08:56:02 -04:00 • committed by GitHub
commit e30e014fbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 328 additions and 61 deletions

View file

@ -19,7 +19,8 @@ struct ConfigView: View {
Image(systemName: "keyboard")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("keyboard count = \(settings.installedPackages.count)")
Text("multiple keyboard package count = \(settings.multiKeyboardPackages.count)")
Text("single keyboard package count = \(settings.singleKeyboardPackages.count)")
Button("debug") {
settings.debug()
}
@ -46,17 +47,39 @@ struct ConfigView: View {
ScrollView {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(settings.installedPackages.enumerated()), id: \.offset) { index, package in
ForEach(Array(settings.singleKeyboardPackages.enumerated()), id: \.offset) { index, package in
VStack {
HStack(alignment: .center, spacing: 10) {
VStack(spacing: 16) {
Text("Scan to visit website:")
.font(.headline)
if let qrImage = package.generateSharePackageQRCode(size: 200) {
Image(nsImage: qrImage)
.interpolation(.none) // important: keeps the QR edges sharp
.resizable()
.frame(width: 200, height: 200)
.background(Color.white) // ensures good scanning contrast
} else {
Text("Failed to generate QR Code")
.foregroundColor(.red)
}
}
.padding()
Text(package.packageName)
.font(.headline)
Text(package.packageVersion)
.font(.subheadline)
// Example of Icon-Only Button
Spacer()
if let nsImage = package.graphicImage {
Image(nsImage: nsImage)
.resizable() // Allows resizing
.scaledToFit() // Maintains original aspect ratio
.frame(maxWidth: 140, maxHeight: 250) // Controls the bounds
}
Button(action: {
settings.removePackage(at: index)
settings.removeInstalledPackage(with: package.id)
}) {
Label("remove", systemImage: "trash.fill")
}

View file

@ -37,7 +37,7 @@ public class InstallationCheck {
self.inputMethodVersion = "unknown"
}
self.configurationVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
self.configurationVersion = ConfigAppUtil.configAppVersion()
self.isInputMethodCurrent = InstallationCheck.isVersionCurrent(inputMethodVersion: self.inputMethodVersion, configurationVersion: self.configurationVersion)
self.installationState = self.loadState()
@ -63,37 +63,37 @@ public class InstallationCheck {
print("InstallationCheck registerObservers")
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(self.handlePermissionNotification(_:)),
name: NSNotification.Name.accessCheck,
selector: #selector(self.handleAccessibilityResponse(_:)),
name: NSNotification.Name.accessibilityQueryResponse,
object: nil // Observe notifications from any sender
)
// MAC-CONFIG_TODO: add timeout?
}
/**
* called when `NSNotification.Name.accessCheck` is received
* called when `NSNotification.Name.accessibilityQueryResponse` is received
*/
@objc func handlePermissionNotification(_ notification: Notification) {
print("handlePermissionNotification")
@objc func handleAccessibilityResponse(_ notification: Notification) {
print("handleAccessibilityResponse")
// Extract message from the notification if available
if let message = notification.object as? String {
let permissionGranted = self.processInputMethodResponse(with: message)
let permissionGranted = self.processAccessibilityResponse(with: message)
self.completeValidation(accessibilityPermissionGranted: permissionGranted)
} else {
print("accessCheckResponse received but did not include message")
print("accessibilityQueryResponse received but did not include message")
}
}
/**
* Process the distributed notification message that we received from the Keyman input method.
*/
func processInputMethodResponse(with message: String) -> Bool {
func processAccessibilityResponse(with message: String) -> Bool {
let timeStyle = Date.FormatStyle()
.hour(.twoDigits(amPM: Date.FormatStyle.Symbol.Hour.AMPMStyle.abbreviated))
.minute(.twoDigits)
.second(.twoDigits)
.secondFraction(.fractional(3))
print("processAccessibilityCheckResponse received message: \(message), time: \(Date().formatted(timeStyle))")
print("processAccessibilityResponse received message: \(message), time: \(Date().formatted(timeStyle))")
// if the message indicates that access was granted, then return true
return !message.isEmpty && message == kAccessibilityPermissionGrantedMessage

View file

@ -28,6 +28,10 @@ NSString *processorType = @"Intel";
NSString *processorType = @"Unknown";
#endif
// distributed notifications
NSString *const kKeyboardsChanged = @"com.keyman.keyboards.changed";
// in-app notifications
NSString *const kKeymanKeyboardDownloadCompletedNotification = @"kKeymanKeyboardDownloadCompletedNotification";
@implementation NSString (VersionNumbers)
@ -125,10 +129,22 @@ id _lastServerWithOSKShowing = nil;
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodDeactivated:) name:kInputMethodDeactivatedNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodChangedClient:) name:kInputMethodClientChangeNotification object:nil];
// register to receive notifications generated from Keyman Configuration App
[[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleKeyboardsChanged:) name:kKeyboardsChanged object:nil];
// start Input Method lifecycle
[KMInputMethodLifecycle.shared startLifecycle];
}
/**
* When packages have been installed, removed, enabled or disabled -- notification from the Keyman Configuration app
*/
- (void)handleKeyboardsChanged:(NSNotification *)notification {
os_log_debug([KMLogs configLog], "***KMInputMethodAppDelegate handleKeyboardsChanged");
[self reloadEnabledKeyboards];
}
/**
* When the input method is activated -- notification from KMInputMethodLifecycle
*/
@ -677,6 +693,24 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef
return _enabledKeyboards;
}
/**
* Called when the enabled keyboards have been changed by the Keyman Config app.
* Reload the enabled keyboards from the UserDefaults
* Then update the Keyman menu
*/
- (void)reloadEnabledKeyboards {
os_log_debug([KMLogs configLog], "reloadEnabledKeyboards");
// read the enabled keyboards from UserDefaults
_enabledKeyboards = [[KMSettingsRepository.shared readEnabledKeyboards] mutableCopy];
// set Sentry tag for how many keyboards are enabled
[KMSentryHelper addEnabledKeyboardCountTag:_enabledKeyboards.count];
// update the keyboard menu to reflect the updated list of enabled keyboards
[self updateKeyboardMenuItems];
}
- (void)saveEnabledKeyboards {
os_log_debug([KMLogs dataLog], "saveEnabledKeyboards");
[KMSettingsRepository.shared writeEnabledKeyboards:_enabledKeyboards];

View file

@ -25,16 +25,19 @@ let package = Package(
.target(
name: "KeymanSettings",
dependencies: [
.product(name: "ZIPFoundation", package: "ZIPFoundation")
.product(name: "ZIPFoundation", package: "ZIPFoundation")
],
path: "Sources"
path: "Sources",
resources: [
.process("Resources")
]
),
.testTarget(
name: "KeymanSettingsTests",
dependencies: ["KeymanSettings"],
path: "Tests",
resources: [
.copy("Resources/amharic.kmp.json") // The test data files, copy files without modifying them
.copy("Resources/amharic.kmp.json") // The test data files, copy files without modifying them
]
),
]

View file

@ -0,0 +1,20 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Shawn Schantz on 2026-07-13
*
* Used for getting application metadata from the Config application
*/
import Foundation
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"
}
}

View file

@ -25,13 +25,17 @@ import Foundation
import Combine
import ZIPFoundation
// distributed notifications
public extension Notification.Name {
static let accessibilityQueryResponse = Notification.Name("com.keyman.accessibility.state")
static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed")
}
// in-app notifications
public extension Notification.Name {
static let accessCheck = Notification.Name("com.keyman.accessibility.state")
static let newPackageInstalled = Notification.Name("com.keyman.package.installed")
static let packageReplaced = Notification.Name("com.keyman.package.replaced")
static let packageDowngradeRequested = Notification.Name("com.keyman.package.downgrade.requested")
static let packageLoadRequired = Notification.Name("com.keyman.package.load.required")
static let packageUpdateRequired = Notification.Name("com.keyman.package.update.required")
}
public enum SettingsError: Error {
@ -40,10 +44,24 @@ public enum SettingsError: Error {
@MainActor // run on the main actor since data is published directly to the UI
public class SettingsContainer : ObservableObject {
// packages are loaded from disk, each package may contain one or more keyboard
@Published public var installedPackages: [KeymanPackage]
// installed packages are loaded from disk, each package may contain one or more keyboard
fileprivate var installedPackages: [KeymanPackage] {
didSet {
// whenever this array is modified, update the arrays that are derived from it
self.updatePackageArrays()
}
}
// Maintain two arrays for use by configuration views.
// One is for packages with single keyboards and one for packages with multiple keyboards.
// Each array is sorted alphabetically by package name.
// These arrays are updated by a property observer on installedPackages.
// (Consider installedPackages as the source of truth and these arrays for presentation purposes.)
@Published public private(set) var singleKeyboardPackages: [KeymanPackage]
@Published public private(set) var multiKeyboardPackages: [KeymanPackage]
// when a new package is downloaded, it is tracked here
public var packageDownload: PackageDownload? = nil
public private(set) var packageDownload: PackageDownload? = nil
fileprivate let packageRepository: PackageRepo
fileprivate let defaultsRepository: DefaultsRepo
@ -53,6 +71,11 @@ public class SettingsContainer : ObservableObject {
fileprivate var selectedKeyboard: String
public init() {
// initialize arrays before loading packages
self.singleKeyboardPackages = []
self.multiKeyboardPackages = []
self.installedPackages = []
// create the package repository, gaining access to the app group container directory
do {
try self.packageRepository = PackageRepository()
@ -72,11 +95,12 @@ public class SettingsContainer : ObservableObject {
} catch {
fatalError("Unable to access defaults in group container: \(error.localizedDescription).")
}
self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard()
// first load all the installed packages from disk
self.installedPackages = []
// load all the installed packages from disk
// though we are in the initializer, didset will be called to update
// the two dependent package arrays because of the call to this helper method
self.loadPackages()
// next, apply the settings to the packages
@ -95,6 +119,8 @@ public class SettingsContainer : ObservableObject {
self.packageRepository = packageRepo
self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard()
self.singleKeyboardPackages = []
self.multiKeyboardPackages = []
self.installedPackages = []
}
@ -133,6 +159,27 @@ public class SettingsContainer : ObservableObject {
self.packageDownload = nil
}
/**
* Whenever the installedPackages array changes, recreate the two subarrays
*/
public func updatePackageArrays() {
self.singleKeyboardPackages = []
self.multiKeyboardPackages = []
// divide the installedPackages array into two separate arrays based on whether they have one or more keyboards
let partitionedPackages = self.installedPackages.reduce(into: (single: [KeymanPackage](), multiple: [KeymanPackage]())) { result, element in
if element.keyboards.count == 1 {
result.single.append(element)
} else if element.keyboards.count > 1 {
result.multiple.append(element)
}
}
// sort subarrays alphabetically, without regard to case, using the 'packageName' property
self.singleKeyboardPackages = partitionedPackages.single.sorted { $0.packageName.caseInsensitiveCompare($1.packageName) == .orderedAscending }
self.multiKeyboardPackages = partitionedPackages.multiple.sorted { $0.packageName.caseInsensitiveCompare($1.packageName) == .orderedAscending }
}
/**
* Called when user approves the downgrade of package
*/
@ -257,9 +304,9 @@ public class SettingsContainer : ObservableObject {
/**
* find the installed package with the specified UUID
*/
public func findPackage(with packageId: UUID) -> KeymanPackage? {
guard let package = self.installedPackages.first(where: { $0.id == packageId }) else {
print ("Error: could not find package with ID: \(packageId)")
public func findInstalledPackage(with id: UUID) -> KeymanPackage? {
guard let package = self.installedPackages.first(where: { $0.id == id }) else {
print ("Error: could not find package with UUID: \(id)")
return nil
}
@ -269,7 +316,7 @@ public class SettingsContainer : ObservableObject {
/**
* find the installed package with the specified package name
*/
public func findPackage(with packageName: String) -> KeymanPackage? {
public func findInstalledPackage(with packageName: String) -> KeymanPackage? {
guard let package = self.installedPackages.first(where: { $0.packageName == packageName }) else {
print ("Error: could not find package with name: \(packageName)")
return nil
@ -279,11 +326,21 @@ public class SettingsContainer : ObservableObject {
}
/**
* remove/uninstall the package at the specified index
* remove/uninstall the package with the specified UUID
*/
public func removePackage(at index: Int) {
let package = self.installedPackages[index]
public func removeInstalledPackage(with id: UUID) {
if let package = findInstalledPackage(with: id) {
self.removeInstalledPackage(package: package)
} else {
print("could not find package with id: \(id)")
}
}
/**
* remove the installed package
*/
func removeInstalledPackage(package: KeymanPackage) {
// will removing this package cause the removal of any enabled keyboards?
let removingEnabledKeyboards = !package.getEnabledKeyboardsKeys().isEmpty
@ -291,20 +348,22 @@ public class SettingsContainer : ObservableObject {
self.packageRepository.deletePackage(package: package)
// remove package from installed packages list
_ = self.installedPackages.remove(at: index)
if let index = self.installedPackages.firstIndex(where: { $0.packageName == package.packageName }) {
self.installedPackages.remove(at: index)
}
// if we removed any enabled keyboards, then update settings
if removingEnabledKeyboards {
self.saveKeyboardState()
}
}
/**
* returns true if the keyboard is enabled
* when enabled, the keyboard appears in the Keyman sub menu in the mac
*/
public func isKeyboardEnabled(packageId: UUID, keyboardKey: String) -> Bool {
guard let package = self.findPackage(with: packageId) else {
guard let package = self.findInstalledPackage(with: packageId) else {
print ("Could not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey)")
return false
}
@ -317,7 +376,7 @@ public class SettingsContainer : ObservableObject {
* enable or disable the keyboard
*/
public func setKeyboardEnabled(packageId: UUID, keyboardKey: String, enabled: Bool) {
guard let package = self.findPackage(with: packageId) else {
guard let package = self.findInstalledPackage(with: packageId) else {
print ("Could not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey)")
return
}

View file

@ -16,6 +16,8 @@ public class Keyboard: Identifiable, Hashable, Equatable {
public var id = UUID()
public var enabled: Bool
public var name: String
public let oskFont: String?
public let displayFont: String?
public var keyboardId: String
// the directory we are reading the keyboard from
public var keyboardDirectoryUrl: URL
@ -30,19 +32,21 @@ public class Keyboard: Identifiable, Hashable, Equatable {
public init(keyboardSource: KeyboardSource, directoryUrl: URL) {
self.enabled = true
self.name = keyboardSource.name
self.oskFont = keyboardSource.oskFont
self.displayFont = keyboardSource.displayFont
self.keyboardId = keyboardSource.id
self.keyboardDirectoryUrl = directoryUrl
self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId)
self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId)
// print("keyboard created for: \(keyboardSource.id) \r with kmxFileUrl: \(self.kmxFileUrl) \r and settingsKey: \(self.keyboardKey)")
}
/**
* initializer that does not rely on package source -- provided to create unit test data
*/
public init(name: String, keyboardId: String, keyboardDirectoryUrl: URL, enabled: Bool) {
public init(name: String, oskFont: String? = nil, displayFont: String? = nil, keyboardId: String, keyboardDirectoryUrl: URL, enabled: Bool) {
self.name = name
self.oskFont = oskFont
self.displayFont = displayFont
self.keyboardId = keyboardId
self.keyboardDirectoryUrl = keyboardDirectoryUrl
self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId)

View file

@ -9,12 +9,17 @@
import Foundation
import AppKit
import Cocoa
import CoreImage
import CoreImage.CIFilterBuiltins
public class KeymanPackage: Identifiable, Hashable, Equatable {
static let defaultImage: NSImage? = {
var image: NSImage? = nil
if let imageUrl = Bundle.main.url(forResource: "SideImage", withExtension: "bmp") {
image = NSImage(contentsOf: imageUrl)
if let imageUrl = Bundle.module.url(forResource: "SideImage", withExtension: "bmp") {
image = NSImage(contentsOf: imageUrl)
} else {
print("Error: Could not find SideImage.bmp in the module bundle.")
}
return image
}()
@ -24,14 +29,23 @@ public class KeymanPackage: Identifiable, Hashable, Equatable {
// the URL of the directory in which the package is contained
public let sourceDirectoryUrl: URL
// the URL of the kmp.json file for the package
public let jsonFileUrl: URL
// the URL for downloading the package from keyman.com
public let sharePackageUrl: URL?
public let keyboards: [Keyboard]
public let fonts: [String]
public let packageName: String
public let packageVersion: String
public let author: String?
public let websiteUrl: URL?
public let copyright: String?
public let jsonFileUrl: URL
// the URL of the readme file within the package
public let readmeFileUrl: URL?
// the URL of the help file within the package, named 'welcomeFile' in kmp.json
public let helpFileUrl: URL?
// the URL of the graphic file within the package
public let graphicFileUrl: URL?
public let graphicImage: NSImage?
@ -42,6 +56,12 @@ public class KeymanPackage: Identifiable, Hashable, Equatable {
self.id = UUID()
self.packageName = packageSource.info.name.description
self.packageVersion = packageSource.info.version.description
self.author = packageSource.info.author?.description
if let websiteUrlString = packageSource.info.website?.url {
self.websiteUrl = URL(string: websiteUrlString)
} else {
self.websiteUrl = nil
}
self.copyright = packageSource.info.copyright?.description
self.sourceDirectoryUrl = packageSource.directoryUrl!
self.jsonFileUrl = packageSource.kmpJsonFileUrl!
@ -52,17 +72,29 @@ public class KeymanPackage: Identifiable, Hashable, Equatable {
} else {
self.readmeFileUrl = nil
}
if let helpFilename = packageSource.helpFilename {
let fileUrl = sourceDirectoryUrl.appendingPathComponent(helpFilename)
self.helpFileUrl = fileUrl
} else {
self.helpFileUrl = nil
}
self.graphicFileUrl = KeymanPackage.buildGraphicFileUrl(source: packageSource)
self.graphicImage = KeymanPackage.loadImage(imageUrl: self.graphicFileUrl)
self.keyboards = KeymanPackage.buildKeyboardsArray(packageSource: packageSource)
self.sharePackageUrl = KeymanPackage.buildSharePackageUrl(packageUrl: self.sourceDirectoryUrl)
let keyboardsArray = KeymanPackage.buildKeyboardsArray(packageSource: packageSource)
self.keyboards = keyboardsArray
self.fonts = KeymanPackage.buildFontNamesArray(keyboards: keyboardsArray)
}
/**
* build an array of Keyboard objects using the array of KeyboardSource object created from the kmp.json
*/
private static func buildKeyboardsArray (packageSource: PackageSource) -> [Keyboard] {
private static func buildKeyboardsArray(packageSource: PackageSource) -> [Keyboard] {
var keyboardsArray = [Keyboard]()
if let keyboards = packageSource.keyboards, let directoryUrl = packageSource.directoryUrl {
@ -74,21 +106,42 @@ public class KeymanPackage: Identifiable, Hashable, Equatable {
return keyboardsArray
}
/**
* build an array of all the fonts used by the specified array of keyboards
*/
private static func buildFontNamesArray(keyboards: [Keyboard]) -> [String] {
var fontNames: Set<String> = []
for keyboard in keyboards {
if let oskFontName = keyboard.oskFont {
fontNames.insert(oskFontName)
}
if let displayFontName = keyboard.displayFont {
fontNames.insert(displayFontName)
}
}
return fontNames.sorted()
}
/**
* initializer that does not rely on package source -- provided to create unit test data
*/
public init(sourceDirectoryUrl: URL, keyboards: [Keyboard], packageName: String, packageVersion: String, copyright: String? = nil, jsonFileUrl: URL, readmeFileUrl: URL? = nil, graphicFileUrl: URL? = nil, graphicImage: NSImage? = nil) {
public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, author: String? = nil, website: URL? = nil, copyright: String? = nil, jsonFileUrl: URL, readmeFileUrl: URL? = nil, helpFileUrl: URL? = nil, graphicFileUrl: URL? = nil, graphicImage: NSImage? = nil) {
self.id = UUID()
self.sourceDirectoryUrl = sourceDirectoryUrl
self.sharePackageUrl = sharePackageUrl
self.keyboards = keyboards
self.packageName = packageName
self.packageVersion = packageVersion
self.author = author
self.websiteUrl = website
self.copyright = copyright
self.jsonFileUrl = jsonFileUrl
self.readmeFileUrl = readmeFileUrl
self.helpFileUrl = helpFileUrl
self.graphicFileUrl = graphicFileUrl
self.graphicImage = graphicImage
self.fonts = []
}
/**
@ -167,6 +220,44 @@ public class KeymanPackage: Identifiable, Hashable, Equatable {
return packageImage
}
/**
* build the URL where the keyboard can be installed from the Keyman website
*/
static func buildSharePackageUrl(packageUrl: URL) -> URL? {
return URL(string: "https://\(KeymanPaths.keymanDomain)/go/keyboard/\(packageUrl.lastPathComponent)/share")
}
// MAC-CONFIG-TODO: cache QR code image, but must be size specific
/**
* generate a QR code for sharing the Keyman Package URL
*/
public func generateSharePackageQRCode(size: CGFloat = 300) -> NSImage? {
guard let data = self.sharePackageUrl?.absoluteString.data(using: .utf8) else { return nil }
// initialize the built-in Apple QR filter
let filter = CIFilter.qrCodeGenerator()
filter.message = data
filter.correctionLevel = "M"
guard let rawQRImage = filter.outputImage else { return nil }
// calculate scaling factor to ensure crisp rendering of QR code
let rawWidth = rawQRImage.extent.width
guard rawWidth > 0 else { return nil }
let scale = size/rawWidth
// apply transform to scale up without blurring (use interpolation = none in SwiftUI image)
let scaledQRImage = rawQRImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
// convert the CIImage to a macOS NSImage
let rep = NSCIImageRep(ciImage: scaledQRImage)
let nsImage = NSImage(size: NSSize(width: size, height: size))
nsImage.addRepresentation(rep)
return nsImage
}
/**
* provided for Hashable conformance
*/

View file

@ -41,6 +41,13 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable {
return nil
}
}
var helpFilename: String? {
if let filename = options?.welcomeFile {
return filename
} else {
return nil
}
}
var graphicFilename: String? {
if let filename = options?.graphicFile {
return filename
@ -144,10 +151,14 @@ struct SystemInfo: Decodable {
struct Options: Decodable {
let readmeFile: String?
let graphicFile: String?
let licenseFile: String?
let welcomeFile: String?
enum CodingKeys: String, CodingKey {
case readmeFile
case graphicFile
case licenseFile
case welcomeFile
}
}

View file

@ -81,8 +81,22 @@ public class DefaultsRepository: DefaultsRepo {
*/
public func writeEnabledKeyboards(enabledKeyboardsArray: [String]) {
self.groupDefaults.set(enabledKeyboardsArray, forKey: kEnabledKeyboardsKey)
self.sendKeyboardsChangedNotification()
}
/**
* Send a distributed notification that the keyboards have changed.
* The input method will receive this and reload the enabled keyboards.
*/
func sendKeyboardsChangedNotification() {
DistributedNotificationCenter.default().postNotificationName (
.keyboardsChanged,
object: nil,
userInfo: nil,
deliverImmediately: true
)
}
/**
* return the selected keyboard from the UserDefaults
* The selected keyboard is the one that Keyman is applying for each keydown event.

View file

@ -40,6 +40,10 @@ public struct KeymanPaths {
static public let configBundleId = "com.keyman.config"
static public let groupId = "group.com.keyman"
static public let keymanDomain = "keyman.com"
static public let keymanHelpDomain = "help.keyman.com"
static public let keymanApiDomain = "api.keyman.com"
// keyman file extensions
static public let keymanPackageFileExtension: String = "kmp"
@ -132,6 +136,9 @@ public struct KeymanPaths {
}
}
/**
* build the URL to the executable inside the specified Input Method app
*/
public func buildInputMethodExecutableUrl(fileName:String) -> URL? {
if let inputMethodUrl = self.buildInputMethodPathUrl(fileName: fileName) {
let executableName = inputMethodUrl.deletingPathExtension().lastPathComponent

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View file

@ -22,11 +22,12 @@ import Foundation
let packageRepo = PackageRepoStub()
let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo)
#expect(settingsContainer.installedPackages.isEmpty)
#expect(settingsContainer.multiKeyboardPackages.isEmpty)
#expect(settingsContainer.singleKeyboardPackages.isEmpty)
settingsContainer.loadPackages()
#expect(settingsContainer.findPackage(with: packageRepo.testPackageId) != nil)
#expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil)
#expect(defaultsRepo.readEnabledKeyboards().contains(moabiteKeyboardKey))
}
@ -37,8 +38,8 @@ import Foundation
settingsContainer.loadPackages()
// verify that we can find that test package and cannot find the null package
#expect(settingsContainer.findPackage(with: packageRepo.testPackageId) != nil)
#expect(settingsContainer.findPackage(with: packageRepo.nullPackageId) == nil)
#expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil)
#expect(settingsContainer.findInstalledPackage(with: packageRepo.nullPackageId) == nil)
}
@Test("Check remove package") @MainActor func testRemovePackage() async throws {
@ -48,9 +49,9 @@ import Foundation
settingsContainer.loadPackages()
// verify that we cannot find the test package after removing it
#expect(settingsContainer.findPackage(with: packageRepo.testPackageId) != nil)
settingsContainer.removePackage(at: 0)
#expect(settingsContainer.findPackage(with: packageRepo.testPackageId) == nil)
#expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil)
settingsContainer.removeMultipleKeyboardPackage(at: 0)
#expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) == nil)
}
/**
@ -78,7 +79,7 @@ import Foundation
let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo)
settingsContainer.loadPackages()
let package = try #require(settingsContainer.findPackage(with: packageRepo.testPackageId))
let package = try #require(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId))
// the hittite keyboard is initialized as disabled
#expect(!package.isKeyboardEnabled(keyboardKey: hittiteKeyboardKey))
@ -171,13 +172,13 @@ import Foundation
@Test("Check Keyman 19 container data directory") func testKeyman19ContainerDataDirectory() async throws {
#expect(true)
let containerDirectory = try #require(KeymanPaths().keyman19ContainerDirectory)
let containerDirectory = try KeymanPaths().keyman19ContainerDirectory
#expect(containerDirectory.absoluteString.hasSuffix("Group%20Containers/group.com.keyman/"))
}
@Test("Check Keyman 19 packages directory") func testKeyman19KeyboardsDirectory() async throws {
#expect(true)
let keyboardsDirectory = try #require(KeymanPaths().keyman19PackagesDirectory)
let keyboardsDirectory = try KeymanPaths().keyman19PackagesDirectory
#expect(keyboardsDirectory.absoluteString.hasSuffix("Group%20Containers/group.com.keyman/Library/Application%20Support/Keyman-Packages/"))
}
}