spiegel-keyman/ios/engine/KMEI/KeymanEngine/Classes/Storage.swift

307 lines
12 KiB
Swift

//
// Storage.swift
// KeymanEngine
//
// Created by Gabriel Wong on 2017-11-21.
// Copyright © 2017 SIL International. All rights reserved.
//
import Foundation
// 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
}
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 {
log.error("Failed to create subdirectory at \(newDir)")
return nil
}
}
func keyboardDir(forID keyboardID: String) -> URL {
return keyboardDir.appendingPathComponent(keyboardID)
}
func lexicalModelDir(forID lexicalModelID: String) -> URL {
return lexicalModelDir.appendingPathComponent(lexicalModelID)
}
func keyboardURL(for keyboard: InstallableKeyboard) -> URL {
return keyboardURL(forID: keyboard.id, version: keyboard.version)
}
func lexicalModelURL(for lexicalModel: InstallableLexicalModel) -> URL {
return lexicalModelURL(forID: lexicalModel.id, version: lexicalModel.version)
}
func lexicalModelPackageURL(for lexicalModel: InstallableLexicalModel) -> URL {
return lexicalModelPackageURL(forID: lexicalModel.id, version: lexicalModel.version)
}
func keyboardURL(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 = keyboardDir(forID: keyboardID).appendingPathComponent("\(keyboardID).js")
if FileManager.default.fileExists(atPath: filename.path) {
return filename
}
return keyboardDir(forID: keyboardID).appendingPathComponent("\(keyboardID)-\(version).js")
}
func lexicalModelURL(forID lexicalModelID: String, version: String) -> URL {
return lexicalModelDir(forID: lexicalModelID).appendingPathComponent("\(lexicalModelID)-\(version).model.js")
}
func lexicalModelPackageURL(forID lexicalModelID: String, version: String, asZip: Bool = false) -> URL {
// Our unzipping dependency requires a .zip extension to function, so we append that here.
return lexicalModelDir(forID: lexicalModelID).appendingPathComponent("\(lexicalModelID)-\(version).model.kmp\(asZip ? ".zip" : "")")
}
func fontURL(forKeyboardID keyboardID: String, filename: String) -> URL {
return keyboardDir(forID: keyboardID).appendingPathComponent(filename)
}
var keyboardDirs: [URL]? {
let contents: [URL]
do {
contents = try FileManager.default.contentsOfDirectory(at: keyboardDir,
includingPropertiesForKeys: [.isDirectoryKey])
} catch {
log.error("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 {
log.error(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: "keymanios.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.keyboardDir(forID: Defaults.keyboard.id)
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)-\(Defaults.keyboard.version).js",
dstDir: defaultKeyboardDir,
excludeFromBackup: false)
try Storage.copy(from: bundle,
resourceName: "DejaVuSans.ttf",
dstDir: defaultKeyboardDir,
excludeFromBackup: false)
}
func installDefaultLexicalModel(from bundle: Bundle) throws {
let defaultLexicalModelDir = self.lexicalModelDir(forID: Defaults.lexicalModel.id)
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)-\(Defaults.lexicalModel.version).model.kmp",
dstDir: defaultLexicalModelDir,
excludeFromBackup: false)
// Perform an auto-install of the lexical model's KMP if not already installed.
let lexicalModelURLasZIP = Storage.active.lexicalModelPackageURL(forID: Defaults.lexicalModel.id,
version: Defaults.lexicalModel.version,
asZip: true)
let lexicalModelURL = Storage.active.lexicalModelPackageURL(forID: Defaults.lexicalModel.id,
version: Defaults.lexicalModel.version,
asZip: false)
// Because of how our .zip dependency works, we need to make the .kmp look like a .zip. A simple rename will do.
do {
try Storage.copy(at: lexicalModelURL, to: lexicalModelURLasZIP, excludeFromBackup: true)
let downloader = ResourceDownloadQueue()
// Hijacking the download queue's KMP installer.
// Issue - requests DL confirmation
// Issue - in a roundabout way, results in attempted access to Manager.shared during Manager.init, causing crash.
_ = downloader.installLexicalModelPackage(downloadedPackageFile: lexicalModelURLasZIP)
} catch {
log.error("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) {
log.info("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 {
let message = "Could not locate \(resourceName) in the Keyman bundle"
throw NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
}
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 {
let message = "Nothing to copy at \(src)"
throw NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
}
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)
}
}
}