/* * Keyman is copyright (C) SIL Global. MIT License. * * Created by Shawn Schantz on 2025-12-10 * * PackageRepository is responsible for reading, writing and removing * Keyman data on disk. * */ import Foundation import OSLog public enum LoadPackageError: LocalizedError { case invalidUrl case unzipError case containsNoFiles case containsNoKeyboards case kmpJsonFileUnreadable case kmpInfFileUnreadable case kmpJsonFileNotFound case missingKeyboardName case missingKeyboardId case missingKeyboardVersion case missingKmxFile case insufficientKeymanVersion(packageName: String, requiredKeymanVersion: String, actualKeymanVersion: String) private var packageBundle: LocalizedStringResource.BundleDescription { .atURL(Bundle.module.bundleURL) } public var errorDescription: String? { switch self { case .invalidUrl: let resource = LocalizedStringResource( "invalid.url", defaultValue: "The URL is not valid.", bundle: packageBundle) return String(localized: resource) case .unzipError: let resource = LocalizedStringResource( "could.not.unzip", defaultValue: "The keyboard package could not be unzipped.", bundle: packageBundle) return String(localized: resource) case .containsNoFiles: let resource = LocalizedStringResource( "contains.no.files", defaultValue: "The keyboard package contains no files.", bundle: packageBundle) return String(localized: resource) case .containsNoKeyboards: let resource = LocalizedStringResource( "contains.no.keyboards", defaultValue: "The keyboard package contains no keyboards.", bundle: packageBundle) return String(localized: resource) case .kmpJsonFileUnreadable: let resource = LocalizedStringResource( "kmp.json.unreadable", defaultValue: "The package's kmp.json file could not be parsed.", bundle: packageBundle) return String(localized: resource) case .kmpInfFileUnreadable: let resource = LocalizedStringResource( "kmp.inf.unreadable", defaultValue: "The package's kmp.inf file could not be parsed.", bundle: packageBundle) return String(localized: resource) case .kmpJsonFileNotFound: let resource = LocalizedStringResource( "kmp.json.not.found", defaultValue: "The package's kmp.json file was not found.", bundle: packageBundle) return String(localized: resource) case .missingKeyboardName: let resource = LocalizedStringResource( "keyboard.name.missing", defaultValue: "A keyboard in the package has no name.", bundle: packageBundle) return String(localized: resource) case .missingKeyboardId: let resource = LocalizedStringResource( "keyboard.id.missing", defaultValue: "A keyboard in the package has no ID.", bundle: packageBundle) return String(localized: resource) case .missingKeyboardVersion: let resource = LocalizedStringResource( "keyboard.missing.version", defaultValue: "A keyboard in the package has no version.", bundle: packageBundle) return String(localized: resource) case .missingKmxFile: let resource = LocalizedStringResource( "keyboard.missing.kmx.file", defaultValue: "A keyboard in the package has no corresponding .KMX file.", bundle: packageBundle) return String(localized: resource) case .insufficientKeymanVersion(let packageName, let requiredKeymanVersion, let actualKeymanVersion): let resource = LocalizedStringResource( "insufficient.keyman.version", defaultValue: "The keyboard package '\(packageName)' requires Keyman version \(requiredKeymanVersion) but your version is \(actualKeymanVersion).", bundle: packageBundle) return String(localized: resource) } } } public class PackageRepository: PackageRepo { fileprivate let packageJsonFilename = "kmp.json" fileprivate let packageInfFilename = "kmp.inf" fileprivate let pathUtil: KeymanPaths public init() throws { self.pathUtil = try KeymanPaths() try self.createKeyman19SharedDataDirectoriesIfNeeded() } /** * Load the Keyman packages from disk and add it to the `installedPackages` array if it passes validation. */ public func loadAllPackages() -> [KeymanPackage] { var installedPackages: [KeymanPackage] = [] let packageMap = self.readPackages() for (url, package) in packageMap { do { try package.validate() installedPackages.append(package) } catch { Logger.data.error("validation failed for \(url.lastPathComponent, privacy: .public) with error: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("validation failed for \(url.lastPathComponent) with error: \(error as NSError)", category: .data) } } return installedPackages } /** * read packages at Keyman 19 location, Keyman-Packages directory inside Group Containers directory */ func readPackages() -> [URL: KeymanPackage] { let packageDirectoryUrl = self.pathUtil.keyman19PackagesDirectory var packageMap: [URL: KeymanPackage] = [:] do { // Get the URLs for all items in the directory that are not hidden let directoryContents = try FileManager.default.contentsOfDirectory( at: packageDirectoryUrl, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) for itemUrl in directoryContents { // if the item is a directory, then attempt to read it as a keyboard package if (itemUrl.hasDirectoryPath) { do { if let package = try readPackageFromDirectory(packageDirectoryUrl: itemUrl) { packageMap[itemUrl] = package } } catch let error as LoadPackageError { Logger.data.error("package at \(itemUrl.cleanUrlPath(), privacy: .public) could not be loaded: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("package at \(itemUrl.cleanUrlPath()) could not be loaded: \(error as NSError)", category: .data) } } } } catch { Logger.data.error("failed to read directory: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("failed to read directory: \(error as NSError)", category: .data) } Logger.data.info("readPackageSource: \(packageMap.count) packages read") LogUtil.infoBreadcrumb("readPackageSource: \(packageMap.count) packages read", category: .data) return packageMap } /** * Load the single package from disk and wrap it as a `KeymanPackage` object * If the `KeymanPackage` passes validation, then add it to the `installedPackages` array. * */ public func loadSinglePackage(packageUrl: URL) throws -> KeymanPackage { Logger.data.info("loadSinglePackage from url: \(packageUrl.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("loadSinglePackage from url: \(packageUrl.cleanUrlPath())", category: .data) guard let package = try readPackageFromDirectory(packageDirectoryUrl: packageUrl) else { throw LoadPackageError.invalidUrl } try package.validate() return package } /** * delete the package from disk */ public func deletePackage(package: KeymanPackage) { Logger.data.info("deleting package: \(package.sourceDirectoryUrl.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("deleting package: \(package.sourceDirectoryUrl.cleanUrlPath())", category: .data) do { try FileManager.default.removeItem(at: package.sourceDirectoryUrl) Logger.data.info("deleted package: \(package.sourceDirectoryUrl.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("deleted package: \(package.sourceDirectoryUrl.cleanUrlPath())", category: .data) } catch { Logger.data.error("could not delete directory: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("could not delete directory: \(error as NSError)", category: .data) } } /** * Creates the directory tree where packages are stored under the standard 'Group Containers' directory * Also creates the temp directory used for keyboard installation */ public func createKeyman19SharedDataDirectoriesIfNeeded() throws { let packageDirectory = pathUtil.keyman19PackagesDirectory let packageTempDirectory = pathUtil.keyman19TempDirectory // create the keyman-packages directory if it doesn't already exist if !FileManager.default.fileExists(atPath: packageDirectory.path(percentEncoded: false)) { try FileManager.default.createDirectory(at: packageDirectory, withIntermediateDirectories: true, attributes: nil) Logger.data.info("Created directory: \(packageDirectory.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("Created directory: \(packageDirectory.cleanUrlPath())", category: .data) } else { Logger.data.info("Directory already exists: \(packageDirectory.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("Directory already exists: \(packageDirectory.cleanUrlPath())", category: .data) } // create the temp directory if it doesn't already exist if !FileManager.default.fileExists(atPath: packageTempDirectory.path(percentEncoded: false)) { try FileManager.default.createDirectory(at: packageTempDirectory, withIntermediateDirectories: true, attributes: nil) Logger.data.info("Created directory: \(packageTempDirectory.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("Created directory: \(packageTempDirectory.cleanUrlPath())", category: .data) } else { Logger.data.info("Directory already exists: \(packageTempDirectory.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("Directory already exists: \(packageTempDirectory.cleanUrlPath())", category: .data) } } /** * Delete all the files in the temp directory */ public func cleanupTempDirectory() { let fileManager = FileManager.default do { let fileURLs = try fileManager.contentsOfDirectory( at: self.pathUtil.keyman19TempDirectory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles ) for fileURL in fileURLs { try fileManager.removeItem(at: fileURL) } Logger.data.info("successfully cleared temp directory") LogUtil.infoBreadcrumb("successfully cleared temp directory", category: .data) } catch { Logger.data.error("error clearing temp directory: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("error clearing temp directory: \(error as NSError)", category: .data) } } /** * get the url to where the specified kmp file should be downloaded */ public func getDownloadUrl(for kmpFilename: String) -> URL { return self.pathUtil.keyman19TempDirectory.appendingPathComponent(kmpFilename) } /** * get the url to where the specified package should initially be unzipped */ public func getUnzipDestinationUrl(for packageName: String) -> URL { return self.pathUtil.keyman19TempDirectory.appendingPathComponent(packageName) } /** * build the URL where the specified package will be installed */ public func buildInstallationUrlForPackageName(directoryName: String) -> URL { return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(directoryName) } /** * install keyboard at specified URL */ public func unzipKmpFile(at kmpFileUrl: URL, to packageDestinationUrl: URL) throws { do { try FileManager.default.unzipItem(at: kmpFileUrl, to: packageDestinationUrl) Logger.data.info("successfully unzipped the file") LogUtil.infoBreadcrumb("successfully unzipped the file", category: .data) } catch { Logger.data.error("extraction failed: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("extraction failed: \(error as NSError)", category: .data) throw LoadPackageError.unzipError } } /** * Check to see whether the shared Keyman data directory exists under 'Library/Group Containers/' */ public func keyman19SharedDataDirectoryExists() -> Bool { return self.directoryExistsAtPath(directoryUrl: self.pathUtil.keyman19PackagesDirectory) } /** * 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(percentEncoded: false), isDirectory: &isDirectory) return exists && isDirectory.boolValue } /** * check the specified directory for the kmp.json file and read it if it exists */ func readPackageFromDirectory(packageDirectoryUrl: URL) throws -> KeymanPackage? { Logger.data.info("readKeymanPackageFromDirectory from url: \(packageDirectoryUrl.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("readKeymanPackageFromDirectory from url: \(packageDirectoryUrl.cleanUrlPath())", category: .data) var keymanPackage: KeymanPackage? = nil let kmpJsonFileUrl = packageDirectoryUrl.appendingPathComponent(packageJsonFilename) let kmpInfFileUrl = packageDirectoryUrl.appendingPathComponent(packageInfFilename) if FileManager.default.fileExists(atPath: kmpJsonFileUrl.path(percentEncoded: false)) { // if an error occurs, it will be propagated to caller if let package = try readPackageFromJson(kmpJsonFileUrl: kmpJsonFileUrl, in: packageDirectoryUrl) { keymanPackage = package } } else { // if no kmp.json file, look for kmp.inf instead if FileManager.default.fileExists(atPath: kmpInfFileUrl.path(percentEncoded: false)) { Logger.data.info("fallback to kmp.inf file at: \(kmpInfFileUrl.cleanUrlPath(), privacy: .public)") LogUtil.infoBreadcrumb("fallback to kmp.inf file at: \(kmpInfFileUrl.cleanUrlPath())", category: .data) if let package = try readPackageFromInf(kmpInfFileUrl: kmpInfFileUrl, in: packageDirectoryUrl) { keymanPackage = package } } else { // no kmp.json and no kmp.inf, so throw error // the error designates only kmp.json missing as kmp.inf is a fallback throw LoadPackageError.kmpJsonFileNotFound } } return keymanPackage } /** * read and parse the kmp.json file at the specified URL */ func readPackageFromJson(kmpJsonFileUrl: URL, in packageDirectoryUrl: URL) throws -> KeymanPackage? { var packageSource: PackageSource? var package: KeymanPackage? do { let jsonData = try Data(contentsOf: kmpJsonFileUrl, options: .mappedIfSafe) packageSource = try JSONDecoder().decode(PackageSource.self, from: jsonData) if let source = packageSource { package = KeymanPackage(packageUrl: packageDirectoryUrl, packageSource: source) } } catch let error as LoadPackageError { // if we encounter a LoadPackageError, propagate it throw error } catch { // otherwise convert the error to a LoadPackageError error Logger.data.error("readKeymanPackageFromJson error: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("readKeymanPackageFromJson error: \(error as NSError)", category: .data) throw LoadPackageError.kmpJsonFileUnreadable } return package } /** * read and parse the kmp.json file at the specified URL */ func readPackageFromInf(kmpInfFileUrl: URL, in packageDirectoryUrl: URL) throws -> KeymanPackage? { var package: KeymanPackage? = nil do { let packageReader = try KmpInfParser(fileUrl: kmpInfFileUrl, in: packageDirectoryUrl) if let keymanPackage = try packageReader.parse() { package = keymanPackage Logger.data.info("successfully read package using kmp.inf: \(keymanPackage.packageName, privacy: .public)") LogUtil.infoBreadcrumb("successfully read package using kmp.inf: \(keymanPackage.packageName)", category: .data) } } catch let error as LoadPackageError { // if we encounter a LoadPackageError, propagate it throw error } catch { // otherwise convert the error to a LoadPackageError error Logger.data.error("readPackageFromInf, failed to read kmp.inf file: \(kmpInfFileUrl.cleanUrlPath(), privacy: .public), error: \(error as NSError)") LogUtil.errorBreadcrumb("readPackageFromInf, failed to read kmp.inf file: \(kmpInfFileUrl.cleanUrlPath()), error: \(error as NSError)", category: .data) throw LoadPackageError.kmpInfFileUnreadable } return package } }