mirror of
https://github.com/TuringSoftware/CrystalFetch.git
synced 2025-12-20 12:28:27 +08:00
main: support different builds through worproject
This commit is contained in:
parent
2dce2ff2c7
commit
b08cbda85e
4 changed files with 349 additions and 26 deletions
|
|
@ -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 */,
|
||||
|
|
|
|||
246
Source/ESDCatalog/MCTCatalogs.swift
Normal file
246
Source/ESDCatalog/MCTCatalogs.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue