From b08cbda85eef838c023e307b47b4284515368b13 Mon Sep 17 00:00:00 2001 From: osy Date: Fri, 28 Mar 2025 10:57:00 -0700 Subject: [PATCH] main: support different builds through worproject --- CrystalFetch.xcodeproj/project.pbxproj | 4 + Source/ESDCatalog/MCTCatalogs.swift | 246 +++++++++++++++++++++++++ Source/SimpleContentView.swift | 70 +++++-- Source/Worker.swift | 55 +++++- 4 files changed, 349 insertions(+), 26 deletions(-) create mode 100644 Source/ESDCatalog/MCTCatalogs.swift diff --git a/CrystalFetch.xcodeproj/project.pbxproj b/CrystalFetch.xcodeproj/project.pbxproj index 86a8f8b..dbd46ea 100644 --- a/CrystalFetch.xcodeproj/project.pbxproj +++ b/CrystalFetch.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 844A8F032A86E86F009A389C /* EULAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844A8F022A86E86F009A389C /* EULAView.swift */; }; 844A8F062A86F92F009A389C /* esd2iso.sh in Resources */ = {isa = PBXBuildFile; fileRef = 844A8F052A86F92F009A389C /* esd2iso.sh */; }; 84EB35722A870EA7004F252E /* ShowWindowButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB35712A870EA6004F252E /* ShowWindowButtonView.swift */; }; + CE39C8A22D97003600A83CE8 /* MCTCatalogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */; }; CEC09F0F2A6BB66200980857 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F0E2A6BB66200980857 /* Main.swift */; }; CEC09F112A6BB66200980857 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F102A6BB66200980857 /* ContentView.swift */; }; CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CEC09F122A6BB66300980857 /* Assets.xcassets */; }; @@ -499,6 +500,7 @@ 844A8F022A86E86F009A389C /* EULAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EULAView.swift; sourceTree = ""; }; 844A8F052A86F92F009A389C /* esd2iso.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = esd2iso.sh; sourceTree = ""; }; 84EB35712A870EA6004F252E /* ShowWindowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowWindowButtonView.swift; sourceTree = ""; }; + CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCTCatalogs.swift; sourceTree = ""; }; CEC09F0B2A6BB66200980857 /* CrystalFetch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrystalFetch.app; sourceTree = BUILT_PRODUCTS_DIR; }; CEC09F0E2A6BB66200980857 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Main.swift; sourceTree = ""; }; CEC09F102A6BB66200980857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -915,6 +917,7 @@ isa = PBXGroup; children = ( 844A8EFA2A860F91009A389C /* ESDCatalog.swift */, + CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */, ); path = ESDCatalog; sourceTree = ""; @@ -1588,6 +1591,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CE39C8A22D97003600A83CE8 /* MCTCatalogs.swift in Sources */, CEC09F112A6BB66200980857 /* ContentView.swift in Sources */, CEC09F312A6DC8FB00980857 /* UUPPackage.swift in Sources */, 844A8EFF2A86CA8C009A389C /* SimpleContentView.swift in Sources */, diff --git a/Source/ESDCatalog/MCTCatalogs.swift b/Source/ESDCatalog/MCTCatalogs.swift new file mode 100644 index 0000000..804c9e6 --- /dev/null +++ b/Source/ESDCatalog/MCTCatalogs.swift @@ -0,0 +1,246 @@ +// +// Copyright © 2025 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 + +actor MCTCatalogs { + struct Version: Equatable { + let latestCabUrl: URL? + let releases: [Release] + } + + struct Release: Equatable, Hashable, Identifiable { + let build: String + let date: Date? + let cabUrl: URL + + var id: Int { + hashValue + } + } + + enum Windows: Int { + case windows10 = 10 + case windows11 = 11 + } + + private(set) var versions: [Windows: Version] = [:] + + init(from data: Data) async throws { + let result = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let parser = XMLParser(data: data) + let coordinator = MCTCatalogsParser() + coordinator.continuation = continuation + parser.delegate = coordinator + if !parser.parse() && coordinator.continuation != nil { + continuation.resume(throwing: parser.parserError ?? ESDCatalogError.unknown) + } + } + } + let results = try result.map { try Self.parseVersion($0) } + for (number, version) in results { + if let windows = Windows(rawValue: number) { + versions[windows] = version + } else { + NSLog("Ignoring unknown Windows version: %@", number) + } + } + } + + private static func parseVersion(_ result: MCTCatalogsParser.Version) throws -> (number: Int, version: Version) { + let latestCabUrl: URL? + if let latestCabLink = result.latestCabLink { + latestCabUrl = URL(string: latestCabLink)! + } else { + latestCabUrl = nil + } + guard let number = Int(result.number) else { + throw ESDCatalogError.missingElement("number", "version") + } + let releases = try result.releases.map { try Self.parseRelease($0) } + return (number, Version(latestCabUrl: latestCabUrl, releases: releases)) + } + + private static func parseRelease(_ result: MCTCatalogsParser.Release) throws -> Release { + let date: Date? + if let dateString = result.date { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + date = dateFormatter.date(from: dateString) + } else { + date = nil + } + guard let cabLink = result.cabLink else { + throw ESDCatalogError.missingElement("cabLink", "release") + } + let cabUrl: URL = URL(string: cabLink)! + return Release(build: result.build, date: date, cabUrl: cabUrl) + } +} + +fileprivate class MCTCatalogsParser: NSObject, XMLParserDelegate { + enum Inside: String { + case productsDb + case versions + case version + case latestCabLink + case releases + case release + case date + case cabLink + } + + struct Version { + var number: String + var latestCabLink: String? + var releases: [Release] = [] + } + + struct Release { + var build: String + var date: String? + var cabLink: String? + } + + var continuation: CheckedContinuation<[Version], Error>? + private var inside: Inside? + private var versions: [Version] = [] + private var currentVersion: Version? + private var currentRelease: Release? + + func parserDidStartDocument(_ xmlParser: XMLParser) { + inside = nil + } + + func parserDidEndDocument(_ xmlParser: XMLParser) { + if let c = continuation { + continuation = nil + c.resume(returning: versions) + } + } + + func parser(_ xmlParser: XMLParser, parseErrorOccurred parseError: Error) { + if let c = continuation { + continuation = nil + c.resume(throwing: parseError) + } + xmlParser.abortParsing() + } + + func parser(_ xmlParser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { + switch inside { + case nil: + guard expect(elementName, named: .productsDb, in: xmlParser) else { + return + } + inside = .productsDb + case .productsDb: + guard expect(elementName, named: .versions, in: xmlParser) else { + return + } + inside = .versions + case .versions: + guard expect(elementName, named: .version, in: xmlParser) else { + return + } + guard let number = attributeDict["number"] else { + parser(xmlParser, parseErrorOccurred: ESDCatalogError.attributeNotFound("number")) + return + } + inside = .version + currentVersion = Version(number: number) + case .version: + guard let seen = expect(elementName, namedOneOf: [.latestCabLink, .releases], in: xmlParser) else { + return + } + inside = seen + case .releases: + guard expect(elementName, named: .release, in: xmlParser) else { + return + } + guard let build = attributeDict["build"] else { + parser(xmlParser, parseErrorOccurred: ESDCatalogError.attributeNotFound("build")) + return + } + inside = .release + currentRelease = Release(build: build) + case .release: + guard let seen = expect(elementName, namedOneOf: [.date, .cabLink], in: xmlParser) else { + return + } + inside = seen + default: + parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName)) + } + } + + func parser(_ xmlParser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + guard elementName == inside?.rawValue else { + parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName)) + return + } + switch inside! { + case .productsDb: + inside = nil + case .versions: + inside = .productsDb + case .version: + versions.append(currentVersion!) + currentVersion = nil + inside = .versions + case .latestCabLink, .releases: + inside = .version + case .release: + currentVersion!.releases.append(currentRelease!) + currentRelease = nil + inside = .releases + case .date, .cabLink: + inside = .release + } + } + + func parser(_ xmlParser: XMLParser, foundCharacters string: String) { + if inside == .latestCabLink { + currentVersion!.latestCabLink = string + } else if inside == .date { + currentRelease!.date = string + } else if inside == .cabLink { + currentRelease!.cabLink = string + } else { + parser(xmlParser, parseErrorOccurred: ESDCatalogError.unexpectedString(string, inside?.rawValue ?? "")) + } + } + + private func expect(_ elementName: String, named element: Inside, in xmlParser: XMLParser) -> Bool { + guard elementName == element.rawValue else { + parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName)) + return false + } + return true + } + + private func expect(_ elementName: String, namedOneOf elements: [Inside], in xmlParser: XMLParser) -> Inside? { + for element in elements { + if elementName == element.rawValue { + return element + } + } + parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName)) + return nil + } +} diff --git a/Source/SimpleContentView.swift b/Source/SimpleContentView.swift index 3089ba2..d394411 100644 --- a/Source/SimpleContentView.swift +++ b/Source/SimpleContentView.swift @@ -22,21 +22,29 @@ struct SimpleContentView: View { @State private var isConfirmCancelShown: Bool = false @State private var isDownloadCompleted: Bool = false - @State private var isWindows10: Bool = false + @State private var windowsVersion: MCTCatalogs.Windows = .windows11 + @State private var selectedBuild: MCTCatalogs.Release? @State private var selected: SelectedTuple = .default - @State private var selectedBuild: ESDCatalog.File? + @State private var selectedFile: ESDCatalog.File? @State private var selectedEula: SelectedEULA? @State private var languages: [DisplayString] = [] @State private var editions: [DisplayString] = [] - + + private let dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM-yyyy" + return dateFormatter + }() + var body: some View { VStack { Form { - Picker("Version", selection: $isWindows10) { - Text("Windows® 11").tag(false) - Text("Windows® 10").tag(true) + Picker("Version", selection: $windowsVersion) { + Text("Windows® 11").tag(MCTCatalogs.Windows.windows11) + Text("Windows® 10").tag(MCTCatalogs.Windows.windows10) }.pickerStyle(.radioGroup) + buildsPicker Picker("Architecture", selection: $selected.architecture) { if hasArchitecture("ARM64") { Text("Apple Silicon").tag("ARM64") @@ -58,12 +66,19 @@ struct SimpleContentView: View { Text(edition.display).tag(edition.id) } } - if isWindows10 && selected.architecture == "ARM64" { + if windowsVersion == .windows10 && selected.architecture == "ARM64" { Text("Note: This build does not work for virtualization on Apple Silicon.") } }.disabled(worker.isBusy) - .onChange(of: isWindows10) { newValue in - worker.refreshEsdCatalog(windows10: newValue) + .onChange(of: windowsVersion) { newValue in + if selectedBuild == nil { + worker.refreshEsdCatalog(windowsVersion: newValue) + } else { + selectedBuild = nil + } + } + .onChange(of: selectedBuild) { newValue in + worker.refreshEsdCatalog(windowsVersion: windowsVersion, release: newValue) } .onChange(of: worker.esdCatalog) { _ in refreshList() @@ -112,21 +127,21 @@ struct SimpleContentView: View { } } else { Button { - if let eula = selectedBuild?.eula { + if let eula = selectedFile?.eula { selectedEula = SelectedEULA(url: URL(string: eula)!) - } else if let selectedBuild = selectedBuild { - worker.download(selectedBuild) + } else if let selectedFile = selectedFile { + worker.download(selectedFile) } } label: { Text("Download…") - }.disabled(selectedBuild == nil) + }.disabled(selectedFile == nil) } } } .sheet(item: $selectedEula) { eula in EULAView(url: eula.url) { - if let selectedBuild = selectedBuild { - worker.download(selectedBuild) + if let selectedFile = selectedFile { + worker.download(selectedFile) } } } @@ -135,6 +150,7 @@ struct SimpleContentView: View { } .padding() .onAppear { + worker.refreshCatalogUrls() worker.refreshEsdCatalog() refreshList() } @@ -152,11 +168,27 @@ struct SimpleContentView: View { } } } - + + @ViewBuilder + var buildsPicker: some View { + if let builds = worker.mctCatalogs[windowsVersion]?.releases { + Picker("Build", selection: $selectedBuild) { + Text("Latest").tag(nil as MCTCatalogs.Release?) + ForEach(builds) { build in + if let date = build.date { + Text("\(build.build) (\(dateFormatter.string(from: date)))").tag(build) + } else { + Text(build.build).tag(build) + } + } + } + } + } + @ViewBuilder private var progressLabel: some View { - if let selectedBuild = selectedBuild { - Text(selectedBuild.name).font(.caption) + if let selectedFile = selectedFile { + Text(selectedFile.name).font(.caption) } else if !worker.isBusy { Text("No build found.").font(.caption) } @@ -173,7 +205,7 @@ struct SimpleContentView: View { let languageFiltered = archFiltered.filter({ $0.languageCode == selected.language }) let editionsList = languageFiltered.map({ DisplayString(id: $0.edition, display: $0.editionPretty )}) editions = Set(editionsList).sorted(using: KeyPathComparator(\.display)) - selectedBuild = languageFiltered.first(where: { $0.edition == selected.edition }) + selectedFile = languageFiltered.first(where: { $0.edition == selected.edition }) } } diff --git a/Source/Worker.swift b/Source/Worker.swift index 388271c..4a9cbfb 100644 --- a/Source/Worker.swift +++ b/Source/Worker.swift @@ -35,7 +35,8 @@ class Worker: ObservableObject { @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() @@ -317,8 +318,31 @@ extension Worker { private var windows11CatalogUrl: URL { URL(string: "https://go.microsoft.com/fwlink?linkid=2156292")! } - - func refreshEsdCatalog(windows10: Bool = false) { + + 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") @@ -326,12 +350,29 @@ extension Worker { let fm = FileManager.default let downloader = Downloader() try? fm.removeItem(at: catalogUrl) - if windows10 { - await downloader.enqueue(downloadUrl: windows10CatalogUrl, to: 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 { - await downloader.enqueue(downloadUrl: windows11CatalogUrl, to: catalogUrl) + // 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 downloader.start() try await exec("cabextract", "-d", cacheUrl.path, catalogUrl.path) let data = try Data(contentsOf: productsUrl) let esd = try await ESDCatalog(from: data)