mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-26 17:47:40 +00:00
Merge pull request #16247 from keymanapp/feat/mac/expand-package
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
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:
commit
e30e014fbe
13 changed files with 328 additions and 61 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
mac/KeymanSettings/Sources/Resources/SideImage.bmp
Normal file
BIN
mac/KeymanSettings/Sources/Resources/SideImage.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
|
|
@ -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/"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue