spiegel-keyman/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift
Marc Durdin bbeb799ac2 feat(ios): add keyman-version to package-version check
For Keyman for iOS, add the new `keyman-version` parameter to the
api.keyman.com/package-version call so that updates to packages that are
not supported on the current version of Keyman will not be offered. This
supports the scenario where an updated keyboard or lexical model depends
on a newer version of Keyman. Note that an older version of the keyboard
or lexical model package will not be offered (the user can still
download and install an older version manually, but generally, the
recommended solution is to upgrade Keyman; there would be significant
cost to add support for querying and installation of older version
keyboards on the server side, for limited benefit).

Also change order of handling for response so that error responses are
recognized even if non-error fields are present. (The current api
endpoint, before keymanapp/api.keyman.com#325 lands, can return `kmp`
and `version` fields even when an `error` field was present, and the iOS
code would see that as a valid response for upgrade, when it shouldn't.
The keymanapp/api.keyman.com#325 change ensures that the additional
fields are not set if there is an `error` field.)

Relates-to: keymanapp/api.keyman.com#325
2026-06-25 11:27:00 +02:00

135 lines
4.5 KiB
Swift

//
// Queries+PackageVersion.swift
// KeymanEngine
//
// Created by Joshua Horton on 6/25/20.
// Copyright © 2020 SIL International. All rights reserved.
//
import Foundation
import os.log
extension Queries {
// Kept internal; the query is 'publicly exposed' through KeymanPackage and ResourceFileManager.
class PackageVersion {
class ResultComponent {}
class ResultEntry: ResultComponent, Decodable {
let version: String
let packageURL: String
enum CodingKeys: String, CodingKey {
case version
case packageURL = "kmp"
}
}
class ResultError: ResultComponent, Decodable {
let error: String
enum CodingKeys: String, CodingKey {
case error
}
}
enum Entry {
case success(ResultEntry)
case failure(ResultError?)
}
struct Result: Decodable {
let keyboards: [String : ResultComponent]?
let models: [String : ResultComponent]?
enum CodingKeys: String, CodingKey {
case keyboards
case models
}
init(from decoder: Decoder) throws {
let rootValues = try decoder.container(keyedBy: CodingKeys.self)
func dictionaryReducer(container: KeyedDecodingContainer<Queries.IDCodingKey>, category: String) -> ([String: ResultComponent], Queries.IDCodingKey) throws -> [String: ResultComponent] {
return { (dict, id) -> [String: ResultComponent] in
var dict = dict
if let error = try? container.decode(ResultError.self, forKey: id) {
dict[id.stringValue] = error
} else if let entry = try? container.decode(ResultEntry.self, forKey: id) {
dict[id.stringValue] = entry
} else {
throw FetchError.decodingError(category, id.stringValue)
}
return dict
}
}
if rootValues.contains(.keyboards) {
let keyboardValueSet = try rootValues.nestedContainer(keyedBy: IDCodingKey.self, forKey: .keyboards)
keyboards = try keyboardValueSet.allKeys.reduce([String: ResultComponent](), dictionaryReducer(container: keyboardValueSet, category: "keyboard"))
} else {
keyboards = nil
}
if rootValues.contains(.models) {
let modelValueSet = try rootValues.nestedContainer(keyedBy: IDCodingKey.self, forKey: .models)
models = try modelValueSet.allKeys.reduce([String: ResultComponent](), dictionaryReducer(container: modelValueSet, category: "lexical model"))
} else {
models = nil
}
}
func entryFor(_ key: KeymanPackage.Key) -> Entry {
var result: ResultComponent?
switch(key.type) {
case .keyboard:
result = keyboards?[key.id]
case .lexicalModel:
result = models?[key.id]
}
if let entry = result as? ResultEntry {
return .success(entry)
} else {
return .failure(result as? ResultError)
}
}
}
private static let PACKAGE_VERSION_ENDPOINT = URLComponents(string: "\(KeymanHosts.API_KEYMAN_COM)/package-version")!
public static func fetch(for packageKeys: [KeymanPackage.Key],
withSession session: URLSession = .shared,
fetchCompletion: @escaping JSONQueryCompletionBlock<Result>) {
// Step 1: build the query
var urlComponents = PACKAGE_VERSION_ENDPOINT
let queryItems: [URLQueryItem] = packageKeys.map { key in
var resourceField: String
switch(key.type) {
case .keyboard:
resourceField = "keyboard"
case .lexicalModel:
resourceField = "model"
}
return URLQueryItem(name: resourceField, value: key.id)
}
urlComponents.queryItems = queryItems + [
URLQueryItem(name: "platform", value: "ios"),
URLQueryItem(name: "keyman-version", value: Version.current.plainString)
]
let message = "Querying package versions through API endpoint: \(urlComponents.url!)"
os_log("%{public}s", log:KeymanEngineLogger.resources, type: .info, message)
SentryManager.breadcrumb(message)
// Step 2: configure the completion closure.
let completionClosure = Queries.jsonDataTaskCompletionAdapter(resultType: Result.self, completionBlock: fetchCompletion)
// Step 3: run the actual query, letting the prepared completion closure take care of the rest.
let task = session.dataTask(with: urlComponents.url!, completionHandler: completionClosure)
task.resume()
}
}
}