mirror of
https://github.com/TuringSoftware/CrystalFetch.git
synced 2025-12-20 20:38:26 +08:00
460 lines
20 KiB
Swift
460 lines
20 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?
|
|
|
|
@Published var mctCatalogs: [MCTCatalogs.Windows: MCTCatalogs.Version] = [:]
|
|
@Published var esdCatalog: [ESDCatalog.File] = []
|
|
|
|
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")
|
|
}
|
|
|
|
static nonisolated var defaultLocale: String? {
|
|
// There is a naming conversation required for UUP API and macOS API
|
|
// see https://github.com/TuringSoftware/CrystalFetch/issues/2 for details
|
|
let localeMapper = [
|
|
"zh-hans-cn": "zh-cn"
|
|
]
|
|
if let preferred = UserDefaults.standard.string(forKey: "LastSelectedLocale") ?? Locale.preferredLanguages.first?.lowercased() {
|
|
return localeMapper[preferred] ?? preferred
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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 lookupPossibleLocale(fromBuildDetails buildDetails: UUPDetails, withPreferredLanguage language: String? = nil) -> String {
|
|
var decisionLocale = language ?? Self.defaultLocale ?? "netural"
|
|
if !buildDetails.langList.contains(decisionLocale) {
|
|
// unable to find this locale, use the English if possible
|
|
decisionLocale = "en-us"
|
|
}
|
|
if !buildDetails.langList.contains(decisionLocale) {
|
|
// unable to find the en-us, use the first value supported if possible
|
|
decisionLocale = buildDetails.langList.first ?? ""
|
|
}
|
|
// if still not possible, throw the error UNSUPPORTED_LANG from server side
|
|
return decisionLocale
|
|
}
|
|
|
|
func refreshDetails(uuid: String, language: String? = nil, _ onCompletion: @escaping (BuildDetails, BuildEditions) -> Void) {
|
|
withBusyIndication { [self] in
|
|
let detailsResponse = try await api.fetchDetails(for: uuid)
|
|
let language = lookupPossibleLocale(fromBuildDetails: detailsResponse, withPreferredLanguage: language)
|
|
let editionsResponse = try await api.fetchEditions(for: uuid, language: language)
|
|
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: .utf8) {
|
|
let lines = string.split(whereSeparator: \.isNewline)
|
|
if let line = lines.filter({ !$0.isEmpty }).last {
|
|
let stringLine = String(line)
|
|
if let pattern = try? NSRegularExpression(pattern: "\\033\\[(0|1).*?m") {
|
|
return pattern.stringByReplacingMatches(in: stringLine, range: NSRange(location: 0, length: stringLine.count), withTemplate: "")
|
|
}
|
|
return stringLine
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func convert(files url: URL) async throws {
|
|
let script = Bundle.main.url(forResource: "convert", withExtension: "sh")!
|
|
try await exec(at: url, executableURL: script, "wim", ".", "1")
|
|
completedDownloadUrl = findIso(at: url)
|
|
}
|
|
|
|
@discardableResult
|
|
private func execv(at currentDirectoryURL: URL?, executableURL: URL, _ args: [String]) async throws -> Int32 {
|
|
let executablePath = Bundle.main.executableURL!.deletingLastPathComponent().path
|
|
let process = Process()
|
|
process.executableURL = executableURL
|
|
process.currentDirectoryURL = currentDirectoryURL
|
|
process.environment = ["PATH": "\(executablePath):/usr/bin:/bin:/usr/sbin:/sbin"]
|
|
process.arguments = args
|
|
let outputPipe = Pipe()
|
|
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
if let line = self.extractLine(from: handle.availableData) {
|
|
NSLog("[%@ stdout]: %@", executableURL.lastPathComponent, line)
|
|
Task { @MainActor in
|
|
self.progressStatus = line
|
|
}
|
|
}
|
|
}
|
|
let errorPipe = Pipe()
|
|
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
if let line = self.extractLine(from: handle.availableData) {
|
|
NSLog("[%@ stderr]: %@", executableURL.lastPathComponent, 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()
|
|
}
|
|
return process.terminationStatus
|
|
}
|
|
|
|
@discardableResult
|
|
private func exec(at currentDirectoryURL: URL?, executableURL: URL, _ args: String...) async throws -> Int32 {
|
|
try await execv(at: currentDirectoryURL, executableURL: executableURL, args)
|
|
}
|
|
|
|
@discardableResult
|
|
private func exec(_ args: String...) async throws -> Int32 {
|
|
let executableURL = Bundle.main.url(forAuxiliaryExecutable: args[0])!
|
|
let args = Array(args.dropFirst())
|
|
return try await execv(at: nil, executableURL: executableURL, args)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ESD Catalog
|
|
extension Worker {
|
|
private var windows10CatalogUrl: URL {
|
|
URL(string: "https://go.microsoft.com/fwlink/?LinkId=841361")!
|
|
}
|
|
|
|
private var windows11CatalogUrl: URL {
|
|
URL(string: "https://go.microsoft.com/fwlink?linkid=2156292")!
|
|
}
|
|
|
|
private var worprojectUrl: URL {
|
|
URL(string: "https://worproject.com/dldserv/esd/getversions.php")!
|
|
}
|
|
|
|
func refreshCatalogUrls() {
|
|
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let catalogsUrl = cacheUrl.appendingPathComponent("mctcatalogs.xml")
|
|
withBusyIndication { [self] in
|
|
let fm = FileManager.default
|
|
let downloader = Downloader()
|
|
try? fm.removeItem(at: catalogsUrl)
|
|
await downloader.enqueue(downloadUrl: worprojectUrl, to: catalogsUrl)
|
|
do {
|
|
try await downloader.start()
|
|
let data = try Data(contentsOf: catalogsUrl)
|
|
let catalogs = try await MCTCatalogs(from: data)
|
|
mctCatalogs = await catalogs.versions
|
|
} catch {
|
|
NSLog("Ignoring error while trying to fetch MCT catalogs: %@", error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
func refreshEsdCatalog(windowsVersion: MCTCatalogs.Windows = .windows11, release: MCTCatalogs.Release? = nil) {
|
|
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let catalogUrl = cacheUrl.appendingPathComponent("catalog.cab")
|
|
let productsUrl = cacheUrl.appendingPathComponent("products.xml")
|
|
withBusyIndication { [self] in
|
|
let fm = FileManager.default
|
|
let downloader = Downloader()
|
|
try? fm.removeItem(at: catalogUrl)
|
|
// if we specify a release, then use it
|
|
if let release = release {
|
|
await downloader.enqueue(downloadUrl: release.cabUrl, to: catalogUrl)
|
|
try await downloader.start()
|
|
} else {
|
|
// next try hard coded cab url
|
|
if windowsVersion == .windows10 {
|
|
await downloader.enqueue(downloadUrl: windows10CatalogUrl, to: catalogUrl)
|
|
} else {
|
|
await downloader.enqueue(downloadUrl: windows11CatalogUrl, to: catalogUrl)
|
|
}
|
|
do {
|
|
try await downloader.start()
|
|
} catch {
|
|
// finally, see if we got a new latest cab url
|
|
if let url = mctCatalogs[windowsVersion]?.latestCabUrl {
|
|
await downloader.enqueue(downloadUrl: url, to: catalogUrl)
|
|
try await downloader.start()
|
|
} else {
|
|
throw error // otherwise we throw the original error
|
|
}
|
|
}
|
|
}
|
|
try await exec("cabextract", "-d", cacheUrl.path, catalogUrl.path)
|
|
let data = try Data(contentsOf: productsUrl)
|
|
let esd = try await ESDCatalog(from: data)
|
|
esdCatalog = await esd.files
|
|
}
|
|
}
|
|
|
|
func download(_ file: ESDCatalog.File) {
|
|
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let uuid = file.sha1.count > 0 ? file.sha1 : UUID().uuidString
|
|
let baseUrl = cacheUrl.appendingPathComponent(uuid)
|
|
let esdUrl = baseUrl.appendingPathComponent(file.name)
|
|
let isoUrl = esdUrl.deletingPathExtension().appendingPathExtension("iso")
|
|
withBusyIndication { [self] in
|
|
let fm = FileManager.default
|
|
if fm.fileExists(atPath: isoUrl.path) {
|
|
completedDownloadUrl = isoUrl
|
|
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("Starting download...", comment: "Worker")
|
|
let downloader = Downloader()
|
|
await downloader.enqueue(downloadUrl: URL(string: file.filePath)!, to: esdUrl)
|
|
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(esd: esdUrl, to: isoUrl)
|
|
}
|
|
}
|
|
|
|
private func convert(esd esdUrl: URL, to isoUrl: URL) async throws {
|
|
let script = Bundle.main.url(forResource: "esd2iso", withExtension: "sh")!
|
|
var build = buildString(from: esdUrl.lastPathComponent)
|
|
if build.count > 32 {
|
|
build = String(build.prefix(32))
|
|
}
|
|
try await exec(at: nil, executableURL: script, "-v", esdUrl.path, isoUrl.path, build)
|
|
completedDownloadUrl = isoUrl
|
|
}
|
|
|
|
private func buildString(from name: String) -> String {
|
|
var count = 0
|
|
for (index, ch) in name.enumerated() {
|
|
if ch == "." {
|
|
count += 1
|
|
}
|
|
if count == 3 {
|
|
let endIndex = name.index(name.startIndex, offsetBy: index-1)
|
|
return String(name[name.startIndex...endIndex])
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|