spiegel-keyman/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift
Joshua Horton bfb97ca0c6 maint(web): upgrade Sentry to 8.55.0
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
2025-05-22 14:02:16 +07:00

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)
}
}
}