Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions mac/Config/Config/ConfigView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion mac/Config/Installation/InstallationCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 6 additions & 3 deletions mac/KeymanSettings/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
),
]
Expand Down
20 changes: 20 additions & 0 deletions mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift
Original file line number Diff line number Diff line change
@@ -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"
}


}
92 changes: 73 additions & 19 deletions mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,23 @@ 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 {
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
Expand All @@ -53,6 +66,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()
Expand All @@ -72,11 +90,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
Expand All @@ -95,6 +114,8 @@ public class SettingsContainer : ObservableObject {
self.packageRepository = packageRepo
self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard()

self.singleKeyboardPackages = []
self.multiKeyboardPackages = []
self.installedPackages = []
}

Expand All @@ -114,7 +135,7 @@ public class SettingsContainer : ObservableObject {
name: .packageReplaced, object: nil
)
}

/**
* called for `newPackageInstalled` notification
*/
Expand All @@ -133,6 +154,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
*/
Expand Down Expand Up @@ -257,9 +299,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
}

Expand All @@ -269,7 +311,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
Expand All @@ -279,32 +321,44 @@ 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

// delete package from disk
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
}
Expand All @@ -317,7 +371,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
}
Expand Down
10 changes: 7 additions & 3 deletions mac/KeymanSettings/Sources/Model/Keyboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading