mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-26 17:47:40 +00:00
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run
When installed, packages keep a reference to their location on disk so that they can delete their associated files if removed. However, installation from the internet now happens via a temp directory. The directory must be updated when moved to the install directory and there can be no trace of the temp directory path anywhere when loading other data from the package. The cleanest way to accomplish this was to use computed properties for the locations of all the data like welcome.htm and the graphic image. These properties are based on the single package directory URL, and this URL is updated when the package is moved.
300 lines
9.7 KiB
Swift
300 lines
9.7 KiB
Swift
/*
|
|
* 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
|
|
|
|
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 {
|
|
print("Error: Could not find SideImage.bmp in the module bundle.")
|
|
}
|
|
return image
|
|
}()
|
|
|
|
public let id: UUID
|
|
|
|
// the URL of the directory in which the package is contained
|
|
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 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) }
|
|
}
|
|
|
|
// 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) }
|
|
}
|
|
|
|
// 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) }
|
|
}
|
|
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.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)
|
|
}
|
|
|
|
/**
|
|
* 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<String> = []
|
|
for keyboard in keyboards {
|
|
if let oskFontName = keyboard.oskFont {
|
|
fontNames.insert(oskFontName)
|
|
}
|
|
if let displayFontName = keyboard.displayFont {
|
|
fontNames.insert(displayFontName)
|
|
}
|
|
}
|
|
return fontNames.sorted()
|
|
}
|
|
|
|
/**
|
|
* initializer that does not rely on package source -- provided to create unit test data
|
|
*/
|
|
public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, author: String? = nil, website: URL? = nil, copyright: String? = nil, readmeFileName: String? = nil, helpFilename: String? = nil, graphicName: String? = nil) {
|
|
self.id = UUID()
|
|
self.sourceDirectoryUrl = sourceDirectoryUrl
|
|
self.sharePackageUrl = sharePackageUrl
|
|
self.keyboards = keyboards
|
|
self.packageName = packageName
|
|
self.packageVersion = packageVersion
|
|
self.author = author
|
|
self.websiteUrl = website
|
|
self.copyright = copyright
|
|
self.readmeFilename = readmeFileName
|
|
self.helpFilename = helpFilename
|
|
self.graphicFilename = graphicName
|
|
self.fonts = []
|
|
}
|
|
|
|
/**
|
|
* 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 whether the package contain a kmx file for each of its keyboards
|
|
*/
|
|
public func validate() throws {
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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://\(KeymanPaths.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
|
|
print("Used cached QR code for package: \(self.packageName)")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
print("Cached QR code for package: \(self.packageName)")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|