CrystalFetch/Source/Worker.swift
2023-07-26 12:46:15 -07:00

275 lines
11 KiB
Swift

//
// Copyright © 2023 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import IOKit.pwr_mgt
@MainActor
class Worker: ObservableObject {
struct ErrorMessage: Identifiable {
var message: String
var id: String {
message
}
}
@Published private(set) var isBusy: Bool = false
@Published var lastSeenError: ErrorMessage?
@Published var builds: [UUPBuilds.Build] = []
@Published var selectedBuild: UUPBuilds.Build?
@Published var search: String = ""
@Published private(set) var progress: Float?
@Published private(set) var progressStatus: String?
@Published var completedDownloadUrl: URL?
private let api = UUPDumpAPI()
private var runningTask: (Task<Void, Never>)?
private var lastErrorLine: String?
private var currentArch: String {
#if arch(arm64)
"arm64"
#else
"amd64"
#endif
}
private var hasPreviewBuilds: Bool {
UserDefaults.standard.bool(forKey: "ShowPreviewBuilds")
}
private var hasServerBuilds: Bool {
UserDefaults.standard.bool(forKey: "ShowServerBuilds")
}
var defaultLocale: String {
UserDefaults.standard.string(forKey: "LastSelectedLocale") ?? Locale.preferredLanguages.first?.lowercased() ?? "netural"
}
func refresh(findDefault: Bool = false) {
withBusyIndication { [self] in
let lastSelectedUuid = selectedBuild?.uuid
let response = try await api.fetchBuilds(search: search.count > 0 ? search : nil)
builds = response.builds.filter({ !$0.title.lowercased().contains("update") })
if findDefault || !builds.contains(where: { $0.uuid == lastSelectedUuid }) {
selectedBuild = recommendedBuild()
}
}
}
func refreshDetails(uuid: String, language: String? = nil, _ onCompletion: @escaping (BuildDetails, BuildEditions) -> Void) {
withBusyIndication { [self] in
let detailsResponse = try await api.fetchDetails(for: uuid)
let editionsResponse = try await api.fetchEditions(for: uuid, language: language ?? defaultLocale)
onCompletion(BuildDetails(from: detailsResponse), BuildEditions(from: editionsResponse))
}
}
private func recommendedBuild() -> UUPBuilds.Build? {
builds.filter({ $0.arch == currentArch && (hasPreviewBuilds || !$0.title.contains("Insider")) && (hasServerBuilds || !$0.title.contains("Server")) }).first
}
private func findIso(at url: URL) -> URL? {
let files = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
return files?.first(where: { $0.pathExtension.lowercased() == "iso" })
}
private nonisolated func requestIdleAssertion() -> IOPMAssertionID? {
var preventIdleSleepAssertion: IOPMAssertionID = .zero
let success = IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleSystemSleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"CrystalFetch Downloader" as CFString,
&preventIdleSleepAssertion)
return success == kIOReturnSuccess ? preventIdleSleepAssertion : nil
}
private nonisolated func releaseIdleAssertion(_ assertion: IOPMAssertionID) {
IOPMAssertionRelease(assertion)
}
func download(uuid: String, language: String, editions: [String]) {
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let baseUrl = cacheUrl.appendingPathComponent(uuid)
withBusyIndication { [self] in
let fm = FileManager.default
if let existingUrl = findIso(at: baseUrl) {
// if we already have the ISO, go ahead and return it
completedDownloadUrl = existingUrl
return
}
try? fm.removeItem(at: baseUrl)
try fm.createDirectory(at: baseUrl, withIntermediateDirectories: true)
let idleAssertion = requestIdleAssertion()
defer {
if let idleAssertion = idleAssertion {
releaseIdleAssertion(idleAssertion)
}
}
completedDownloadUrl = nil
progress = 0.0
progressStatus = NSLocalizedString("Fetching files list...", comment: "Worker")
let package = try await api.fetchPackage(for: uuid, language: language, editions: editions)
progressStatus = NSLocalizedString("Starting download...", comment: "Worker")
let downloader = Downloader()
for (key, value) in package.files {
await downloader.enqueue(downloadUrl: URL(string: value.url)!, to: baseUrl.appendingPathComponent(key), size: value.size)
}
try await downloader.start { bytesWritten, bytesTotal in
let written = ByteCountFormatter.string(fromByteCount: bytesWritten, countStyle: .file)
let total = ByteCountFormatter.string(fromByteCount: bytesTotal, countStyle: .file)
Task { @MainActor in
self.progressStatus = String.localizedStringWithFormat(NSLocalizedString("Downloading %@ of %@...", comment: "Worker"), written, total)
let progress = (Float(bytesWritten) / Float(bytesTotal))
self.progress = progress > 1.0 ? 1.0 : progress
}
}
progressStatus = NSLocalizedString("Converting download to ISO...", comment: "Worker")
progress = nil
try await convert(files: baseUrl)
}
}
private nonisolated func extractLine(from data: Data) -> String? {
if let string = String(data: data, encoding: .ascii)?.filter({ $0.isASCII }) {
let lines = string.split(whereSeparator: \.isNewline)
if let line = lines.filter({ !$0.isEmpty }).last {
return String(line)
}
}
return nil
}
private func convert(files url: URL) async throws {
let script = Bundle.main.url(forResource: "convert", withExtension: "sh")!
let executablePath = Bundle.main.executableURL!.deletingLastPathComponent().path
let process = Process()
process.executableURL = script
process.currentDirectoryURL = url
process.environment = ["PATH": "\(executablePath):/usr/bin:/bin:/usr/sbin:/sbin"]
process.arguments = ["wim", ".", "1"]
let outputPipe = Pipe()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
if let line = self.extractLine(from: handle.availableData) {
NSLog("[convert.sh stdout]: %@", line)
Task { @MainActor in
self.progressStatus = line
}
}
}
let errorPipe = Pipe()
errorPipe.fileHandleForReading.readabilityHandler = { handle in
if let line = self.extractLine(from: handle.availableData) {
NSLog("[convert.sh stderr]: %@", line)
Task { @MainActor in
self.lastErrorLine = line
}
}
}
process.standardOutput = outputPipe
process.standardError = errorPipe
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
process.terminationHandler = { process in
let status = process.terminationStatus
if process.terminationReason == .exit && status == 0 {
continuation.resume()
} else if process.terminationReason == .uncaughtSignal && status == SIGTERM {
continuation.resume(throwing: CancellationError())
} else {
Task { @MainActor in
if let lastErrorLine = self.lastErrorLine {
continuation.resume(throwing: WorkerError.conversionFailedMessage(lastErrorLine))
} else {
continuation.resume(throwing: WorkerError.conversionFailedUnknown(status))
}
}
}
}
do {
lastErrorLine = nil
try process.run()
} catch {
continuation.resume(throwing: error)
}
}
} onCancel: {
process.terminate()
}
completedDownloadUrl = findIso(at: url)
}
func finalize(isoUrl: URL, destinationUrl: URL) {
let scoped = destinationUrl.startAccessingSecurityScopedResource()
completedDownloadUrl = nil
withBusyIndication {
defer {
if scoped {
destinationUrl.stopAccessingSecurityScopedResource()
}
}
self.progressStatus = NSLocalizedString("Saving ISO...", comment: "Worker")
do {
try FileManager.default.moveItem(at: isoUrl, to: destinationUrl)
} catch {
let error = error as NSError
if error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError {
// ignore this error
} else {
throw error
}
}
try FileManager.default.removeItem(at: isoUrl.deletingLastPathComponent())
}
}
func stop() {
runningTask?.cancel()
}
private func withBusyIndication(_ action: @escaping @MainActor () async throws -> Void) {
isBusy = true
progress = nil
progressStatus = nil
runningTask = Task {
do {
try await action()
} catch is CancellationError {
} catch {
lastSeenError = ErrorMessage(message: error.localizedDescription)
}
runningTask = nil
isBusy = false
progress = 0.0
progressStatus = nil
}
}
}
enum WorkerError: Error {
case conversionFailedUnknown(Int32)
case conversionFailedMessage(String)
}
extension WorkerError: LocalizedError {
var errorDescription: String? {
switch self {
case .conversionFailedUnknown(let status): return String.localizedStringWithFormat(NSLocalizedString("The conversion script failed with error code %d", comment: "Worker"), status)
case .conversionFailedMessage(let message): return message
}
}
}