mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-06 08:55:34 +00:00
Fixes: KEYMAN-WEB-RX (It was produced artificially after this PR's changes to validate them.) Cross-reference with https://github.com/getsentry/sentry-javascript/blob/master/docs/migration/v7-to-v8.md in regard to minimum supported versions. @keymanapp-test-bot skip
377 lines
15 KiB
Swift
377 lines
15 KiB
Swift
//
|
|
// Storage.swift
|
|
// KeymanEngine
|
|
//
|
|
// Created by Gabriel Wong on 2017-11-21.
|
|
// Copyright © 2017 SIL International. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Sentry
|
|
import os.log
|
|
|
|
// MARK: - Static members
|
|
extension Storage {
|
|
/// Storage that is not shared with the other process (app or extension).
|
|
static let nonShared: Storage? = {
|
|
let paths = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)
|
|
if paths.isEmpty {
|
|
return nil
|
|
}
|
|
let message = "Storage.nonShared: using path \(paths[0])"
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .info, message)
|
|
SentryManager.breadcrumb(message)
|
|
return Storage(baseURL: paths[0], userDefaults: UserDefaults.standard)
|
|
}()
|
|
|
|
/// Storage shared between the app and app extension.
|
|
/// - Important: `Manager.applicationGroupIdentifier` must be set before accessing this. Modifications to
|
|
/// `Manager.applicationGroupIdentifier` will not be reflected here.
|
|
static let shared: Storage? = {
|
|
guard let groupID = Manager.applicationGroupIdentifier else {
|
|
return nil
|
|
}
|
|
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID),
|
|
let userDefaults = UserDefaults(suiteName: groupID)
|
|
else {
|
|
return nil
|
|
}
|
|
return Storage(baseURL: url, userDefaults: userDefaults)
|
|
}()
|
|
|
|
/// Active storage to be used for app data. This is `shared` if possible, otherwise `nonShared`.
|
|
/// - Important: `Manager.applicationGroupIdentifier` must be set before accessing this. Modifications to
|
|
/// `Manager.applicationGroupIdentifier` will not be reflected here.
|
|
static let active: Storage! = {
|
|
if let shared = shared {
|
|
return shared
|
|
}
|
|
return nonShared
|
|
}()
|
|
}
|
|
|
|
class Storage {
|
|
let baseDir: URL
|
|
let keyboardDir: URL
|
|
let lexicalModelDir: URL
|
|
let userDefaults: UserDefaults
|
|
|
|
let kmwURL: URL
|
|
let specialOSKFontURL: URL
|
|
|
|
private init?(baseURL: URL, userDefaults: UserDefaults) {
|
|
guard
|
|
let baseDir = Storage.createSubdirectory(baseDir: baseURL, name: "keyman"),
|
|
let keyboardDir = Storage.createSubdirectory(baseDir: baseDir, name: "keyboards"),
|
|
let lexicalModelDir = Storage.createSubdirectory(baseDir: baseDir, name: "lexicalModels")
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
self.baseDir = baseDir
|
|
self.keyboardDir = keyboardDir
|
|
self.lexicalModelDir = lexicalModelDir
|
|
self.userDefaults = userDefaults
|
|
kmwURL = baseDir.appendingPathComponent(Resources.kmwFilename)
|
|
specialOSKFontURL = baseDir.appendingPathComponent(Resources.oskFontFilename)
|
|
}
|
|
|
|
private static func createSubdirectory(baseDir: URL, name: String) -> URL? {
|
|
let newDir = baseDir.appendingPathComponent(name)
|
|
do {
|
|
try FileManager.default.createDirectory(at: newDir,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil)
|
|
return newDir
|
|
} catch {
|
|
let message = ("Failed to create subdirectory at \(newDir)")
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message)
|
|
SentryManager.capture(error, message:message)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Note: legacy* methods are utilized for migrations through 14.0, at which point
|
|
// KeymanEngine transitioned entirely to using LanguageResource instances
|
|
// for path lookups.
|
|
|
|
func legacyKeyboardDir(forID keyboardID: String) -> URL {
|
|
return keyboardDir.appendingPathComponent(keyboardID)
|
|
}
|
|
|
|
func legacyLexicalModelDir(forID lexicalModelID: String) -> URL {
|
|
return lexicalModelDir.appendingPathComponent(lexicalModelID)
|
|
}
|
|
|
|
func packageDir(forID packageID: String, ofType resourceType: LanguageResourceType) -> URL {
|
|
switch resourceType {
|
|
case .keyboard:
|
|
return keyboardDir.appendingPathComponent(packageID)
|
|
case .lexicalModel:
|
|
return lexicalModelDir.appendingPathComponent(packageID)
|
|
}
|
|
}
|
|
|
|
func packageDir(forKey key: KeymanPackage.Key) -> URL {
|
|
return packageDir(forID: key.id, ofType: key.type)
|
|
}
|
|
|
|
func packageDir(for package: KeymanPackage) -> URL? {
|
|
return packageDir(forID: package.id, ofType: package.resourceType())
|
|
}
|
|
|
|
func resourceDir(for resource: AnyLanguageResource) -> URL? {
|
|
if let keyboard = resource as? InstallableKeyboard {
|
|
return packageDir(forID: keyboard.packageID ?? keyboard.id, ofType: .keyboard)
|
|
} else if let lexicalModel = resource as? InstallableLexicalModel {
|
|
return packageDir(forID: lexicalModel.packageID ?? lexicalModel.id, ofType: .lexicalModel)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func keyboardURL(for keyboard: InstallableKeyboard) -> URL {
|
|
return resourceDir(for: keyboard)!.appendingPathComponent("\(keyboard.id).js")
|
|
}
|
|
|
|
func lexicalModelURL(for lexicalModel: InstallableLexicalModel) -> URL {
|
|
return resourceDir(for: lexicalModel)!.appendingPathComponent("\(lexicalModel.id).model.js")
|
|
}
|
|
|
|
// Facilitates abstractions based on the LanguageResource protocol.
|
|
func resourceURL(for resource: AnyLanguageResource) -> URL? {
|
|
if let keyboard = resource as? InstallableKeyboard {
|
|
return keyboardURL(for: keyboard)
|
|
} else if let lexicalModel = resource as? InstallableLexicalModel {
|
|
return lexicalModelURL(for: lexicalModel)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Facilitates abstractions based on the LanguageResource protocol.
|
|
func legacyResourceURL(for resource: AnyLanguageResource) -> URL? {
|
|
if let keyboard = resource as? InstallableKeyboard {
|
|
return legacyKeyboardURL(forID: keyboard.id, version: keyboard.version)
|
|
} else if let lexicalModel = resource as? InstallableLexicalModel {
|
|
return legacyLexicalModelURL(forID: lexicalModel.id, version: lexicalModel.version)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func lexicalModelPackageURL(for lexicalModel: InstallableLexicalModel) -> URL {
|
|
return resourceDir(for: lexicalModel)!.appendingPathComponent("\(lexicalModel.id).model.kmp")
|
|
}
|
|
|
|
// TODO: Once we drop cloud-keyboard downloads, remove this function.
|
|
func cloudKeyboardURL(forID keyboardID: String) -> URL {
|
|
// For now, we treat cloud keyboards as KMP-less packages, generating a kmp.json after download.
|
|
return packageDir(forID: keyboardID, ofType: .keyboard).appendingPathComponent("\(keyboardID).js")
|
|
}
|
|
|
|
func legacyKeyboardURL(forID keyboardID: String, version: String) -> URL {
|
|
// We no longer embed version numbers into filenames
|
|
// This allows us to support existing installs and OEM products that
|
|
// still include the version string, at reasonable cost
|
|
// Note, that for installing a keyboard, it will no longer perform this
|
|
// rename with this model, as the target file will obviously not already
|
|
// exist.
|
|
let filename = legacyKeyboardDir(forID: keyboardID).appendingPathComponent("\(keyboardID).js")
|
|
if FileManager.default.fileExists(atPath: filename.path) {
|
|
return filename
|
|
}
|
|
return legacyKeyboardDir(forID: keyboardID).appendingPathComponent("\(keyboardID)-\(version).js")
|
|
}
|
|
|
|
func legacyLexicalModelURL(forID lexicalModelID: String, version: String) -> URL {
|
|
return legacyLexicalModelDir(forID: lexicalModelID).appendingPathComponent("\(lexicalModelID)-\(version).model.js")
|
|
}
|
|
|
|
func legacyLexicalModelPackageURL(forID lexicalModelID: String, version: String, asZip: Bool = false) -> URL {
|
|
// Our unzipping dependency requires a .zip extension to function, so we append that here.
|
|
return legacyLexicalModelDir(forID: lexicalModelID).appendingPathComponent("\(lexicalModelID)-\(version).model.kmp\(asZip ? ".zip" : "")")
|
|
}
|
|
|
|
func fontURL(forResource resource: AnyLanguageResource, filename: String) -> URL? {
|
|
return resourceDir(for: resource)?.appendingPathComponent(filename)
|
|
}
|
|
|
|
var keyboardDirs: [URL]? {
|
|
let contents: [URL]
|
|
do {
|
|
contents = try FileManager.default.contentsOfDirectory(at: keyboardDir,
|
|
includingPropertiesForKeys: [.isDirectoryKey])
|
|
} catch {
|
|
let message = ("Failed to list contents at \(keyboardDir) with error \(error)")
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message)
|
|
SentryManager.capture(error, message:message)
|
|
return nil
|
|
}
|
|
return contents.filter { url in
|
|
do {
|
|
let values = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
return values.isDirectory ?? false
|
|
} catch {
|
|
let message = "\(String(describing: error))"
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message)
|
|
SentryManager.capture(error)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Copying
|
|
extension Storage {
|
|
func copyKMWFiles(from bundle: Bundle) throws {
|
|
try Storage.copy(from: bundle,
|
|
resourceName: Resources.kmwFilename,
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "ios-host.js",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "keymanweb-webview.js",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "keyman-sentry.js",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "kmwosk.css",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "keymanweb-osk.ttf",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
}
|
|
|
|
func installDefaultKeyboard(from bundle: Bundle) throws {
|
|
let defaultKeyboardDir = self.resourceDir(for: Defaults.keyboard)!
|
|
try FileManager.default.createDirectory(at: defaultKeyboardDir, withIntermediateDirectories: true)
|
|
|
|
// Since we only want to do this installation the first time (rather than constantly force-reinstalling
|
|
// the resource), we don't want this excluded from backup.
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "\(Defaults.keyboard.id).kmp",
|
|
dstDir: defaultKeyboardDir,
|
|
excludeFromBackup: false)
|
|
|
|
do {
|
|
// Perform an auto-install of the keyboard's KMP if not already installed.
|
|
let defaultKMPFile = defaultKeyboardDir.appendingPathComponent("\(Defaults.keyboard.id).kmp")
|
|
let package = try ResourceFileManager.shared.prepareKMPInstall(from: defaultKMPFile) as! KeyboardKeymanPackage
|
|
try ResourceFileManager.shared.install(resourceWithID: Defaults.keyboard.fullID, from: package)
|
|
} catch {
|
|
let message = "Failed to install the default keyboard from the bundled KMP: \(error)"
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message)
|
|
SentryManager.capture(error, message:message)
|
|
}
|
|
}
|
|
|
|
func installDefaultLexicalModel(from bundle: Bundle) throws {
|
|
let defaultLexicalModelDir = self.resourceDir(for: Defaults.lexicalModel)!
|
|
|
|
try FileManager.default.createDirectory(at: defaultLexicalModelDir, withIntermediateDirectories: true)
|
|
|
|
// Since we only want to do this installation the first time (rather than constantly force-reinstalling
|
|
// the resource), we don't want this excluded from backup.
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "\(Defaults.lexicalModel.id).model.kmp",
|
|
dstDir: defaultLexicalModelDir,
|
|
excludeFromBackup: false)
|
|
|
|
do {
|
|
// Perform an auto-install of the lexical model's KMP if not already installed.
|
|
let defaultKMPFile = defaultLexicalModelDir.appendingPathComponent("\(Defaults.lexicalModel.id).model.kmp")
|
|
let package = try ResourceFileManager.shared.prepareKMPInstall(from: defaultKMPFile) as! LexicalModelKeymanPackage
|
|
|
|
// Install all languages for the model, not just the default-listed one.
|
|
try ResourceFileManager.shared.install(resourcesWithIDs: package.installables[0].map { $0.fullID }, from: package)
|
|
} catch {
|
|
let message = "Failed to install the default lexical model from the bundled KMP: \(error)"
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message)
|
|
SentryManager.capture(error, message:message)
|
|
}
|
|
}
|
|
|
|
func copyFiles(to dst: Storage) throws {
|
|
if FileManager.default.fileExists(atPath: dst.baseDir.path) {
|
|
let message = "Deleting \(dst.baseDir) for copy from \(baseDir) to \(dst.baseDir)"
|
|
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .info, message)
|
|
SentryManager.breadcrumb(message)
|
|
try FileManager.default.removeItem(at: dst.baseDir)
|
|
}
|
|
try FileManager.default.copyItem(at: baseDir, to: dst.baseDir)
|
|
}
|
|
|
|
func copyUserDefaults(to dst: Storage, withKeys keys: [String], shouldOverwrite: Bool) {
|
|
for key in keys {
|
|
if shouldOverwrite || dst.userDefaults.object(forKey: key) == nil {
|
|
dst.userDefaults.set(userDefaults.object(forKey: key), forKey: key)
|
|
}
|
|
}
|
|
dst.userDefaults.synchronize()
|
|
}
|
|
|
|
private static func copy(from bundle: Bundle, resourceName: String, dstDir: URL, excludeFromBackup: Bool = false) throws {
|
|
guard let srcURL = bundle.url(forResource: resourceName, withExtension: nil) else {
|
|
throw KeymanError.missingFile(resourceName, true)
|
|
}
|
|
let dstURL = dstDir.appendingPathComponent(srcURL.lastPathComponent)
|
|
|
|
return try Storage.copy(at: srcURL, to: dstURL, excludeFromBackup: true)
|
|
}
|
|
|
|
private static func copyDirectoryContents(at srcDir: URL, to dstDir: URL) throws {
|
|
let srcContents = try FileManager.default.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: [])
|
|
for srcFile in srcContents {
|
|
try Storage.copy(at: srcFile, to: dstDir.appendingPathComponent(srcFile.lastPathComponent), excludeFromBackup: true)
|
|
}
|
|
}
|
|
|
|
private static func addSkipBackupAttribute(to url: URL) throws {
|
|
var url = url
|
|
var resourceValues = URLResourceValues()
|
|
resourceValues.isExcludedFromBackup = true
|
|
|
|
// Writes values to the backing store. It is not only mutating the URL in memory.
|
|
try url.setResourceValues(resourceValues)
|
|
}
|
|
|
|
/// Copy and exclude from iCloud backup if `src` is newer than `dst`. Does nothing for a directory.
|
|
static func copy(at src: URL,
|
|
to dst: URL,
|
|
shouldOverwrite: Bool = true,
|
|
excludeFromBackup: Bool = false) throws {
|
|
let fm = FileManager.default
|
|
|
|
var isDirectory: ObjCBool = false
|
|
let fileExists = fm.fileExists(atPath: src.path, isDirectory: &isDirectory)
|
|
if isDirectory.boolValue {
|
|
return
|
|
}
|
|
|
|
if !fileExists {
|
|
throw KeymanError.missingFile(src.path, false)
|
|
}
|
|
|
|
if !fm.fileExists(atPath: dst.path) {
|
|
try fm.copyItem(at: src, to: dst)
|
|
} else if shouldOverwrite {
|
|
try fm.removeItem(at: dst)
|
|
try fm.copyItem(at: src, to: dst)
|
|
} else {
|
|
return
|
|
}
|
|
|
|
if excludeFromBackup {
|
|
try Storage.addSkipBackupAttribute(to: dst)
|
|
}
|
|
}
|
|
}
|