/* * Keyman is copyright (C) SIL Global. MIT License. * * Created by Shawn Schantz on 2026-02-24 * * Object that represents a Keyman package * KeymanPackage is immutable, but the state of the Keyboards in the keyboards array can change */ import Foundation import AppKit import Cocoa import CoreImage import CoreImage.CIFilterBuiltins import OSLog public class KeymanPackage: Identifiable, Hashable, Equatable { static let defaultImage: NSImage? = { var image: NSImage? = nil if let imageUrl = Bundle.module.url(forResource: "SideImage", withExtension: "bmp") { image = NSImage(contentsOf: imageUrl) } else { Logger.setup.error("error: could not find SideImage.bmp in the module bundle") LogUtil.errorBreadcrumb("error: could not find SideImage.bmp in the module bundle", category: .setup) } return image }() public let id: UUID // The directory where this package is contained. // It is used to delete/uninstall the package if requested by the user. // This value only changes when the package is moved // from the temp directory during package installation. public var sourceDirectoryUrl: 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 minimumSupportedKeymanVersion: String public let author: String? public let websiteUrl: URL? public let copyright: String? // the name of the readme file used to generate the Url let readmeFilename: String? // the URL of the readme file within the package public var readmeFileUrl: URL? { return readmeFilename.map { sourceDirectoryUrl.appendingPathComponent($0).standardizedFileURL } } // the name of the help file used to generate the Url let helpFilename: String? // the URL of the help file within the package, named 'welcomeFile' in kmp.json public var helpFileUrl: URL? { return helpFilename.map { sourceDirectoryUrl.appendingPathComponent($0).standardizedFileURL } } // the name of the graphicFile used to generate the Url let graphicFilename: String? // a cache of the image private var cachedGraphicImage: NSImage? private var graphicFileUrl: URL? { return graphicFilename.map { sourceDirectoryUrl.appendingPathComponent($0).standardizedFileURL } } public var graphicImage: NSImage? { get { if let cachedImage = self.cachedGraphicImage { return cachedImage } else { let newImage = KeymanPackage.loadImage(imageUrl: self.graphicFileUrl) self.cachedGraphicImage = newImage return newImage } } } // the qrCode image does not change size so a single cached image per package is sufficient var qrCodeImageCache: (image: NSImage, size: CGFloat)? = nil /** * create a KeymanPackage object using the location of the package and the PackageSource object created from the kmp.json */ init(packageUrl: URL, packageSource: PackageSource) { self.id = UUID() self.sourceDirectoryUrl = packageUrl self.packageName = packageSource.info.name.description self.packageVersion = packageSource.info.version.description self.minimumSupportedKeymanVersion = packageSource.system.fileVersion.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.readmeFilename = packageSource.readmeFilename self.helpFilename = packageSource.helpFilename self.graphicFilename = packageSource.graphicFilename self.sharePackageUrl = KeymanPackage.buildSharePackageUrl(packageUrl: self.sourceDirectoryUrl) let packageDirectory = packageUrl.lastPathComponent let keyboardsArray = KeymanPackage.buildKeyboardsArray(packageSource: packageSource, packageDirectoryName: packageDirectory) self.keyboards = keyboardsArray self.fonts = KeymanPackage.buildFontNamesArray(keyboards: keyboardsArray) } /** * initializer to create for kmp.inf file */ public init( packageUrl: URL, keyboards: [Keyboard], fonts: [String], packageName: String, packageVersion: String, minimumKeymanVersion: String = "7.0.0", author: String? = nil, websiteUrl: URL? = nil, copyright: String? = nil, readmeFilename: String? = nil, helpFilename: String? = nil, graphicFilename: String? = nil ) throws { self.id = UUID() self.sourceDirectoryUrl = packageUrl self.sharePackageUrl = KeymanPackage.buildSharePackageUrl(packageUrl: self.sourceDirectoryUrl) self.keyboards = keyboards self.fonts = fonts self.packageName = packageName self.packageVersion = packageVersion self.minimumSupportedKeymanVersion = minimumKeymanVersion self.author = author self.websiteUrl = websiteUrl self.copyright = copyright self.readmeFilename = readmeFilename self.helpFilename = helpFilename self.graphicFilename = graphicFilename } /** * build an array of Keyboard objects using the array of KeyboardSource object created from the kmp.json and the package URL */ private static func buildKeyboardsArray(packageSource: PackageSource, packageDirectoryName: String) -> [Keyboard] { var keyboardsArray = [Keyboard]() if let keyboards = packageSource.keyboards { for keyboardSource in keyboards { let keyboard = Keyboard(keyboardSource: keyboardSource, packageDirectoryName: packageDirectoryName) keyboardsArray.append(keyboard) } } 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 = [] for keyboard in keyboards { if let oskFontName = keyboard.oskFont { fontNames.insert(oskFontName) } if let displayFontName = keyboard.displayFont { fontNames.insert(displayFontName) } } return fontNames.sorted() } /** * find the keyboard with the specified key in the package and return its enabled state */ public func isKeyboardEnabled(keyboardKey: String) -> Bool { var enabled = false if let keyboard = self.keyboards.first(where: { $0.keyboardKey == keyboardKey }) { enabled = keyboard.enabled } return enabled } /** * find the keyboard with the specified key in the package and set its enabled state */ public func enableKeyboard(keyboardKey: String, enabled: Bool) { let keyboard = self.keyboards.first(where: { $0.keyboardKey == keyboardKey }) if (keyboard != nil) { keyboard!.enabled = enabled } } /** * get all the keys for enabled keyboards in the package */ public func getEnabledKeyboardsKeys() -> [String] { var settingsKeyArray = [String]() self.keyboards.forEach { keyboard in if (keyboard.enabled) { settingsKeyArray.append(keyboard.keyboardKey) } } return settingsKeyArray } /** * validate the package * 1. whether the package can be loaded by this version of Keyman * 2. whether it contains a kmx file for each of its keyboards */ public func validate() throws { try self.validateKeymanVersionForPackage() // if validateKmxFile throws an error, then the loop is stopped and the error is propagated try self.keyboards.forEach { keyboard in try keyboard.validateKmxFile(in: self.sourceDirectoryUrl) } } /** * verify that the version of Keyman is equal to our newer than the * minimum required Keyman version specifed by the package */ func validateKeymanVersionForPackage() throws { let keymanVersion = ConfigAppUtil.configAppVersion() let minimumKeymanVersion = self.minimumSupportedKeymanVersion var meetsRequiredVersion: Bool = false let comparisonResult = keymanVersion.compare(minimumKeymanVersion, options: .numeric) if comparisonResult == .orderedAscending { // keyman version is too old meetsRequiredVersion = false Logger.data.log("validateKeymanVersionForPackage for package: '\(self.packageName, privacy: .public)' keyman version \(keymanVersion, privacy: .public) is older than required version \(minimumKeymanVersion, privacy: .public)") } else { meetsRequiredVersion = true } if (!meetsRequiredVersion) { throw LoadPackageError.insufficientKeymanVersion(packageName: self.packageName, requiredKeymanVersion: minimumKeymanVersion, actualKeymanVersion: keymanVersion) } } /** * create the image specified for the package * if none specified, load the default image */ static func loadImage(imageUrl: URL?) -> NSImage? { var packageImage: NSImage? = nil; if let fileUrl = imageUrl, let image = NSImage(contentsOf: fileUrl) { packageImage = image } else { packageImage = KeymanPackage.defaultImage } 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://\(InputMethodUtil.keymanDomain)/go/keyboard/\(packageUrl.lastPathComponent)/share") } /** * get a QR code image of the specified size from the cache or generate a new one */ public func getSharePackageQRCode(for size: CGFloat) -> NSImage? { var qrCodeImage: NSImage? = nil // if the image is already cached for the specified size, then use it if let qrImageCache = self.qrCodeImageCache { if size == qrImageCache.size { qrCodeImage = qrImageCache.image } } // if no matching image cached, then create one and cache it if qrCodeImage == nil { if let newImage = self.generateSharePackageQRCode(for: size) { qrCodeImage = newImage self.qrCodeImageCache = (newImage, size) } } return qrCodeImage } /** * generate a QR code image for sharing the Keyman Package URL */ func generateSharePackageQRCode(for size: CGFloat) -> 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 */ public func hash(into hasher: inout Hasher) { hasher.combine(id) // only combine the unique ID } /** * provided for Equatable conformance */ public static func == (lhs: KeymanPackage, rhs: KeymanPackage) -> Bool { return lhs.id == rhs.id // only compare unique IDs } }