Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b1d73b4
feat(mac): added several fields to package, including QR Code
sgschantz Jul 13, 2026
adcfd43
feat(mac): published installed packages as two separate arrays
sgschantz Jul 14, 2026
d228eeb
feat(mac): remove reference to private property
sgschantz Jul 15, 2026
be6cd15
Merge branch 'epic/mac-config' into feat/mac/expand-package
sgschantz Jul 15, 2026
7cbb585
feat(mac): update input method after keyboard config change
sgschantz Jul 16, 2026
88c1232
feat(mac): add remove methods for each package list
sgschantz Jul 16, 2026
9d4bc48
Merge branch 'feat/mac/expand-package' of https://github.com/keymanap…
sgschantz Jul 16, 2026
542e326
feat(mac): fix comment
sgschantz Jul 16, 2026
29ed0ab
feat(mac): added comment
sgschantz Jul 16, 2026
fc34086
feat(mac): change keyboardsChanged notification to deliver immediately
sgschantz Jul 16, 2026
b072356
feat(mac): added author website and default image
sgschantz Jul 21, 2026
9cac155
Merge branch 'epic/mac-config' into feat/mac/expand-package
sgschantz Jul 21, 2026
610b254
Merge branch 'feat/mac/expand-package' into feat/mac/notify-input-method
sgschantz Jul 21, 2026
3e72c9e
feat(mac): add method to delete package by UUID
sgschantz Jul 21, 2026
777e3b3
Merge branch 'feat/mac/expand-package' of https://github.com/keymanap…
sgschantz Jul 21, 2026
2d5f66f
Merge branch 'feat/mac/expand-package' into feat/mac/notify-input-method
sgschantz Jul 21, 2026
e733df1
Merge pull request #16250 from keymanapp/feat/mac/notify-input-method
sgschantz Jul 31, 2026
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)")
Comment thread
sgschantz marked this conversation as resolved.
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:")
Comment thread
sgschantz marked this conversation as resolved.
.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 = []
Comment thread
sgschantz marked this conversation as resolved.
}

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)
Comment thread
sgschantz marked this conversation as resolved.
}

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