mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-08 09:55:32 +00:00
This is part of the fix chain for #5853. Split ios-host.js out from keyboard.html in order to make it easier to debug and mock. No changes to the content of the sscript, just moving it here.
369 lines
14 KiB
Swift
369 lines
14 KiB
Swift
//
|
|
// Storage.swift
|
|
// KeymanEngine
|
|
//
|
|
// Created by Gabriel Wong on 2017-11-21.
|
|
// Copyright © 2017 SIL International. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Sentry
|
|
|
|
// 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
|
|
}
|
|
SentryManager.breadcrumbAndLog("Storage.nonShared: using path \(paths[0])")
|
|
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 {
|
|
SentryManager.captureAndLog(error, message: "Failed to create subdirectory at \(newDir)")
|
|
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 {
|
|
SentryManager.captureAndLog(error, message: "Failed to list contents at \(keyboardDir) with error \(error)")
|
|
return nil
|
|
}
|
|
return contents.filter { url in
|
|
do {
|
|
let values = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
return values.isDirectory ?? false
|
|
} catch {
|
|
SentryManager.captureAndLog(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: "keymanios.js",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "keyman-sentry.js",
|
|
dstDir: baseDir,
|
|
excludeFromBackup: true)
|
|
// For debug compilations - IF we have a sourcemap file, copy that over too.
|
|
if bundle.url(forResource: "keyman.js.map", withExtension: nil) != nil {
|
|
try Storage.copy(from: bundle,
|
|
resourceName: "keyman.js.map",
|
|
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 {
|
|
SentryManager.captureAndLog(error, message: "Failed to install the default keyboard from the bundled KMP: \(error)")
|
|
}
|
|
}
|
|
|
|
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 {
|
|
SentryManager.captureAndLog(error, message: "Failed to install the default lexical model from the bundled KMP: \(error)")
|
|
}
|
|
}
|
|
|
|
func copyFiles(to dst: Storage) throws {
|
|
if FileManager.default.fileExists(atPath: dst.baseDir.path) {
|
|
SentryManager.breadcrumbAndLog("Deleting \(dst.baseDir) for copy from \(baseDir) to \(dst.baseDir)")
|
|
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)
|
|
}
|
|
}
|
|
}
|