// // ResourceFileManager.swift // KeymanEngine // // Created by Joshua Horton on December 18, 2019. // Copyright © 2019 SIL International. All rights reserved. // import Foundation import UIKit import os.log /** * This class stores common methods used for installing language resources, regardless of source. * * It also contains methods for general-purpose installation of language resources from .kmp files. */ public class ResourceFileManager { public static let shared = ResourceFileManager() private var haveRunMigrations: Bool = false fileprivate init() { } func runMigrationsIfNeeded() { if !haveRunMigrations { // Set here in order to prevent recursively calling itself in case // Migrations itself needs to use installation methods. haveRunMigrations = true // We must make sure that all resources are properly migrated before // allowing any new resources to be installed. Migrations.migrate(storage: Storage.active) Migrations.updateResources(storage: Storage.active) if Storage.active.userDefaults.userKeyboards?.isEmpty ?? true { Storage.active.userDefaults.userKeyboards = [Defaults.keyboard] // Ensure the default keyboard is installed in this case. do { try Storage.active.installDefaultKeyboard(from: Resources.bundle) } catch { let message = "Failed to copy default keyboard from bundle: \(error)" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message) SentryManager.capture(error, message:message) } } Migrations.engineVersion = Version.latestFeature } haveRunMigrations = true } public var installedPackages: [KeymanPackage] { let userResources = Storage.active.userDefaults.userResources ?? [] var backingPackages: [KeymanPackage] = [] for resource in userResources { if let package = try? KeymanPackage.parse(Storage.active.resourceDir(for: resource)!) { // On successful parse, just ensure that we haven't already listed the package. if !backingPackages.contains(where: { $0.id == package.id }) { backingPackages.append(package) } } } return backingPackages } public func installState(forPackage key: KeymanPackage.Key) -> KeymanPackage.InstallationState { return installState(forPackage: key, withManager: ResourceDownloadManager.shared) } // For mocked test use. internal func installState(forPackage key: KeymanPackage.Key, withManager downloadManager: ResourceDownloadManager) -> KeymanPackage.InstallationState { let localCachePath = self.cachedPackagePath(forKey: key) if let package = getInstalledPackage(withKey: key) { return package.installState } else if downloadManager.downloader.containsPackageKeyInQueue(matchingKey: key) { return .downloading } else if FileManager.default.fileExists(atPath: localCachePath.path) { return .pending } else { return .none } } public func getInstalledPackage(for resource: Resource) -> Resource.Package? { if let packageDir = Storage.active.resourceDir(for: resource) { return try? KeymanPackage.parse(packageDir) as? Resource.Package } else { return nil } } public func getInstalledPackage(withKey key: KeymanPackage.Key) -> KeymanPackage? { return try? KeymanPackage.parse(Storage.active.packageDir(forKey: key)) } internal func packageDownloadTempPath(forKey key: KeymanPackage.Key) -> URL { let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let tempFilename = KeymanPackage.baseFilename(for: key) let url = documentDir.appendingPathComponent("\(tempFilename).partial") // marks it as a download in progress return url } internal func cachedPackagePath(forKey key: KeymanPackage.Key) -> URL { let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let filename = KeymanPackage.baseFilename(for: key) let url = documentDir.appendingPathComponent(filename) return url } /** * Apple doesn't provide a method that performs copy-and-overwrite functionality. This function fills in that gap. */ internal func copyWithOverwrite(from source: URL, to destination: URL) throws { let fileManager = FileManager.default // If we've been provided a security-scoped resource URL, // it needs special handling. This function needs to accept // both scoped & non-scoped URLs. let isSecurityScoped = source.startAccessingSecurityScopedResource() defer { if isSecurityScoped { source.stopAccessingSecurityScopedResource() } } // Build a temporary copy of the file that will replace the destination file // so that we can do the overwrite atomically, ensuring that we can't end // in a state where the destination was erased but never got re-written due // to a copy error. let tempFile = fileManager.temporaryDirectory.appendingPathComponent("temp.file") try? fileManager.removeItem(at: tempFile) // Just in case the replace failed previously. try fileManager.copyItem(at: source, to: tempFile) // The "replace" operation essentially transfers ownership of the temp file's // contents to the destination filepath. The temp-file reference to those // contents is removed. _ = try fileManager.replaceItemAt(destination, withItemAt: tempFile) } /** * Use this function to "import" a file from outside the app's designated file system area to a new location within, * copying the original. It will be placed within the app's Documents folder. * * Returns the app-owned destination path, usable for subsequent file operations. */ public func importFile(_ url: URL) -> URL? { var destinationUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] destinationUrl.appendPathComponent(url.lastPathComponent) // Since it's possible to request an install from a KMP in our owned document space, // we need to check that it's not already in place where we want it. if url == destinationUrl { return url } do { try copyWithOverwrite(from: url, to: destinationUrl) return destinationUrl } catch { let message = "\(String(describing: error))" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message) SentryManager.capture(error, message:message) return nil } } /** * Use this function to "install" external KMP files to within the Keyman app's alloted iOS file management domain. * Note that we don't request permissions to support opening/modifying files "in place," so .kmps should already be * located in app-space (by use of `importFile`) before unzipping them. */ public func prepareKMPInstall(from url: URL) throws -> KeymanPackage { // Once selected, start the standard install process. let message = "Opening KMP from \(url)" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .info, message) SentryManager.breadcrumb(message) // Step 1: Copy it to a temporary location, making it a .zip in the process let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] var archiveUrl = cacheDirectory archiveUrl.appendPathComponent("\(url.lastPathComponent).zip") do { try copyWithOverwrite(from: url, to: archiveUrl) } catch { let errorMessage = "\(String(describing: error))" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, errorMessage) throw KMPError.copyFiles } var extractionFolder = cacheDirectory extractionFolder.appendPathComponent("temp/\(archiveUrl.lastPathComponent)") // first clear extraction folder to avoid creating duplicates try KeymanPackage.clearDirectory(destination: extractionFolder) do { if let package = try KeymanPackage.extract(fileUrl: archiveUrl, destination: extractionFolder) { return package } else { throw KMPError.doesNotExist } } catch { throw error } } /** * Use this function to "install" external KMP files to within the Keyman app's alloted iOS file management domain. * Note that we don't request permissions to support opening/modifying files "in place," so .kmps should already be * located in app-space (by use of `importFile`) before unzipping them. */ @available(*, deprecated) public func prepareKMPInstall(from url: URL, completionHandler: @escaping (KeymanPackage?, Error?) -> Void) { do { let kmp = try prepareKMPInstall(from: url) completionHandler(kmp, nil) } catch { let errorMessage = "\(String(describing: error))" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, errorMessage) completionHandler(nil, error) } } /** * A utility version of `prepareKMPInstall` that displays default UI alerts if errors occur when preparing a KMP for installation. * * The completion handler will only be called when a package is successfully "prepared". */ @available(*, deprecated) public func prepareKMPInstall(from url: URL, alertHost: UIViewController, completionHandler: @escaping (KeymanPackage) -> Void) { if let package = prepareKMPInstall(from: url, alertHost: alertHost) { completionHandler(package) } } /** * A utility version of `prepareKMPInstall` that displays default UI alerts if errors occur when preparing a KMP for installation. */ public func prepareKMPInstall(from url: URL, alertHost: UIViewController) -> KeymanPackage? { do { return try self.prepareKMPInstall(from: url) } catch { let alert: UIAlertController if let kmpError = error as? KMPError { alert = self.buildKMPError(kmpError) } else { alert = self.buildKMPError(KMPError.copyFiles) } alertHost.present(alert, animated: true, completion: nil) return nil } } internal func doInstallPrompt>( for package: Package, defaultLanguageCode: String? = nil, in rootVC: UIViewController, withAssociators associators: [AssociatingPackageInstaller.Associator] = [], successHandler: ((KeymanPackage) -> Void)? = nil) where Resource.Package == Package { let activitySpinner = Alerts.constructActivitySpinner() activitySpinner.center = rootVC.view.center let packageInstaller = AssociatingPackageInstaller(for: package, defaultLanguageCode: defaultLanguageCode, withAssociators: associators) { status in if status == .starting { // Start a spinner! activitySpinner.startAnimating() rootVC.view.addSubview(activitySpinner) activitySpinner.centerXAnchor.constraint(equalTo: rootVC.view.centerXAnchor).isActive = true activitySpinner.centerYAnchor.constraint(equalTo: rootVC.view.centerYAnchor).isActive = true rootVC.view.isUserInteractionEnabled = false } else if status == .complete || status == .cancelled { // Report completion! activitySpinner.stopAnimating() activitySpinner.removeFromSuperview() rootVC.view.isUserInteractionEnabled = true rootVC.dismiss(animated: true) { Manager.shared.showKeyboard() } successHandler?(package) } } if let navVC = rootVC as? UINavigationController { packageInstaller.promptForLanguages(inNavigationVC: navVC) } else { let nvc = UINavigationController.init() packageInstaller.promptForLanguages(inNavigationVC: nvc) rootVC.present(nvc, animated: true, completion: nil) } } public func promptPackageInstall(of package: KeymanPackage, in rootVC: UIViewController, isCustom: Bool, successHandler: ((KeymanPackage) -> Void)? = nil) { if let kbdPackage = package as? KeyboardKeymanPackage { doInstallPrompt(for: kbdPackage, in: rootVC, withAssociators: [.lexicalModels], successHandler: successHandler) } else if let lmPackage = package as? LexicalModelKeymanPackage { doInstallPrompt(for: lmPackage, in: rootVC, withAssociators: [], successHandler: successHandler) } } public func buildKMPError(_ error: KMPError) -> UIAlertController { return buildSimpleAlert(title: NSLocalizedString("alert-error-title", bundle: engineBundle, comment: ""), message: error.localizedDescription) } public func buildSimpleAlert(title: String, message: String, completionHandler: (() -> Void)? = nil ) -> UIAlertController { let alertController = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) alertController.addAction(UIAlertAction(title: NSLocalizedString("command-ok", bundle: engineBundle, comment: ""), style: UIAlertAction.Style.default, handler: { _ in completionHandler?() })) //UIApplication.shared.keyWindow?.rootViewController?.present(alertController, animated: true, completion: nil) return alertController } /** * Performs the actual installation of a package's resources once confirmation has been received from the user. */ public func finalizePackageInstall(_ package: KeymanPackage, isCustom: Bool) throws { // Time to pass the package off to the final installers - the parse__KMP methods. // TODO: (14.0+) These functions should probably be refactored to within this class eventually. if package.isKeyboard() { try Manager.shared.parseKbdKMP(package.sourceFolder, isCustom: isCustom) } else { try Manager.parseLMKMP(package.sourceFolder, isCustom: isCustom) } } @available(*, deprecated) public func finalizePackageInstall(_ package: KeymanPackage, isCustom: Bool, completionHandler: (Error?) -> Void) { do { try finalizePackageInstall(package, isCustom: isCustom) completionHandler(nil) } catch { let errorMessage = "\(String(describing: error))" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, errorMessage) completionHandler(error) } } /** * Searches the specified package for a language resource with the indicated resource-language-code "full ID" key, * importing the package's files and installing the indicated resource-language pairing upon success. * * The`resourcesWithIDs:` variant is better optimized for installing multiple resources from the same package. */ public func install ( resourceWithID fullID: FullID, from package: FullID.Resource.Package) throws where FullID.Resource.Package: TypedKeymanPackage { try install(resourcesWithIDs: [fullID], from: package) } /** * Searches the specified package for language resources with the indicated resource-language-code "full ID" keys * importing the package's files and installing the indicated resource-language pairings upon success. */ public func install ( resourcesWithIDs fullIDs: [FullID], from package: FullID.Resource.Package) throws where FullID.Resource.Package: TypedKeymanPackage { if fullIDs.contains(where: { package.findResource(withID: $0) == nil }) { let missingResource = fullIDs.first(where: { package.findResource(withID: $0) == nil })! let errorMessage = "Resource with full ID \(missingResource.description) not in package" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, errorMessage) throw KMPError.resourceNotInPackage } // Resources should only be installed after Migrations have been run. // Otherwise, the Migrations engine may misinterpret the installed format // on an app's first install. // // It is possible for a KeymanEngine framework consumer to reach this point // without `Manager.init` having been run. runMigrationsIfNeeded() do { try copyWithOverwrite(from: package.sourceFolder, to: Storage.active.packageDir(for: package)!) } catch { let errorMessage = "Could not create installation directory and/or copy resources: \(error)" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, errorMessage) throw KMPError.fileSystem } let updatables: [FullID] = findPotentialUpdates(in: package, ignoring: [] as [FullID.Resource]).map { return $0.typedFullID } let fullList = fullIDs + updatables fullList.forEach { addResource(package.findResource(withID: $0)!) } } internal func findPotentialUpdates> ( in package: Package, ignoring resourcesToIgnore: [Resource] = []) -> [Resource] { let installedResources = Storage.active.userDefaults.userResources(ofType: Resource.self) ?? [] var updatableResources: [Resource] = [] installedResources.forEach { resource in // If there's no package ID, default to the resource's ID. // If the package ID matches the resource's package ID and we're not ignoring the resource, // check to ensure that the package does contain the resource. if (resource.packageID ?? resource.id) == package.id, !resourcesToIgnore.contains(where: { $0.typedFullID == resource.typedFullID }) { if let updatable = package.findResource(withID: resource.typedFullID) { updatableResources.append(updatable) } } } return updatableResources } internal func addResource(_ resource: Resource) { let path = Storage.active.resourceURL(for: resource)!.path if !FileManager.default.fileExists(atPath: path) { // Is 'internal' and only called after packages have been installed, // thus when the files should already be in-place. let message = "Could not add resource of type: \(resource.fullID.type) with ID: \(resource.id) because the resource file does not exist" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .error, message) SentryManager.capture(message) return } // Get keyboards list if it exists in user defaults, otherwise create a new one let userDefaults = Storage.active.userDefaults // Local, inline func used by the block following it. func addOrAppend(_ resource: Resource, to resourceList: [Resource]) -> [Resource] { var list = resourceList // Update resource if it exists if let index = resourceList.firstIndex(where: { $0.typedFullID == resource.typedFullID }) { list[index] = resource } else { list.append(resource) } return list } // Use the func we just declared while performing proper Swift type coersion. if resource is InstallableKeyboard { let resourceList = addOrAppend(resource, to: userDefaults.userKeyboards as? [Resource] ?? []) userDefaults.userKeyboards = (resourceList as! [InstallableKeyboard]) } else if resource is InstallableLexicalModel { let resourceList = addOrAppend(resource, to: userDefaults.userLexicalModels as? [Resource] ?? []) userDefaults.userLexicalModels = (resourceList as! [InstallableLexicalModel]) } else { fatalError("Cannot install instance of unexpected LanguageResource subclass") } userDefaults.set([Date()], forKey: Key.synchronizeSWKeyboard) userDefaults.synchronize() let message = "Added \(resource.fullID.type) with ID: \(resource.id) and language code: \(resource.languageID)" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .info, message) SentryManager.breadcrumb(message) } }