spiegel-keyman/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift
Shawn Schantz a18c740d8a
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run
rename PackageDownload to PackageInstallHelper
modifying the class to handle not just installation
of packages being downloaded but also installation
due to drag and drop or opening a .kmp from the
file menu

also added boolean to distinguish a download and
install operation because that is the only case
where we delete the original .kmp file that was
downloaded to the temp directory
2026-08-18 09:43:32 -04:00

219 lines
7.6 KiB
Swift

/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Shawn Schantz on 2026-06-30
*
* Tracks the state of a package being installed with functions
* to derive its temporary install location, compare it to a
* package of the same type if it exists and replace or delete depending
* on its version and user feedback.
*/
import Foundation
@MainActor // run on the main actor as it is called from SettingsContainer
public class PackageInstallHelper {
let temporaryKmpFileLocation: URL
let temporaryPackageLocation: URL
let installPackageLocation: URL
let installedPackages: [KeymanPackage] // needed to check for existing package after download
let isDownload: Bool // if not download, then the package was opened from disk or dropped
var packageToInstall: KeymanPackage? // the newly downloaded package
var packageToReplace: KeymanPackage? // the package to replace, if it exists
fileprivate let packageRepository: PackageRepo
public init(filename: String, packageName: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) {
self.packageRepository = packageRepo
self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename)
self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: packageName)
self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(packageName: packageName)
self.installedPackages = installedPackages
self.isDownload = isDownload
// cannot be initialized until after download when packageName of new package is known
self.packageToReplace = nil
// if any packages are remaining from an earlier download, delete them
self.packageRepository.cleanupTempDirectory()
}
/**
* Indicates that a package has been downloaded and can be prepared for installation
*/
public func packageDownloadComplete(for kmpFileUrl: URL) throws {
print ("packageDownloadComplete \(kmpFileUrl)")
try self.prepareToInstall(for: kmpFileUrl)
}
/**
* Indicates that a package is ready to be unzipped and installed
*/
public func prepareToInstall(for kmpFileUrl: URL) throws {
print ("prepareToInstall \(kmpFileUrl)")
do {
try self.unzipDownloadedPackage(for: kmpFileUrl)
try self.handleNewPackage()
} catch {
self.cleanupFailedInstallation()
print ("package installation failed with error '\(error)' for \(kmpFileUrl)")
throw error
}
}
/**
* Unzip and load the downloaded package
*/
func unzipDownloadedPackage(for kmpFileUrl: URL) throws {
try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation)
// load the unzipped package from the temporary location and save a reference to it
let newPackage = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation)
self.packageToInstall = newPackage
}
/**
* Decides whether the package should be installed.
* - If this package is not replacing a package, then it is installed.
* - If this package is replacing an older package, the new package replaces the old.
* - If this package is replacing a newer package, then the user is notified to confirm.
*/
func handleNewPackage() throws {
// first check whether this install is replacing an existing package,
if self.checkForExistingPackage() {
if self.replacingInstalledPackageWithEarlierVersion() {
// check with the user before allowing a downgrade
self.sendNotificationToConfirmPackageDowngrade()
} else {
try self.replaceExistingPackageWithNewPackage()
}
} else {
try self.installNewPackage()
}
}
/**
* Check whether a package of the same name is already installed which may be replaced.
*/
func checkForExistingPackage() -> Bool {
var packageExists = false
if let package = self.installedPackages.first(where: { $0.packageName == self.packageToInstall?.packageName }) {
self.packageToReplace = package
packageExists = true
}
return packageExists
}
/**
* Send a notification that an attempt to downgrade a package has been detected
*/
func sendNotificationToConfirmPackageDowngrade() {
NotificationCenter.default.post(name: .packageDowngradeRequested, object: nil)
}
/**
* Install the newly downloaded package (no existing package to replace)
*/
func installNewPackage() throws {
try self.movePackageFromTemporaryToInstalled()
if (self.isDownload) {
do {
try self.deleteDownloadedKmpFile()
} catch {
print("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)")
}
}
NotificationCenter.default.post(name: .newPackageInstalled, object: nil)
}
/**
* Replace the existing installed package with the newly download package
*/
func replaceExistingPackageWithNewPackage() throws {
try self.deleteInstalledPackage()
try self.deleteDownloadedKmpFile()
try self.movePackageFromTemporaryToInstalled()
NotificationCenter.default.post(name: .packageReplaced, object: nil)
}
/**
* Clean up the downloaded .kmp file and package folder
*/
func cleanupFailedInstallation() {
print("cleanupFailedInstallation of: \(self.temporaryPackageLocation.lastPathComponent)")
do {
try self.deleteDownloadedKmpFile()
} catch {
print("cleanupFailedInstallation did not delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)")
}
do {
try self.deleteDownloadedPackage()
} catch {
print("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryPackageLocation.lastPathComponent)")
}
}
/**
* Delete the existing installed package that matches the downloaded package
*/
func deleteInstalledPackage() throws {
try FileManager.default.removeItem(at: self.installPackageLocation)
}
/**
* Move the downloaded package into the keyman packages directory.
*/
func movePackageFromTemporaryToInstalled() throws {
try FileManager.default.moveItem(at: self.temporaryPackageLocation, to: self.installPackageLocation)
// Update the KeymanPackage object with its new location
if let package = self.packageToInstall {
package.sourceDirectoryUrl = self.installPackageLocation
}
}
/**
* Delete the downloaded .kmp file from the temp directory
*/
func deleteDownloadedKmpFile() throws {
try FileManager.default.removeItem(at: self.temporaryKmpFileLocation)
}
/**
* Delete the downloaded package from the temp directory
*/
func deleteDownloadedPackage() throws {
try FileManager.default.removeItem(at: self.temporaryPackageLocation)
}
/**
* Determine whether the new package is older than the currently installed package
*/
func replacingInstalledPackageWithEarlierVersion() -> Bool {
var downgrade = false
guard let installedVersion = self.packageToReplace?.packageVersion,
let newVersion = self.packageToInstall?.packageVersion else {
return false
}
let comparisonResult = newVersion.compare(installedVersion, options: .numeric)
if comparisonResult == .orderedAscending {
// downgrade detected
downgrade = true
print("downgrade: new version is older than installed version")
} else if comparisonResult == .orderedDescending {
print("upgrade: new version is newer than installed version")
} else {
print("new and installed versions are identical")
}
return downgrade
}
}