mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-06 08:55:34 +00:00
555 lines
21 KiB
Swift
555 lines
21 KiB
Swift
//
|
|
// ResourceDownloadQueue.swift
|
|
// KeymanEngine
|
|
//
|
|
// Created by Joshua Horton on 8/20/19.
|
|
// Copyright © 2019 SIL International. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Reachability
|
|
|
|
protocol DownloadNode {
|
|
var resources: [LanguageResource]? { get }
|
|
}
|
|
|
|
class DownloadTask: DownloadNode {
|
|
public enum Resource {
|
|
case keyboard, lexicalModel, other
|
|
}
|
|
|
|
public final var type: Resource
|
|
public final var resources: [LanguageResource]?
|
|
public final var request: HTTPDownloadRequest
|
|
|
|
public init(do request: HTTPDownloadRequest, for resources: [LanguageResource]?, type: DownloadTask.Resource) {
|
|
self.request = request
|
|
self.type = type
|
|
self.resources = resources
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents one overall resource-related command for requests against the Keyman Cloud API.
|
|
*/
|
|
class DownloadBatch: DownloadNode {
|
|
public enum Activity {
|
|
case download, update, composite
|
|
}
|
|
|
|
/*
|
|
* Three main cases so far:
|
|
* - [.download, .keyboard]: download new Keyboard (possibly with an associated lexical model)
|
|
* - [.download, .lexicalModel]: download new LexicalModel.
|
|
* - [.composite, .other]: batch update of all resources, containing the following within its compositeQueue
|
|
* - [.update, .keyboard]: updates one Keyboard
|
|
* - [.update, .lexicalModel]: updates one LexicalModel
|
|
* - Depending on needs, this may be extended to allow 'nested' .composite instances.
|
|
*/
|
|
public final var activity: Activity
|
|
public final var type: DownloadTask.Resource
|
|
|
|
public final var tasks: [DownloadNode]
|
|
var errors: [Error?] // Only used by the ResourceDownloadQueue.
|
|
//public final var promise: Int
|
|
|
|
public init?(do tasks: [DownloadTask], as activity: Activity, ofType type: DownloadTask.Resource) {
|
|
self.activity = activity
|
|
self.type = type
|
|
self.tasks = tasks
|
|
|
|
self.errors = Array(repeating: nil, count: tasks.count)
|
|
|
|
// Indicates 'composite' mode, which should use the other initializer.
|
|
if activity == .composite {
|
|
return nil
|
|
}
|
|
|
|
// A batch containing tasks should always be targeting a 'primary' language resource.
|
|
// .other exists to handle fonts packaged with keyboards, not our primary resources.
|
|
if type == .other {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public init(queue: [DownloadBatch]) {
|
|
self.activity = .composite
|
|
self.type = .other
|
|
self.tasks = queue
|
|
|
|
self.errors = Array(repeating: nil, count: tasks.count)
|
|
}
|
|
|
|
public var resources: [LanguageResource]? {
|
|
get {
|
|
var resArray: [LanguageResource] = []
|
|
|
|
tasks.forEach{ task in
|
|
resArray.append(contentsOf: task.resources ?? [])
|
|
}
|
|
|
|
return resArray
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is a private class used internally by ResourceDownloadQueue to track progress through
|
|
// the queue and its DownloadBatches. We need a stack scheme - each instance of this corresponds
|
|
// to one frame of the stack.
|
|
private class DownloadQueueFrame {
|
|
public var nodes: [DownloadNode] = []
|
|
public var index: Int = 0
|
|
public final var batch: DownloadBatch?
|
|
private(set) var isComposite = false
|
|
|
|
public init() {
|
|
batch = nil
|
|
}
|
|
|
|
public init? (from batch: DownloadBatch) {
|
|
if batch.activity != .composite {
|
|
return nil
|
|
}
|
|
|
|
self.isComposite = true
|
|
self.batch = batch
|
|
nodes.append(contentsOf: batch.tasks)
|
|
}
|
|
}
|
|
|
|
// The other half of ResourceDownloadManager, this class is responsible for executing the downloads
|
|
// and handling the results.
|
|
class ResourceDownloadQueue: HTTPDownloadDelegate {
|
|
private var queueRoot: DownloadQueueFrame
|
|
private var queueStack: [DownloadQueueFrame]
|
|
|
|
private var downloader: HTTPDownloader?
|
|
private var reachability: Reachability!
|
|
private let keymanHostName = "api.keyman.com"
|
|
|
|
public init() {
|
|
reachability = Reachability(hostname: keymanHostName)
|
|
|
|
queueRoot = DownloadQueueFrame()
|
|
queueStack = [queueRoot] // queueRoot will always be the bottom frame of the stack.
|
|
}
|
|
|
|
public func hasConnection() -> Bool {
|
|
return reachability.connection != Reachability.Connection.none
|
|
}
|
|
|
|
// Might should add a "withNotification: Bool" option for clarity.
|
|
public func canExecute(_ batch: DownloadBatch) -> Bool {
|
|
guard reachability.connection != Reachability.Connection.none else {
|
|
let error = NSError(domain: "Keyman", code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "No internet connection"])
|
|
downloadFailed(forBatch: batch, error: error)
|
|
return false
|
|
}
|
|
|
|
// At this stage, we now have everything needed to generate download requests.
|
|
guard currentBatch == nil else { // Original behavior - only one download operation is permitted at a time.
|
|
let error = NSError(domain: "Keyman", code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "Download queue is busy"])
|
|
downloadFailed(forBatch: batch, error: error)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
public var currentBatch: DownloadBatch? {
|
|
get {
|
|
let frame = currentFrame
|
|
|
|
if(frame.nodes.count == 0) {
|
|
return nil // Nothing's downloading.
|
|
} else {
|
|
return (frame.nodes[frame.index] as! DownloadBatch) // Return the currently processing DownloadBatch.
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentFrame: DownloadQueueFrame {
|
|
get {
|
|
return queueStack[queueStack.count - 1]
|
|
}
|
|
}
|
|
|
|
private func finalizeCurrentBatch() {
|
|
let frame = queueStack[queueStack.count - 1]
|
|
|
|
if queueStack.count == 1 {
|
|
// Batches in the root may be safely dumped from the queue.
|
|
// This automatically cleans out old top-level commands once done, instead of maintaining a persistent history.
|
|
//
|
|
// We _could_ keep the full history until the last command in the queue is done and empty the root's node list
|
|
// when everything's done instead. Might be worth a thought.
|
|
frame.nodes.remove(at: 0)
|
|
} else {
|
|
// Batches in subframes should be kept so that we can report progress; increment the index instead.
|
|
frame.index += 1
|
|
}
|
|
}
|
|
|
|
private func executeNext() {
|
|
let frame = queueStack[queueStack.count - 1]
|
|
|
|
// If nothing's in the queue, this will be straight-forward.
|
|
if queueStack.count == 1 { // Are we on the root frame?
|
|
// Is the root frame out of commands?
|
|
if frame.nodes.count == 0 {
|
|
return
|
|
} // else
|
|
|
|
let batch = frame.nodes[0] as! DownloadBatch
|
|
innerExecute(batch)
|
|
} else { // We're in a queue stack frame; check our state!
|
|
if frame.index == frame.nodes.count {
|
|
// We've hit the end of this stack frame's commands; time to pop and continue from the previous frame's perspective.
|
|
_ = queueStack.popLast()
|
|
|
|
// In the current state of the app, this is only the case for batch updates.
|
|
if frame.isComposite {
|
|
let parentFrame = queueStack[queueStack.count - 1]
|
|
updateBatchFinished(parentFrame.nodes[parentFrame.index] as! DownloadBatch)
|
|
}
|
|
|
|
// Of course, this means we've "finished" a batch download. We can use the same handlers as before.
|
|
finalizeCurrentBatch()
|
|
executeNext()
|
|
return
|
|
} else {
|
|
// We've got more batches left within this stack frame. Continue.
|
|
let batch = frame.nodes[frame.index] as! DownloadBatch
|
|
innerExecute(batch)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func innerExecute(_ batch: DownloadBatch) {
|
|
if batch.activity == .composite {
|
|
if batch.type == .other {
|
|
// It's the top level of an update operation. Time to make a correpsonding notification.
|
|
updateBatchStarted(batch)
|
|
}
|
|
// We're a batch composed of multiple sub-batches. Time to set that up on the queue!
|
|
let frame = DownloadQueueFrame(from: batch)!
|
|
queueStack.append(frame)
|
|
// TODO: Notify about the new batch being started!
|
|
|
|
innerExecute(batch.tasks[0] as! DownloadBatch)
|
|
return
|
|
} else {
|
|
// Make a separate one for each batch; this simplifies event handling when multiple batches are in queue,
|
|
// as the old downloader will have a final event left to trigger at this point. (downloadQueueFinished)
|
|
downloader = HTTPDownloader(self)
|
|
let tasks = batch.tasks as! [DownloadTask]
|
|
downloader!.userInfo = [Key.downloadBatch: batch]
|
|
|
|
tasks.forEach { task in
|
|
downloader!.addRequest(task.request)
|
|
}
|
|
|
|
downloader!.run()
|
|
// TODO: Notify about the new batch being started!
|
|
}
|
|
}
|
|
|
|
public func queue(_ batch: DownloadBatch) {
|
|
queueRoot.nodes.append(batch)
|
|
|
|
// If the queue was empty before this...
|
|
if queueRoot.nodes.count == 1 {
|
|
executeNext()
|
|
}
|
|
}
|
|
|
|
// MARK - helper methods for ResourceDownloadManager
|
|
|
|
// TODO: Eliminate this property coompletely.
|
|
var currentRequest: HTTPDownloadRequest? {
|
|
get {
|
|
// This was literally the state of this property when the refactor producing this class was performed.
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
// TODO: Needs validation.
|
|
func keyboardIdForCurrentRequest() -> String? {
|
|
if let currentRequest = currentRequest {
|
|
let tmpStr = currentRequest.url.lastPathComponent
|
|
if tmpStr.hasJavaScriptExtension {
|
|
return String(tmpStr.dropLast(3))
|
|
}
|
|
} else {
|
|
// TODO: search the queue instead.
|
|
// let kbInfo = downloader!.userInfo[Key.keyboardInfo]
|
|
// if let keyboards = kbInfo as? [InstallableKeyboard], let keyboard = keyboards.first {
|
|
// return keyboard.id
|
|
// }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lexicalModelIdForCurrentRequest() -> String? {
|
|
if let currentRequest = currentRequest {
|
|
let tmpStr = currentRequest.url.lastPathComponent
|
|
if tmpStr.hasJavaScriptExtension {
|
|
return String(tmpStr.dropLast(3))
|
|
}
|
|
} else {
|
|
// TODO: search the queue instead.
|
|
// let kbInfo = downloader!.userInfo[Key.lexicalModelInfo]
|
|
// if let lexicalModels = kbInfo as? [InstallableLexicalModel], let lexicalModel = lexicalModels.first {
|
|
// return lexicalModel.id
|
|
// }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK - notification methods
|
|
|
|
private func downloadFailed(forBatch batch: DownloadBatch, error: Error) {
|
|
let tasks = batch.tasks as! [DownloadTask]
|
|
if batch.activity == .composite {
|
|
// It's an update operation.
|
|
// Not yet called in this case.
|
|
} else if batch.type == .keyboard {
|
|
let keyboards = tasks.compactMap { task in
|
|
return task.resources as? [InstallableKeyboard]
|
|
}.flatMap {$0}
|
|
downloadFailed(forKeyboards: keyboards, error: error)
|
|
} else if batch.type == .lexicalModel {
|
|
let lexModels = tasks.compactMap { task in
|
|
return task.resources as? [InstallableLexicalModel]
|
|
}.flatMap {$0}
|
|
downloadFailed(forLanguageID: lexModels[0].languageID, error: error)
|
|
}
|
|
}
|
|
|
|
public func downloadFailed(forKeyboards keyboards: [InstallableKeyboard], error: Error) {
|
|
let notification = KeyboardDownloadFailedNotification(keyboards: keyboards, error: error)
|
|
NotificationCenter.default.post(name: Notifications.keyboardDownloadFailed,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
public func downloadFailed(forLanguageID languageID: String, error: Error) {
|
|
let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: languageID, error: error)
|
|
NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
public func downloadFailed(forLexicalModelPackage packageURL: String, error: Error) {
|
|
let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: packageURL, error: error)
|
|
NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
public func downloadSucceeded(forKeyboards keyboards: [InstallableKeyboard]) {
|
|
let notification = KeyboardDownloadCompletedNotification(keyboards)
|
|
NotificationCenter.default.post(name: Notifications.keyboardDownloadCompleted,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
public func downloadSucceeded(forLexicalModel lm: InstallableLexicalModel) {
|
|
let notification = LexicalModelDownloadCompletedNotification([lm])
|
|
NotificationCenter.default.post(name: Notifications.lexicalModelDownloadCompleted,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
|
|
public func updateBatchStarted(_ batch: DownloadBatch) {
|
|
let notification = BatchUpdateStartedNotification(batch.resources!)
|
|
NotificationCenter.default.post(name: Notifications.batchUpdateStarted,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
public func updateBatchFinished(_ batch: DownloadBatch) {
|
|
var successes: [[LanguageResource]] = []
|
|
var failures: [[LanguageResource]] = []
|
|
var errors: [Error] = []
|
|
|
|
// Remember, since this is for .composite batches, batch.tasks is of type [DownloadBatch].
|
|
for (index, res) in batch.tasks.enumerated() {
|
|
if batch.errors[index] == nil {
|
|
successes.append(res.resources!)
|
|
} else {
|
|
failures.append(res.resources!)
|
|
errors.append(batch.errors[index]!)
|
|
}
|
|
}
|
|
|
|
let notification = BatchUpdateCompletedNotification(successes: successes, failures: failures, errors: errors)
|
|
NotificationCenter.default.post(name: Notifications.batchUpdateCompleted,
|
|
object: self,
|
|
value: notification)
|
|
}
|
|
|
|
//MARK: - HTTPDownloadDelegate methods
|
|
|
|
func downloadQueueFinished(_ queue: HTTPDownloader) {
|
|
// We can use the properties of the current "batch" to generate specialized notifications.
|
|
let batch = queue.userInfo[Key.downloadBatch] as! DownloadBatch
|
|
|
|
// Due to how the queue operates, batch.activity != .composite. That case is handled elsewhere.
|
|
if(batch.activity == .composite) {
|
|
// Composite indicates that a batch is made of multiple sub-batches, not individual
|
|
// DownloadTasks that the HTTPDownloader can handle, which are 1-to-1 with its requests.
|
|
fatalError("HTTPDownloader should never be assigned a .composite-type DownloadBatch.")
|
|
} else {
|
|
let isUpdate = batch.activity == .update
|
|
|
|
if batch.type == .keyboard {
|
|
// The request has succeeded.
|
|
if downloader!.requestsCount == 0 { // Download queue finished.
|
|
let keyboards = batch.resources as! [InstallableKeyboard]
|
|
|
|
FontManager.shared.registerCustomFonts()
|
|
log.info("Downloaded keyboard: \(keyboards[0].id).")
|
|
|
|
if(!isUpdate) {
|
|
downloadSucceeded(forKeyboards: keyboards)
|
|
}
|
|
|
|
let userDefaults = Storage.active.userDefaults
|
|
userDefaults.set([Date()], forKey: Key.synchronizeSWKeyboard)
|
|
userDefaults.synchronize()
|
|
}
|
|
} else if batch.type == .lexicalModel {
|
|
let task = batch.tasks[0] as! DownloadTask // It's always at this index.
|
|
if let lm = installLexicalModelPackage(downloadedPackageFile: URL.init(string: task.request.destinationFile!)!) {
|
|
if !isUpdate {
|
|
downloadSucceeded(forLexicalModel: lm)
|
|
}
|
|
} else if !isUpdate {
|
|
let installError = NSError(domain: "Keyman", code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "installError"])
|
|
downloadFailed(forLexicalModelPackage: "\(task.request.url)", error: installError)
|
|
currentFrame.batch?.errors[currentFrame.index] = installError
|
|
}
|
|
}
|
|
}
|
|
|
|
// Completing the queue means having completed a batch. We should only move forward in this class's
|
|
// queue at this time, once a batch's task queue is complete.
|
|
finalizeCurrentBatch()
|
|
executeNext()
|
|
}
|
|
|
|
func downloadQueueCancelled(_ queue: HTTPDownloader) {
|
|
//
|
|
|
|
// In case we're part of a 'composite' operation, we should still keep the queue moving.
|
|
finalizeCurrentBatch()
|
|
executeNext()
|
|
}
|
|
|
|
func downloadRequestStarted(_ request: HTTPDownloadRequest) {
|
|
// If we're downloading a new keyboard.
|
|
// The extra check is there to filter out other potential request types in the future.
|
|
let batch = request.userInfo[Key.downloadBatch] as! DownloadBatch
|
|
|
|
if request.tag == 0 && batch.activity != .update {
|
|
let task = request.userInfo[Key.downloadTask] as! DownloadTask
|
|
if task.type == .keyboard {
|
|
NotificationCenter.default.post(name: Notifications.keyboardDownloadStarted,
|
|
object: self,
|
|
value: task.resources as! [InstallableKeyboard])
|
|
} else if task.type == .lexicalModel {
|
|
NotificationCenter.default.post(name: Notifications.lexicalModelDownloadStarted,
|
|
object: self,
|
|
value: task.resources as! [InstallableLexicalModel])
|
|
}
|
|
}
|
|
}
|
|
|
|
func downloadRequestFinished(_ request: HTTPDownloadRequest) {
|
|
// Did we finish, but with an request error code?
|
|
if request.responseStatusCode != 200 {
|
|
// Possible request error (400 Bad Request, 404 Not Found, etc.)
|
|
|
|
let errorMessage = "\(request.responseStatusMessage ?? ""): \(request.url)"
|
|
let error = NSError(domain: "Keyman", code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: errorMessage])
|
|
currentFrame.batch?.errors[currentFrame.index] = error
|
|
|
|
// Now that we've synthesized an appropriate error instance, use the same handler
|
|
// as for HTTPDownloader's 'failed' condition.
|
|
downloadRequestFailed(request, with: error)
|
|
} // else - handled once the entire queue is completed without errors.
|
|
// This particularly matters for keyboards.
|
|
}
|
|
|
|
func downloadRequestFailed(_ request: HTTPDownloadRequest) {
|
|
// We should never be in a state to return 'Unknown error", but better safe than sorry.
|
|
downloadRequestFailed(request, with: request.error ?? NSError(domain: "Keyman", code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unknown error"]))
|
|
}
|
|
|
|
func downloadRequestFailed(_ request: HTTPDownloadRequest, with error: Error) {
|
|
currentFrame.batch?.errors[currentFrame.index] = error
|
|
|
|
let task = request.userInfo[Key.downloadTask] as! DownloadTask
|
|
let batch = request.userInfo[Key.downloadBatch] as! DownloadBatch
|
|
let isUpdate = batch.activity == .update
|
|
|
|
if task.type == .keyboard {
|
|
log.error("Keyboard download failed: \(error).")
|
|
let keyboards = task.resources as? [InstallableKeyboard]
|
|
|
|
if !isUpdate {
|
|
// Clean up keyboard file if anything fails
|
|
// TODO: Also clean up remaining fonts
|
|
try? FileManager.default.removeItem(at: Storage.active.keyboardURL(for: keyboards![0]))
|
|
downloadFailed(forKeyboards: keyboards ?? [], error: error as NSError)
|
|
}
|
|
} else if task.type == .lexicalModel {
|
|
log.error("Lexical model download failed: \(error).")
|
|
let lexicalModels = task.resources as? [InstallableLexicalModel]
|
|
|
|
if !isUpdate {
|
|
// Clean up model file if anything fails
|
|
try? FileManager.default.removeItem(at: Storage.active.lexicalModelURL(for: lexicalModels![0]))
|
|
downloadFailed(forLanguageID: lexicalModels?[0].languageID ?? "", error: error as NSError)
|
|
}
|
|
}
|
|
|
|
downloader!.cancelAllOperations()
|
|
}
|
|
|
|
// MARK - Language resource installation methods
|
|
|
|
// Processes fetched lexical models.
|
|
// return a lexical model so caller can use it in a downloadSucceeded call
|
|
// is called by other class funcs
|
|
public func installLexicalModelPackage(downloadedPackageFile: URL) -> InstallableLexicalModel? {
|
|
var installedLexicalModel: InstallableLexicalModel? = nil
|
|
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
var destination = documentsDirectory
|
|
destination.appendPathComponent("temp/\(downloadedPackageFile.lastPathComponent)")
|
|
|
|
KeymanPackage.extract(fileUrl: downloadedPackageFile, destination: destination, complete: { kmp in
|
|
if let kmp = kmp as! LexicalModelKeymanPackage? {
|
|
do {
|
|
try Manager.parseLMKMP(kmp.sourceFolder)
|
|
log.info("successfully parsed the lexical model in: \(kmp.sourceFolder)")
|
|
installedLexicalModel = kmp.models[0].installableLexicalModels[0]
|
|
//this can fail gracefully and not show errors to users
|
|
try FileManager.default.removeItem(at: downloadedPackageFile)
|
|
} catch {
|
|
log.error("Error installing the lexical model: \(error)")
|
|
}
|
|
} else {
|
|
log.error("Error extracting the lexical model from the package: \(KMPError.invalidPackage)")
|
|
}
|
|
})
|
|
return installedLexicalModel
|
|
}
|
|
}
|