main: support different builds through worproject

This commit is contained in:
osy 2025-03-28 10:57:00 -07:00
parent 2dce2ff2c7
commit b08cbda85e
4 changed files with 349 additions and 26 deletions

View file

@ -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 = "<group>"; };
844A8F052A86F92F009A389C /* esd2iso.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = esd2iso.sh; sourceTree = "<group>"; };
84EB35712A870EA6004F252E /* ShowWindowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowWindowButtonView.swift; sourceTree = "<group>"; };
CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCTCatalogs.swift; sourceTree = "<group>"; };
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 = "<group>"; };
CEC09F102A6BB66200980857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -915,6 +917,7 @@
isa = PBXGroup;
children = (
844A8EFA2A860F91009A389C /* ESDCatalog.swift */,
CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */,
);
path = ESDCatalog;
sourceTree = "<group>";
@ -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 */,

View file

@ -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
}
}

View file

@ -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()
}
@ -153,10 +169,26 @@ 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 })
}
}

View file

@ -36,6 +36,7 @@ class Worker: ObservableObject {
@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()
@ -318,7 +319,30 @@ extension Worker {
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)