From bb816a133588df8d9922b0ce8acbb5eb1ef35aed Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Thu, 2 Apr 2026 14:01:11 -0400 Subject: [PATCH] feat(mac): display packages and keyboard list view added buttons for removal of packages and toggle for disabling of keyboard not remotely final but mostly for ensuring that we can bind to the data model and respond to updates --- mac/Config/Config/ConfigView.swift | 64 ++++++-- mac/Config/Config/KeyboardListView.swift | 34 +++++ .../KeymanSettings/SettingsContainer.swift | 141 +++++++++++++++--- .../Sources/Model/Keyboard.swift | 81 +++++++++- .../Sources/Model/KeymanPackage.swift | 40 ++++- .../Persistence/PackageRepository.swift | 35 ++++- .../Persistence/SettingsRepository.swift | 33 ++-- .../KeymanSettingsTests.swift | 35 ++++- 8 files changed, 394 insertions(+), 69 deletions(-) create mode 100644 mac/Config/Config/KeyboardListView.swift diff --git a/mac/Config/Config/ConfigView.swift b/mac/Config/Config/ConfigView.swift index 575ceb732c..64ee8f460f 100644 --- a/mac/Config/Config/ConfigView.swift +++ b/mac/Config/Config/ConfigView.swift @@ -11,17 +11,61 @@ import KeymanSettings struct ConfigView: View { @EnvironmentObject var settings: SettingsContainer + // selectedItem cannot be used with .enumerated + //@State private var selectedItem: KeymanPackage? var body: some View { VStack { - Image(systemName: "keyboard") - .imageScale(.large) - .foregroundColor(.accentColor) - Text(settings.settingName) - Text("keyboard count = \(settings.keyboardPackages.count)") - Button("Update settings") {settings.settingName = "Settings changed!"} - Button("Remove last keyboard") {if !settings.keyboardPackages.isEmpty { - settings.keyboardPackages.removeLast()} + HStack { + Image(systemName: "keyboard") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("keyboard count = \(settings.installedKeyboardPackages.count)") + Button("debug") { + settings.debug() + } + Spacer() + } + .padding() + +// ScrollView(showsIndicators: false) { +// List { +// // Use $ to get binding to the published array +// ForEach($settings.installedKeyboardPackages) { $package in +// Section(header: Text("Section")) { +// // Use $ to get binding to the nested array +// ForEach($package.keyboards) { $keyboard in +// Toggle(keyboard.keyboardId, isOn: $keyboard.enabled) // Binding here +// } +// } +// } +// } +// .padding() +// } + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(settings.installedKeyboardPackages.enumerated()), id: \.offset) { index, package in + VStack { + HStack(alignment: .center, spacing: 10) { + Text(package.packageName) + .font(.headline) + Text(package.packageVersion) + .font(.subheadline) + // Example of Icon-Only Button + Spacer() + Button(action: { + settings.installedKeyboardPackages.remove(at: index) + }) { + Label("remove", systemImage: "trash.fill") + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + KeyboardListView(packageId: package.id, keyboards: package.keyboards) + } + } + } + .padding(.trailing, 25) // allow space for scroll bar } } .padding() @@ -29,7 +73,7 @@ struct ConfigView: View { } #Preview { - let configuration = Configuration() + let settings = SettingsContainer() ConfigView() - .environmentObject(configuration) + .environmentObject(settings) } diff --git a/mac/Config/Config/KeyboardListView.swift b/mac/Config/Config/KeyboardListView.swift new file mode 100644 index 0000000000..2a74130342 --- /dev/null +++ b/mac/Config/Config/KeyboardListView.swift @@ -0,0 +1,34 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-02 + * + * Subview to display list of keyboards for a package + */ + +import SwiftUI +import KeymanSettings +import Combine + +struct KeyboardListView: View { + @EnvironmentObject var settings: SettingsContainer + @State var packageId: UUID + @State var keyboards: [Keyboard] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach($keyboards) { $keyboard in + HStack { + Toggle("", isOn: Binding( + get: { settings.isKeyboardEnabled(packageId: packageId, keyboardId: keyboard.keyboardId) }, + set: { newValue in settings.setKeyboardEnabled(packageId: packageId, keyboardId: keyboard.keyboardId, enabled: newValue) + settings.objectWillChange.send() } + )) + Text(keyboard.keyboardId) + .padding(.leading, 5) + Spacer() + } + } + } + } +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 9f4227068a..a273153ac8 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -1,25 +1,89 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-02 + * + * Class that exposes all settings information to the Config app + * Provides a place for the Config app can bind directly to the settings + * and update when changes are made + * + */ + +import Foundation import Combine public class SettingsContainer : ObservableObject { - - @Published public var settingName: String - @Published public var keyboardPackages: [KeymanPackage] - @Published public var activeKeyboards: Set - @Published public var currentKeyboard: String - - fileprivate let dataRepository: PackageRepository + // keyboard packages are defined on disk + @Published public var installedKeyboardPackages: [KeymanPackage] + + fileprivate let packageRepository: PackageRepository fileprivate let settingsRepository: SettingsRepository - + + // active keyboards and selected keyboard are stored in the UserDefaults + fileprivate var activeKeyboards: Set + fileprivate var currentKeyboard: String + public init() { - settingName = "uninitialized" - self.dataRepository = PackageRepository() - self.settingsRepository = SettingsRepository(suiteName: KeymanPaths.groupId) + self.packageRepository = PackageRepository() + + do { + try self.settingsRepository = SettingsRepository(suiteName: KeymanPaths.groupId) + print("Found group container") + } catch UserDefaultsError.unknownSuite { + fatalError("Group container not found.") + } catch { + fatalError("Unable to access settings in group container.") + } self.activeKeyboards = self.settingsRepository.readActiveKeyboards() self.currentKeyboard = self.settingsRepository.readSelectedKeyboard() - self.keyboardPackages = [] + self.installedKeyboardPackages = [] + self.loadPackages() + self.synchronizeSettings() + self.applySettings() + } + + // TODO: delete test code + public func debug() { + self.installedKeyboardPackages .forEach { package in + package.keyboards.forEach { keyboard in + print("\(keyboard.keyboardId) enabled: \(keyboard.enabled)") + } + } + } + + // TODO: extract method for keyboard search + // TODO: throw error for keyboard not found or log and fail silently (should never happen) + public func isKeyboardEnabled(packageId: UUID, keyboardId: String) -> Bool { + var enabled = false; + guard let package = self.installedKeyboardPackages.first(where: { $0.id == packageId }) else { + print ("ERROR: setKeyboardEnabled could not find package with ID: \(packageId)") + return enabled + } + + enabled = package.isKeyboardEnabled(keyboardId: keyboardId) + print ("isEnabled for \(keyboardId) returning with \(enabled)") + + return enabled + } + + public func setKeyboardEnabled(packageId: UUID, keyboardId: String, enabled: Bool) { + let package = self.installedKeyboardPackages.first(where: { $0.id == packageId }) + if (package == nil) { + print ("ERROR: setKeyboardEnabled could not find package with ID: \(packageId)") + } else { + package!.enableKeyboard(keyboardId: keyboardId, enabled: enabled) + print ("setKeyboardEnabled for \(keyboardId) setting to \(enabled)") + } + } + + /** + * read the Keyman packages from the group container directory and store in the keyboardPackages array + */ + func loadPackages() { + // TODO: create keyboards directory if it doesn't exist // if let keyboardsUrl = self.pathUtil.keymanKeyboardsDirectory { // // if FileManager.default.fileExists(atPath: keyboardsUrl.path) { @@ -28,21 +92,50 @@ public class SettingsContainer : ObservableObject { // print("non-existent directory: \(keyboardsUrl.absoluteString)") // } // } - - // load keyboards from disk - if (self.dataRepository.keyman19SharedDataDirectoryExists()) { - // TODO: remove test code - self.dataRepository.writeSomethingToContainer() - let packageSourceArray = self.dataRepository.readKeymanPackagesForKeyman19() + // load keyboards from disk + if (self.packageRepository.keyman19SharedDataDirectoryExists()) { + // TODO: remove test code + self.packageRepository.writeSomethingToContainer() - // create a KeymanPackage object for each PackageSource object and insert it in the array - for source in packageSourceArray { - let package = KeymanPackage(packageSource: source) - self.keyboardPackages.append(package) - } + self.installedKeyboardPackages = self.packageRepository.loadPackages() } else { - self.dataRepository.createKeyman19SharedDataDirectories() + self.packageRepository.createKeyman19SharedDataDirectories() } } + + /** + * returns set containing the keyboards settings keys for the keyboards of every installed package + */ + func getAllKeyboardSettingsKeys() -> Set { + var settingsKeys = Set() + + // loop through all the installed packages and for each of the package's keyboards, + // insert the settings key for the keyboard + self.installedKeyboardPackages.forEach { $0.keyboards.forEach + {settingsKeys.insert($0.keyboardSettingsKey)} + } + + return settingsKeys + } + + /** + * remove any settings (UserDefaults) for which we have no installed package + */ + func synchronizeSettings() { + let installedPackageKeys = self.getAllKeyboardSettingsKeys() + let activeKeyboardKeys = self.settingsRepository.readActiveKeyboards() + let commonKeyboardKeys = installedPackageKeys.intersection(activeKeyboardKeys) + + // TODO: replace the active keyboards list with the intersection of the current + // active keyboards list and the installed packages list + } + + /** + * apply the current settings to the installed packages + */ + func applySettings() { + + } + } diff --git a/mac/KeymanSettings/Sources/Model/Keyboard.swift b/mac/KeymanSettings/Sources/Model/Keyboard.swift index 593d8a7e5a..17634f97da 100644 --- a/mac/KeymanSettings/Sources/Model/Keyboard.swift +++ b/mac/KeymanSettings/Sources/Model/Keyboard.swift @@ -3,8 +3,8 @@ * * Created by Shawn Schantz on 2026-02-24 * - * Wrapper bject that represents a Keyman keyboard as loaded from a .KMP file - * Can be loaded but disabled + * Wrapper object that represents a Keyman keyboard as loaded from a .KMP file + * Mostly immutable, but can be marked as enabled/disabled * */ @@ -12,19 +12,86 @@ import Foundation import AppKit -public struct Keyboard: Identifiable, Hashable, Equatable { +public class Keyboard: Identifiable, Hashable, Equatable { public var id = UUID() public var enabled: Bool public var name: String public var keyboardId: String - - public init(keyboardSource: KeyboardSource) { + // the directory we are reading the keyboard from + public var keyboardDirectoryUrl: URL + // the URL of the .kmx file for the package + public let kmxFileUrl: URL + // the UserDefaults key for this package, used for the selected Keyboard and active keyboards + // the key is in the form "/[package directory]/[package name].kmx" + // for example, "/khmer_angkor/khmer_angkor.kmx" + public let keyboardSettingsKey: String + + public init(keyboardSource: KeyboardSource, directoryUrl: URL) { self.enabled = true self.name = keyboardSource.name self.keyboardId = keyboardSource.id - + self.keyboardDirectoryUrl = directoryUrl + self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + self.keyboardSettingsKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + // ConfigLogger.shared.testLogger.debug("keyboard created for: \(keyboardSource.id)") - print("keyboard created for: \(keyboardSource.id)") + print("keyboard created for: \(keyboardSource.id) \r with kmxFileUrl: \(self.kmxFileUrl) \r and settingsKey: \(self.keyboardSettingsKey)") } + + public static func == (lhs: Keyboard, rhs: Keyboard) -> Bool { + return lhs.keyboardId == rhs.keyboardId && lhs.enabled == rhs.enabled + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(keyboardId) + } + + static func deriveKmxFileUrl(from keyboardDirectory: URL, keyboardId: String) -> URL { + return keyboardDirectory.appendingPathComponent("\(keyboardId).kmx") + } + + static func deriveKeyboardSettingsKey(from keyboardDirectory: URL, keyboardId: String) -> String { + // get parent directory + let parentDirectoryName = keyboardDirectory.lastPathComponent + + // get filename from keyboardId + let kmxFilename = "\(keyboardId).kmx" + + let settingsKey = "/\(parentDirectoryName)/\(kmxFilename)" + print("settingsKey: \(settingsKey)") + + return settingsKey + } + + // TODO: throw/handle error if no kmx file found in directory + public func validateKmxFile() -> Bool { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: self.kmxFileUrl.path) { + return true + } else { + print(" *** Error: could not find kmx file \(self.kmxFileUrl.path)") + return false + } + } + + public func findKmxFile(in keyboardDirectoryUrl: URL) -> URL? { + var kmxFileUrl: URL? = nil + let fileManager = FileManager.default + + do { + let fileURLs = try fileManager.contentsOfDirectory(at: keyboardDirectoryUrl, includingPropertiesForKeys: nil) + let filteredFiles = fileURLs.filter { $0.pathExtension == "kmx" } + + if (!filteredFiles.isEmpty) { + kmxFileUrl = filteredFiles.first! + } + } catch { + print("Error reading directory: \(error)") + } + + return kmxFileUrl; + } + + } diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index b9c0c7feb6..624564b108 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -4,7 +4,7 @@ * Created by Shawn Schantz on 2026-02-24 * * Wrapper object that represents a Keyman package as loaded from a .KMP file - * Can be loaded but disabled + * Mostly immutable, but can be marked as enabled/disabled * */ @@ -12,7 +12,7 @@ import Foundation import AppKit // MARK: - Root -public struct KeymanPackage: Identifiable, Hashable, Equatable { +public class KeymanPackage: Identifiable, Hashable, Equatable { static let defaultImage: NSImage? = { var image: NSImage? = nil if let imageUrl = Bundle.main.url(forResource: "SideImage", withExtension: "bmp") { @@ -22,11 +22,12 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { }() public var id = UUID() - public var enabled: Bool let source: PackageSource - public let keyboards: [Keyboard] + public var keyboards: [Keyboard] + // 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 public let readmeFileUrl: URL? public let graphicFileUrl: URL? @@ -48,7 +49,6 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { } init(packageSource: PackageSource) { - self.enabled = true self.source = packageSource self.sourceDirectoryUrl = packageSource.directoryUrl! self.jsonFileUrl = packageSource.jsonFileUrl! @@ -67,7 +67,7 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { if let keyboards = source.keyboards { for keyboardSource in keyboards { - let keyboard = Keyboard(keyboardSource: keyboardSource) + let keyboard = Keyboard(keyboardSource: keyboardSource, directoryUrl: self.sourceDirectoryUrl) keyboardsArray.append(keyboard) } } @@ -77,6 +77,34 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { print("package created for: \(packageSource.packageName)") } + public func isKeyboardEnabled(keyboardId: String) -> Bool { + var enabled = false + if let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) { + enabled = keyboard.enabled + } + + return enabled + } + + public func enableKeyboard(keyboardId: String, enabled: Bool) { + let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) + if (keyboard != nil) { + keyboard!.enabled = enabled + } + } + + public func validate() -> Bool { + var validKeyboards = self.keyboards.isEmpty == false + + self.keyboards.forEach { keyboard in + if (validKeyboards && !keyboard.validateKmxFile()) { + validKeyboards = false + } + } + + return validKeyboards + } + static func buildGraphicFileUrl(source: PackageSource) -> URL? { var fileUrl: URL? = nil diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index 16e7fa65ae..e2515f349d 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -18,6 +18,23 @@ public struct PackageRepository { self.pathUtil = KeymanPaths() } + func loadPackages() -> [KeymanPackage] { + var installedPackages: [KeymanPackage] = [] + let packageSourceArray = self.readKeymanPackagesForKeyman19() + + // create a KeymanPackage object for each PackageSource object and insert it in the array if it is valid + for source in packageSourceArray { + let package = KeymanPackage(packageSource: source) + if (package.validate()) { + installedPackages.append(package) + } else { + print("package '\(source.packageName)' is not valid") + } + } + + return installedPackages + } + /** * Creates the directory tree where keyboards are stored under the standard 'Group Containers' directory */ @@ -44,6 +61,9 @@ public struct PackageRepository { } } + /** + * for group container testing purposes to check directory access + */ public func writeSomethingToContainer() { if let keyboardDirectory = self.pathUtil.keyman19KeyboardsDirectory { print("About to write to: \(keyboardDirectory.path)") @@ -74,6 +94,9 @@ public struct PackageRepository { return self.directoryExistsAtPath(directoryUrl: keyboardsUrl) } + /** + * returns true if a directory exists at the specified URL + */ func directoryExistsAtPath(directoryUrl: URL) -> Bool { var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: directoryUrl.path, isDirectory: &isDirectory) @@ -110,7 +133,7 @@ public struct PackageRepository { ) for itemUrl in directoryContents { - // if the item is a directory, then attempt to read it as a keyboard + // if the item is a directory, then attempt to read it as a keyboard package if (itemUrl.hasDirectoryPath) { if let packageSource = readKeyboardPackageFromDirectory(keyboardDirectoryUrl: itemUrl) { packages.append(packageSource) @@ -124,27 +147,27 @@ public struct PackageRepository { print("\(packages.count) packages read") return packages } - + func readKeyboardPackageFromDirectory(keyboardDirectoryUrl: URL) -> PackageSource? { var packageSource: PackageSource? - let lastPathComponent = keyboardDirectoryUrl.lastPathComponent let kmpFileUrl = keyboardDirectoryUrl.appendingPathComponent(packageFileName) + if FileManager.default.fileExists(atPath: kmpFileUrl.path) { if let source = readKeyboardPackage(kmpFileUrl) { packageSource = source // save the keyboard directory and path of the kmp.json for this keyboard packageSource?.directoryUrl = keyboardDirectoryUrl packageSource?.jsonFileUrl = kmpFileUrl - } + } } else { -// ConfigLogger.shared.testLogger.debug(" ** \(lastPathComponent) DOES NOT contain the file 'kmp.json')") + // ConfigLogger.shared.testLogger.debug(" ** \(lastPathComponent) DOES NOT contain the file 'kmp.json')") print(" ** \(lastPathComponent) DOES NOT contain the file 'kmp.json')") } return packageSource } - + public func readKeyboardPackage(_ kmpFileUrl: URL) -> PackageSource? { var packageSource: PackageSource? do { diff --git a/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift b/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift index 817016edfc..58e6a7c64a 100644 --- a/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift @@ -8,9 +8,12 @@ * */ - import Foundation +public enum UserDefaultsError: Error { + case unknownSuite +} + public struct SettingsRepository { fileprivate let pathUtil: KeymanPaths let defaultsSuiteName: String @@ -19,7 +22,8 @@ public struct SettingsRepository { let kActiveKeyboardsKey = "KMActiveKeyboardsKey" let kSelectedKeyboardKey = "KMSelectedKeyboardKey" let kPersistedOptionsKey = "KMPersistedOptionsKey" - let kShowOskOnActivate = "KMShowOskOnActivate" + let kDataModelVersionKey = "KMDataModelVersionKey" + let kShowOskOnActivateKey = "KMShowOskOnActivate" let kForceSentryError = "KMForceSentryError" // public init() { @@ -27,10 +31,16 @@ public struct SettingsRepository { // self.defaultsSuiteName = KeymanPaths.groupId // } // - public init(suiteName: String) { + public init(suiteName: String) throws(Error) { self.pathUtil = KeymanPaths() self.defaultsSuiteName = suiteName - self.defaults = UserDefaults(suiteName: suiteName) + + let userDefaults = UserDefaults(suiteName: suiteName) + if userDefaults == nil { + throw(UserDefaultsError.unknownSuite) + } else { + self.defaults = userDefaults + } } public func readActiveKeyboards() -> Set { @@ -46,13 +56,16 @@ public struct SettingsRepository { } public func readSelectedKeyboard() -> String { - guard let sharedDefaults = self.defaults else { - print("Group container UserDefaults not found.") + if let sharedDefaults = self.defaults { + return sharedDefaults.value(forKey: kSelectedKeyboardKey) as? String ?? "" + } else { return "" } - - let selectedKeyboard = sharedDefaults.value(forKey: kSelectedKeyboardKey) as? String ?? "" - - return selectedKeyboard + } + + public func writeSelectedKeyboard(keyboardName: String) { + if let sharedDefaults = self.defaults { + sharedDefaults.set(keyboardName, forKey: kSelectedKeyboardKey) + } } } diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift index 363550f4bf..768635a9ae 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift @@ -50,8 +50,36 @@ import Foundation } } +// defaults read test.suite.name -@Suite("Load KMP data") struct KeymanDataRepositoryTests { +@Suite("Settings check") struct KeymanSettingsRepositoryTests { + var settingsRepo: SettingsRepository + + fileprivate init() async throws { + print("init Settings") + do { + try self.settingsRepo = SettingsRepository(suiteName: "test.suite.name") + print("Found group container") + } catch UserDefaultsError.unknownSuite { + fatalError("Group container not found.") + } catch { + fatalError("Unable to access settings in group container.") + } + + self.settingsRepo.writeSelectedKeyboard(keyboardName: "keyboardtest") + } + + @Test("Write selected keyboard") func writeSelectedKeyboard() async throws { + settingsRepo.writeSelectedKeyboard(keyboardName: "testing") + #expect(settingsRepo.readSelectedKeyboard() == "testing") + } + + @Test("Read selected keyboard") func readSelectedKeyboard() async throws { + #expect(settingsRepo.readSelectedKeyboard() == "keyboardtest") + } +} + +@Suite("Load KMP data") struct KeymanPackageRepositoryTests { var testKmpUrl: URL? = nil fileprivate init() async throws { @@ -62,11 +90,6 @@ import Foundation return Bundle.module.url(forResource: "amharic.kmp", withExtension: "json") } - @Test("Find amharic .kmp file") func locateKmpTestData() async throws { - let kmpUrl = try #require(self.getAmharicKmpUrl()) - #expect(kmpUrl.isFileURL) - } - @Test("Read package") func loadPackageSource() async throws { let dataRepo = PackageRepository()