From 29d70d15f5aba0d59411ee3fd9aa3263783fe4f8 Mon Sep 17 00:00:00 2001 From: osy Date: Tue, 25 Jul 2023 21:42:18 -0700 Subject: [PATCH] worker: implement convert process --- Source/BuildConfigView.swift | 31 ++++++- Source/ContentView.swift | 3 +- Source/Downloader.swift | 3 + Source/Glass_ISO_Builder.entitlements | 2 +- Source/Main.swift | 1 + Source/Worker.swift | 115 ++++++++++++++++++++++++-- 6 files changed, 144 insertions(+), 11 deletions(-) diff --git a/Source/BuildConfigView.swift b/Source/BuildConfigView.swift index 0b944ca..8d1f758 100644 --- a/Source/BuildConfigView.swift +++ b/Source/BuildConfigView.swift @@ -27,6 +27,7 @@ struct BuildConfigView: View { @State private var isConfirmCancelShown: Bool = false @State private var details = BuildDetails.empty @State private var edition = BuildEditions.empty + @State private var isDownloadCompleted: Bool = false var body: some View { VStack { @@ -81,10 +82,19 @@ struct BuildConfigView: View { } Spacer() HStack { - ProgressView(value: worker.progress) { - - } currentValueLabel: { - Text(worker.progressStatus ?? "") + // SwiftUI BUG: ProgressView cannot go to indeterminate mode and back + if let progress = worker.progress { + ProgressView(value: progress) { + + } currentValueLabel: { + Text(worker.progressStatus ?? "") + } + } else { + ProgressView(value: nil as Float?) { + + } currentValueLabel: { + Text(worker.progressStatus ?? "") + } } } HStack { @@ -128,5 +138,18 @@ struct BuildConfigView: View { } } } + .onChange(of: worker.completedDownloadUrl) { newValue in + if newValue != nil { + isDownloadCompleted = true + } + } + .fileMover(isPresented: $isDownloadCompleted, file: worker.completedDownloadUrl) { result in + switch result { + case .success(let success): + worker.finalize(isoUrl: worker.completedDownloadUrl!, destinationUrl: success) + case .failure(let failure): + worker.lastSeenError = Worker.ErrorMessage(message: failure.localizedDescription) + } + } } } diff --git a/Source/ContentView.swift b/Source/ContentView.swift index 7b39cf6..4888985 100644 --- a/Source/ContentView.swift +++ b/Source/ContentView.swift @@ -39,7 +39,8 @@ struct ContentView: View { BuildsListView(arch: "arm64", hasPreviewBuilds: hasPreviewBuilds, hasServerBuilds: hasServerBuilds) #endif }.disabled(worker.isBusy) - }.listStyle(.sidebar) + }.frame(minWidth: 200, idealWidth: 300) + .listStyle(.sidebar) .searchable(text: $worker.search, placement: .toolbar) .onSubmit(of: .search) { if !worker.isBusy { diff --git a/Source/Downloader.swift b/Source/Downloader.swift index 061a2aa..0c7e9c8 100644 --- a/Source/Downloader.swift +++ b/Source/Downloader.swift @@ -108,6 +108,8 @@ actor Downloader { /// Start downloading a single item from the queue and retry if the download is interrupted private func dequeue() async throws { let (task, destinationUrl, retry) = queue.removeFirst() + let debugIdentifier = task.originalRequest?.url?.absoluteString ?? "(unknown request)" + NSLog("Downloading %@ to %@ (retries left: %d)", debugIdentifier, destinationUrl.path, retry) do { let resultUrl = try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in @@ -120,6 +122,7 @@ actor Downloader { try FileManager.default.moveItem(at: resultUrl, to: destinationUrl) } catch { let error = error as NSError + NSLog("Downloading %@ failed: ", debugIdentifier, error.localizedDescription) if retry > 0 { let newTask: URLSessionDownloadTask if let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data { diff --git a/Source/Glass_ISO_Builder.entitlements b/Source/Glass_ISO_Builder.entitlements index 625af03..a046386 100644 --- a/Source/Glass_ISO_Builder.entitlements +++ b/Source/Glass_ISO_Builder.entitlements @@ -4,7 +4,7 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write com.apple.security.network.client diff --git a/Source/Main.swift b/Source/Main.swift index d1ba06d..04c9b9c 100644 --- a/Source/Main.swift +++ b/Source/Main.swift @@ -23,6 +23,7 @@ struct Main: App { var body: some Scene { WindowGroup { ContentView().environmentObject(worker) + .frame(minWidth: 800, minHeight: 400) } } } diff --git a/Source/Worker.swift b/Source/Worker.swift index 8713747..72b7af6 100644 --- a/Source/Worker.swift +++ b/Source/Worker.swift @@ -31,12 +31,13 @@ class Worker: ObservableObject { @Published var builds: [UUPBuilds.Build] = [] @Published var selectedBuild: UUPBuilds.Build? @Published var search: String = "" - @Published private(set) var progress: Float = 0.0 + @Published private(set) var progress: Float? @Published private(set) var progressStatus: String? @Published var completedDownloadUrl: URL? private let api = UUPDumpAPI() private var runningTask: (Task)? + private var lastErrorLine: String? private var currentArch: String { #if arch(arm64) @@ -81,13 +82,17 @@ class Worker: ObservableObject { 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" }) + } + 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 - let files = try? fm.contentsOfDirectory(at: baseUrl, includingPropertiesForKeys: nil) - if let existingUrl = files?.first(where: { $0.pathExtension == "iso" }) { + if let existingUrl = findIso(at: baseUrl) { // if we already have the ISO, go ahead and return it completedDownloadUrl = existingUrl return @@ -108,11 +113,95 @@ class Worker: ObservableObject { let total = ByteCountFormatter.string(fromByteCount: bytesTotal, countStyle: .file) Task { @MainActor in self.progressStatus = String.localizedStringWithFormat(NSLocalizedString("Downloading %@ of %@...", comment: "Worker"), written, total) - self.progress = (Float(bytesWritten) / Float(bytesTotal)) * 0.9 + self.progress = (Float(bytesWritten) / Float(bytesTotal)) } } progressStatus = NSLocalizedString("Converting download to ISO...", comment: "Worker") - throw CancellationError() + progress = nil + try await convert(files: baseUrl) + } + } + + 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 = String(data: handle.availableData, encoding: .ascii)?.filter({ $0.isASCII }), !line.isEmpty { + NSLog("[convert.sh stdout]: %@", line) + Task { @MainActor in + self.progressStatus = line + } + } + } + let errorPipe = Pipe() + errorPipe.fileHandleForReading.readabilityHandler = { handle in + if let line = String(data: handle.availableData, encoding: .ascii)?.filter({ $0.isASCII }), !line.isEmpty { + 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()) } } @@ -122,6 +211,8 @@ class Worker: ObservableObject { private func withBusyIndication(_ action: @escaping @MainActor () async throws -> Void) { isBusy = true + progress = nil + progressStatus = nil runningTask = Task { do { try await action() @@ -137,3 +228,17 @@ class Worker: ObservableObject { } } } + +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 + } + } +}