project: add new ESD catalog method

Resolves #8
This commit is contained in:
osy 2023-08-11 17:45:22 -07:00
parent 3a2e830d6e
commit 5086227d62
11 changed files with 1140 additions and 25 deletions

View file

@ -7,6 +7,10 @@
objects = {
/* Begin PBXBuildFile section */
844A8EFB2A860F91009A389C /* ESDCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844A8EFA2A860F91009A389C /* ESDCatalog.swift */; };
844A8EFF2A86CA8C009A389C /* SimpleContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844A8EFE2A86CA8C009A389C /* SimpleContentView.swift */; };
844A8F032A86E86F009A389C /* EULAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844A8F022A86E86F009A389C /* EULAView.swift */; };
844A8F062A86F92F009A389C /* esd2iso.sh in Resources */ = {isa = PBXBuildFile; fileRef = 844A8F052A86F92F009A389C /* esd2iso.sh */; };
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 */; };
@ -487,6 +491,10 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
844A8EFA2A860F91009A389C /* ESDCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESDCatalog.swift; sourceTree = "<group>"; };
844A8EFE2A86CA8C009A389C /* SimpleContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleContentView.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
@ -893,11 +901,28 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
844A8EF92A860DF6009A389C /* ESDCatalog */ = {
isa = PBXGroup;
children = (
844A8EFA2A860F91009A389C /* ESDCatalog.swift */,
);
path = ESDCatalog;
sourceTree = "<group>";
};
844A8F042A86F911009A389C /* Extras */ = {
isa = PBXGroup;
children = (
844A8F052A86F92F009A389C /* esd2iso.sh */,
);
path = Extras;
sourceTree = "<group>";
};
CEC09F022A6BB66200980857 = {
isa = PBXGroup;
children = (
CEC0A3082A71BBA900980857 /* Build.xcconfig */,
CEC09F0D2A6BB66200980857 /* Source */,
844A8F042A86F911009A389C /* Extras */,
CEC0A3022A70A6D500980857 /* converter */,
CEC09F442A6F645400980857 /* cabextract */,
CEC09FD42A6F771C00980857 /* mkisofs */,
@ -925,10 +950,13 @@
CEC09F0D2A6BB66200980857 /* Source */ = {
isa = PBXGroup;
children = (
844A8EF92A860DF6009A389C /* ESDCatalog */,
CEC09F292A6DC37800980857 /* UUPDump */,
CEC09F0E2A6BB66200980857 /* Main.swift */,
CEC09F102A6BB66200980857 /* ContentView.swift */,
844A8EFE2A86CA8C009A389C /* SimpleContentView.swift */,
CEC09F3C2A6EECC700980857 /* Downloader.swift */,
844A8F022A86E86F009A389C /* EULAView.swift */,
CEC09F252A6DAB7E00980857 /* BuildConfigView.swift */,
CEC09F362A6E5B7600980857 /* BuildDetails.swift */,
CEC09F382A6E5D5C00980857 /* BuildEditions.swift */,
@ -1529,6 +1557,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
844A8F062A86F92F009A389C /* esd2iso.sh in Resources */,
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1542,8 +1571,10 @@
files = (
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */,
CEC09F312A6DC8FB00980857 /* UUPPackage.swift in Sources */,
844A8EFF2A86CA8C009A389C /* SimpleContentView.swift in Sources */,
CEC09F332A6DCA2C00980857 /* UUPDumpAPI.swift in Sources */,
CEC09F282A6DBED400980857 /* Worker.swift in Sources */,
844A8EFB2A860F91009A389C /* ESDCatalog.swift in Sources */,
CEC09F262A6DAB7E00980857 /* BuildConfigView.swift in Sources */,
CEC09F0F2A6BB66200980857 /* Main.swift in Sources */,
CEC09F372A6E5B7600980857 /* BuildDetails.swift in Sources */,
@ -1552,6 +1583,7 @@
CEC09F2D2A6DC60500980857 /* UUPDetails.swift in Sources */,
CEC09F352A6DF33C00980857 /* BuildsListView.swift in Sources */,
CEC09F392A6E5D5C00980857 /* BuildEditions.swift in Sources */,
844A8F032A86E86F009A389C /* EULAView.swift in Sources */,
CEC09F2F2A6DC72000980857 /* UUPEditions.swift in Sources */,
CEC09F2B2A6DC40900980857 /* UUPBuilds.swift in Sources */,
);

302
Extras/esd2iso.sh Executable file
View file

@ -0,0 +1,302 @@
#!/bin/sh
#
# w11arm_esd2iso - download and convert Microsoft ESD files for Windows 11 ARM to ISO
#
# Copyright (C) 2023 Paul Rockwell
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA#
#
# Credit: Location and methods of obtaining Microsoft ESD distributions and
# Microsoft Product catalog from b0gdanw "ESD to ISO on macOS.txt" https://gist.github.com/b0gdanw/e36ea84828dbd19e03eff6158f1fc77c
#
versionID="4.0.2 (13-July-2023)"
version="w11arm_esd2iso ${versionID}\n"
verbosityLevel=0
awk="/usr/bin/awk"
genisoimage="$(command -v mkisofs)"
declare -a lTags
declare -a lDesc
usage() {
echo "Usage:\n"
echo "$0 [-v]"
echo "$0 [-Vh]"
echo "\nOptions:"
echo "\t-h\tPrint usage and exit"
echo "\t-v\tEnable verbose output"
echo "\t-V\tPrint program version and exit"
}
printVersion() {
echo $version
}
verboseOn() {
if (( verbosityLevel == 0 )); then
return 1
else
return 0
fi
}
extractEsd(){
local eFile
local eDir
local retVal
local esdImageCount
local bootWimFile
local installWimFile
local images
local imageIndex
local imageEdition
local beQuiet
eFile=$1
eDir=$2
beQuiet="--quiet"
bootWimFile=$eDir/sources/boot.wim
installWimFile=$eDir/sources/install.wim
images=("4" "5")
verboseOn && beQuiet=""
esdImageCount=$(wimlib-imagex info $eFile | $awk '/Image Count:/ {print $3}')
verboseOn && echo "[DEBUG] image count in ESD: $esdImageCount"
(( $esdImageCount == 6 )) && images+=("6")
#---------------
# Extract image 1 in the ESD to create the boot environment
#---------------
echo "\nApplying boot files to the image"
wimlib-imagex apply $eFile 1 $eDir $beQuiet 2>/dev/null
retVal=$?
if (( retVal != 0 )); then
echo "[ERROR] Extract of boot files failed"
return $retVal
fi
echo "Boot files successfully applied to image"
#---------------
# Create the boot.wim file that contains WinPE and Windows Setup
# Images 2 and 3 in the ESD contain these components
#
# Important: image 3 in the ESD must be marked as bootable when
# transferred to boot.wim or else the installer will fail
#---------------
echo "\nAdding WinPE and Windows Setup to the image"
wimlib-imagex export $eFile 2 $bootWimFile --compress=LZX --chunk-size 32K $beQuiet
retVal=$?
if (( retVal != 0 )); then
echo "[ERROR] Add of WinPE failed"
return $retVal
fi
wimlib-imagex export $eFile 3 $bootWimFile --compress=LZX --chunk-size 32K --boot $beQuiet
retVal=$?
if (( retVal != 0 )); then
echo "[ERROR] Add of Windows Setup failed"
return $retVal
fi
echo "WinPE and Windows Setup added successfully to image\n"
verboseOn && {
echo "[DEBUG] contents of $bootWimFile"
wimlib-imagex info $bootWimFile
}
#---------------
# Create the install.wim file that contains the files that Setup will install
# Images 4, 5, (and 6 if it exists) in the ESD contain these components
#---------------
for imageIndex in ${images[*]}; do
imageEdition="$(wimlib-imagex info $eFile $imageIndex | grep '^Description:' | sed 's/Description:[ \t]*//')"
echo "\nAdding $imageEdition to the image"
wimlib-imagex export $eFile $imageIndex $installWimFile --compress=LZMS --chunk-size 128K $beQuiet
retVal=$?
if (( retVal != 0 )); then
echo "[ERROR] Addition of $imageIndex to the image failed"
return $retVal
fi
echo "$imageEdition added successfully to the image"
done
echo "\nAll Windows editions added to image"
verboseOn && {
echo "[DEBUG] contents of $installWimFile"
wimlib-imagex info $installWimFile
}
return 0
}
buildIso(){
local iDir=$1
local iFile=$2
local iLabel=$3
local elToritoBootFile
iDir=$1
iFile=$2
if [ -e $iFile ]; then
echo "\t[INFO] File $iFile exists, removing it"
rm -rf $iFile
fi
elToritoBootFile=$iDir/efi/microsoft/boot/efisys.bin
#
# Create the ISO file
#
#$hdiutil makehybrid -o $iFile -iso -udf -hard-disk-boot -eltorito-boot $elToritoBootFile $iDir
"$genisoimage" -b "efi/microsoft/boot/efisys.bin" --no-emul-boot \
--udf -iso-level 3 --hide "*" -V "$iLabel" -o "$iFile" $iDir
return $?
}
#-------------------
#
# Start of program
#
#-------------------
#-------------------
#
# Process arguments
#
#-------------------
while getopts ":hr:vV" opt; do
case $opt in
h)
usage
exit 1
;;
v)
let verbosityLevel+=1
;;
V)
printVersion
exit 1
;;
:)
echo "[ERROR] Option -$OPTARG requires an argument"
usage
exit 1
;;
\?)
echo "[ERROR] Invalid option: -$OPTARG\n"
usage
exit 1
;;
esac
done
shift "$((OPTIND-1))"
printVersion
esdFile="$1"
isoFile="$2"
isoLabel="$3"
#-------------------
# Check number of arguments
# One argument is allowed when using the -r option for restart
# No arguments are allowed otherwise
#-------------------
if (( $# > 3 )); then
echo "[ERROR] Too many arguments"
usage
exit 1
fi
workingDir="$(mktemp -q -d ./esd2iso_temp.XXXXXX)"
if (( $? != 0 )); then
echo "[ERROR] Unable to create work directory, exiting"
exit 1
fi
#---------------
#
# extDir is the "extract directory" where we're going to extract the ESD
# and evenutally build the ISO from. It's a subdirectory of the working/temp directory
#
##---------------
extDir=$workingDir/ESD_ISO
mkdir $extDir
echo "\nStep 3: Building installation image from ESD distribution"
extractEsd $esdFile $extDir
retVal=$?
if (( retVal != 0 )); then
echo "[ERROR] Installation image build failed with error code $retVal"
echo "Work directory $workingDir was not deleted, use for debugging"
exit 1
fi
#---------------
# At this point we no longer need the ESD file as it's already extracted
# In order to reduce disk space reauirements, delete the ESD file unless we
# have set the environment variable keepDownloads
#---------------
if [[ "x${keepDownloads}" == "x" ]]; then
echo \n"ESD added successfully to installation image and is no longer needed.\nDeleting it to save disk space."
verboseOn && echo "Deleting ESD file ${esdFile}"
rm -rf ${esdFile}
retVal=$?
if (( retVal != 0 )); then
echo "[WARNING] Deletion of ESD file encountered a problem."
echo " The ISO build can continue, but will consume an addtional 5 GB of disk space."
else
echo "ESD file deleted successfully\n"
fi
else
verboseOn && echo "[DEBUG] keepDownloads is set - keeping ESD download"
fi
echo "\nStep 3 complete - installation image built"
echo "\nStep 4: Creating ISO $isoFile from the installation image\n"
buildIso $extDir $isoFile $isoLabel
retVal=$?
if (( retVal != 0 )); then
echo "[ERROR] ISO was NOT created"
echo "Working directory $workingDir was not deleted, use for debugging"
exit 1
fi
echo "Step 4 complete - ISO created"
echo "\nCleaning up work directory"
rm -rf $workingDir
echo "Done!"
exit 0

View file

@ -22,8 +22,11 @@ Credits
-------
CrystalFetch uses [UUPDump][3] APIs and converter scripts.
CrystalFetch uses [esd2iso][4] by Technogeezer.
This project is not affiliated with Microsoft Corporation. Windows® is a registered trademark of Microsoft Corporation.
[1]: https://github.com/TuringSoftware/CrystalFetch/actions?query=event%3Arelease+workflow%3ABuild
[2]: https://mac.getutm.app
[3]: https://uupdump.net
[4]: https://communities.vmware.com/t5/VMware-Fusion-Documents/w11arm-esd2iso-a-utility-to-create-Windows-11-ARM-ISOs-from/ta-p/2957381

View file

@ -136,7 +136,7 @@ struct BuildConfigView: View {
if let lastSelectedLocale = lastSelectedLocale {
selectedLocale = lastSelectedLocale
} else {
selectedLocale = worker.defaultLocale
selectedLocale = Worker.defaultLocale ?? "netural"
}
}
}

View file

@ -13,6 +13,11 @@
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>dl.delivery.mp.microsoft.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>

View file

@ -81,7 +81,8 @@ actor Downloader {
private func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
totalDownloadedSize += bytesWritten
progressCallback?(totalDownloadedSize, totalExpectedSize)
let expectedSize = totalExpectedSize > 0 ? totalExpectedSize : totalBytesExpectedToWrite
progressCallback?(totalDownloadedSize, expectedSize)
}
private func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
@ -99,7 +100,7 @@ actor Downloader {
/// - downloadUrl: What to download
/// - destinationUrl: Where to put it
/// - size: Estimated size for progress updates
func enqueue(downloadUrl: URL, to destinationUrl: URL, size: Int64) {
func enqueue(downloadUrl: URL, to destinationUrl: URL, size: Int64 = 0) {
let task = session.downloadTask(with: downloadUrl)
queue.append((task: task, destinationUrl: destinationUrl, retry: kMaxRetries))
totalExpectedSize += size
@ -107,6 +108,9 @@ actor Downloader {
/// Start downloading a single item from the queue and retry if the download is interrupted
private func dequeue() async throws {
guard !queue.isEmpty else {
return
}
let (task, destinationUrl, retry) = queue.removeFirst()
let debugIdentifier = task.originalRequest?.url?.absoluteString ?? "(unknown request)"
NSLog("Downloading %@ to %@ (retries left: %d)", debugIdentifier, destinationUrl.path, retry)

View file

@ -0,0 +1,364 @@
//
// 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
actor ESDCatalog {
struct File: Equatable {
let name: String
let languageCode: String
let languagePretty: String
let edition: String
let editionPretty: String
let architecture: String
let architecturePretty: String
let size: Int64
let sha1: String
let filePath: String
let isRetailOnly: Bool
let eula: String?
}
private(set) var files: [File]
init(from data: Data) async throws {
let result: ESDCatalogParser.Result = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let parser = XMLParser(data: data)
let coordinator = ESDCatalogParser()
coordinator.continuation = continuation
parser.delegate = coordinator
if !parser.parse() && coordinator.continuation != nil {
continuation.resume(throwing: parser.parserError ?? ESDCatalogError.unknown)
}
}
}
files = try result.files.map { parseFile in
let languageCode = parseFile.LanguageCode ?? "default"
guard let localization = result.localization[languageCode] else {
throw ESDCatalogError.localizationNotFound(languageCode)
}
return try Self.parseResult(parseFile, localization: localization, eulas: result.eulas)
}
}
private static func parseResult(_ result: ESDCatalogParser.File, localization: [String: String], eulas: [String: String?]) throws -> File {
guard let name = result.FileName else {
throw ESDCatalogError.missingElement("FileName", "Unknown")
}
guard let languageCode = result.LanguageCode else {
throw ESDCatalogError.missingElement("LanguageCode", name)
}
guard let languagePretty = result.Language else {
throw ESDCatalogError.missingElement("Language", name)
}
guard let edition = result.Edition else {
throw ESDCatalogError.missingElement("Edition", name)
}
guard let editionLoc = result.Edition_Loc else {
throw ESDCatalogError.missingElement("Edition_Loc", name)
}
let editionPretty = localize(editionLoc, localization: localization)
guard let architecture = result.Architecture else {
throw ESDCatalogError.missingElement("Architecture", name)
}
guard let architectureLoc = result.Architecture_Loc else {
throw ESDCatalogError.missingElement("Architecture_Loc", name)
}
let architecturePretty = localize(architectureLoc, localization: localization)
guard let size = result.Size else {
throw ESDCatalogError.missingElement("Size", name)
}
guard let sha1 = result.Sha1 else {
throw ESDCatalogError.missingElement("Sha1", name)
}
guard let filePath = result.FilePath else {
throw ESDCatalogError.missingElement("FilePath", name)
}
guard let isRetailOnly = result.IsRetailOnly else {
throw ESDCatalogError.missingElement("IsRetailOnly", name)
}
let eula = eulas[languageCode] ?? nil
return File(name: name,
languageCode: languageCode,
languagePretty: languagePretty,
edition: edition,
editionPretty: editionPretty,
architecture: architecture,
architecturePretty: architecturePretty,
size: size,
sha1: sha1,
filePath: filePath,
isRetailOnly: isRetailOnly,
eula: eula)
}
private static func localize(_ string: String, localization: [String: String]) -> String {
let lookup = String(string.dropFirst().dropLast())
return localization[lookup] ?? lookup
}
}
fileprivate class ESDCatalogParser: NSObject, XMLParserDelegate {
enum Inside: String {
case MCT
case Catalogs
case Catalog
case PublishedMedia
case Files
case File
case Languages
case Language
case EULAs
case EULA
}
struct File {
var FileName: String?
var LanguageCode: String?
var Language: String?
var Edition: String?
var Architecture: String?
var Size: Int64?
var Sha1: String?
var FilePath: String?
var Architecture_Loc: String?
var Edition_Loc: String?
var IsRetailOnly: Bool?
}
struct Language {
var LanguageCode: String
var Localization: [String: String] = [:]
}
struct EULA {
var LanguageCode: String?
var URL: String?
}
typealias Result = (files: [File], localization: [String: [String: String]], eulas: [String: String?])
var continuation: CheckedContinuation<Result, Error>?
private var inside: Inside?
private var field: String?
private var currentRecord: Any?
private var files: [File] = []
private var localization: [String: [String: String]] = [:]
private var eulas: [String: String?] = [:]
func parserDidStartDocument(_ xmlParser: XMLParser) {
inside = nil
}
func parserDidEndDocument(_ xmlParser: XMLParser) {
if let c = continuation {
continuation = nil
c.resume(returning: (files, localization, eulas))
}
}
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 .MCT:
guard expect(elementName, named: .Catalogs, in: xmlParser) else {
return
}
inside = .Catalogs
case .Catalogs:
guard expect(elementName, named: .Catalog, in: xmlParser) else {
return
}
inside = .Catalog
case .Catalog:
guard expect(elementName, named: .PublishedMedia, in: xmlParser) else {
return
}
inside = .PublishedMedia
case .PublishedMedia:
if elementName == Inside.Files.rawValue {
inside = .Files
} else if elementName == Inside.Languages.rawValue {
inside = .Languages
} else if elementName == Inside.EULAs.rawValue {
inside = .EULAs
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
}
case .Files:
guard expect(elementName, named: .File, in: xmlParser) else {
return
}
inside = .File
currentRecord = File()
case .Languages:
guard expect(elementName, named: .Language, in: xmlParser) else {
return
}
guard let languageCode = attributeDict["LanguageCode"] else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.attributeNotFound("LanguageCode"))
return
}
inside = .Language
currentRecord = Language(LanguageCode: languageCode)
case .EULAs:
guard expect(elementName, named: .EULA, in: xmlParser) else {
return
}
inside = .EULA
currentRecord = EULA()
case nil:
guard expect(elementName, named: .MCT, in: xmlParser) else {
return
}
inside = .MCT
case .File, .Language, .EULA:
guard field == nil else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return
}
field = elementName
}
}
func parser(_ xmlParser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
guard field == nil else {
if field == elementName {
field = nil
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
}
return
}
guard elementName == inside?.rawValue else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return
}
switch inside! {
case .MCT:
inside = nil
case .Catalogs:
inside = .MCT
case .Catalog:
inside = .Catalogs
case .PublishedMedia:
inside = .Catalog
case .File:
inside = .Files
files.append(currentRecord as! File)
case .Language:
inside = .Languages
let record = currentRecord as! Language
localization[record.LanguageCode] = record.Localization
case .EULA:
inside = .EULAs
let record = currentRecord as! EULA
if let languageCode = record.LanguageCode {
eulas[languageCode] = record.URL
}
case .Files, .Languages, .EULAs:
inside = .PublishedMedia
}
}
func parser(_ xmlParser: XMLParser, foundCharacters string: String) {
guard let field = field else {
return
}
if inside == .File {
parseFileElement(string, for: field)
} else if inside == .Language {
parseLanguageElement(string, for: field)
} else if inside == .EULA {
parseEULAElement(string, for: field)
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.unexpectedString(string, field))
}
}
private func parseFileElement(_ element: String, for field: String) {
var record = currentRecord as! File
switch field {
case "FileName": record.FileName = element
case "LanguageCode": record.LanguageCode = element
case "Language": record.Language = element
case "Edition": record.Edition = element
case "Architecture": record.Architecture = element
case "Size": record.Size = Int64(element)
case "Sha1": record.Sha1 = element
case "FilePath": record.FilePath = element
case "Architecture_Loc": record.Architecture_Loc = element
case "Edition_Loc": record.Edition_Loc = element
case "IsRetailOnly": record.IsRetailOnly = element == "True"
default: break
}
currentRecord = record
}
private func parseLanguageElement(_ element: String, for field: String) {
var record = currentRecord as! Language
record.Localization[field] = element
currentRecord = record
}
private func parseEULAElement(_ element: String, for field: String) {
var record = currentRecord as! EULA
switch field {
case "LanguageCode": record.LanguageCode = element
case "URL": record.URL = element
default: break
}
currentRecord = record
}
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
}
}
enum ESDCatalogError: Error {
case unknown
case invalidElement(String)
case attributeNotFound(String)
case unexpectedString(String, String)
case localizationNotFound(String)
case missingElement(String, String)
}
extension ESDCatalogError: LocalizedError {
var errorDescription: String? {
switch self {
case .unknown: return NSLocalizedString("Unknown parser error.", comment: "ESDCatalog")
case .invalidElement(let element): return String.localizedStringWithFormat(NSLocalizedString("Failed to parse element '%@'", comment: "ESDCatalog"), element)
case .attributeNotFound(let string): return String.localizedStringWithFormat(NSLocalizedString("Attribute '%@' not found", comment: "ESDCatalog"), string)
case .unexpectedString(let string, let field): return String.localizedStringWithFormat(NSLocalizedString("Unexpected string '%@' for field '%@'", comment: "ESDCatalog"), string, field)
case .localizationNotFound(let string): return String.localizedStringWithFormat(NSLocalizedString("Localization '%@' not found", comment: "ESDCatalog"), string)
case .missingElement(let element, let fileName): return String.localizedStringWithFormat(NSLocalizedString("Missing element '%@' in '%@'", comment: "ESDCatalog"), element, fileName)
}
}
}

69
Source/EULAView.swift Normal file
View file

@ -0,0 +1,69 @@
//
// 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 SwiftUI
struct EULAView: View {
let url: URL
let onAccept: () -> Void
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
@State private var loadedEula: NSAttributedString?
var body: some View {
ScrollView {
if let loadedEula = loadedEula {
Text(AttributedString(loadedEula)).padding()
} else {
HStack(alignment: .center) {
ProgressView().progressViewStyle(.circular).padding()
}
}
}
.task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
loadedEula = NSAttributedString(rtf: data, documentAttributes: nil) ?? NSAttributedString(string: NSLocalizedString("Failed to load EULA.", comment: "EULAView"))
} catch {
loadedEula = NSAttributedString(string: error.localizedDescription)
}
}
.frame(minWidth: 400, minHeight: 200)
.toolbar {
ToolbarItemGroup(placement: .cancellationAction) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("Cancel")
}
}
ToolbarItemGroup(placement: .confirmationAction) {
Button {
onAccept()
presentationMode.wrappedValue.dismiss()
} label: {
Text("Accept")
}.disabled(loadedEula == nil)
}
}
}
}
#Preview {
EULAView(url: URL(string: "https://www.google.com/")!) {
}
}

View file

@ -21,7 +21,12 @@ struct Main: App {
@StateObject private var worker = Worker()
var body: some Scene {
WindowGroup {
WindowGroup(id: "simple") {
SimpleContentView().environmentObject(worker)
.frame(minWidth: 500, idealWidth: 500, minHeight: 300, idealHeight: 300)
}
WindowGroup(id: "advanced") {
ContentView().environmentObject(worker)
.frame(minWidth: 800, minHeight: 400)
}.commands {

View file

@ -0,0 +1,219 @@
//
// 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 SwiftUI
struct SimpleContentView: View {
@EnvironmentObject private var worker: Worker
@State private var isConfirmCancelShown: Bool = false
@State private var isDownloadCompleted: Bool = false
@State private var isWindows10: Bool = false
@State private var selected: SelectedTuple = .default
@State private var selectedBuild: ESDCatalog.File?
@State private var selectedEula: SelectedEULA?
@State private var languages: [DisplayString] = []
@State private var editions: [DisplayString] = []
@Environment(\.openWindow) private var openWindow
var body: some View {
VStack {
Form {
Picker("Version", selection: $isWindows10) {
Text("Windows® 11").tag(false)
Text("Windows® 10").tag(true)
}.pickerStyle(.radioGroup)
Picker("Architecture", selection: $selected.architecture) {
if hasArchitecture("ARM64") {
Text("Apple Silicon").tag("ARM64")
}
if hasArchitecture("x64") {
Text("Intel x64").tag("x64")
}
if hasArchitecture("x86") {
Text("Intel x86").tag("x86")
}
}.pickerStyle(.radioGroup)
Picker("Language", selection: $selected.language) {
ForEach(languages) { language in
Text(language.display).tag(language.id)
}
}
Picker("Edition", selection: $selected.edition) {
ForEach(editions) { edition in
Text(edition.display).tag(edition.id)
}
}
}.disabled(worker.isBusy)
.onChange(of: isWindows10) { newValue in
worker.refreshEsdCatalog(windows10: newValue)
}
.onChange(of: worker.esdCatalog) { _ in
refreshList()
}
.onChange(of: selected) { _ in
refreshList()
}
Spacer()
HStack {
// SwiftUI BUG: ProgressView cannot go to indeterminate mode and back
if let progress = worker.progress {
ProgressView(value: progress) {
progressLabel
} currentValueLabel: {
Text(worker.progressStatus ?? "")
}
} else {
ProgressView(value: nil as Float?) {
progressLabel
} currentValueLabel: {
Text(worker.progressStatus ?? "")
}
}
}
HStack {
Button {
} label: {
Text("All builds…")
}.disabled(worker.isBusy)
Spacer()
if worker.isBusy {
Button(role: .cancel) {
isConfirmCancelShown.toggle()
} label: {
Text("Cancel")
}.keyboardShortcut(.cancelAction)
.confirmationDialog("Are you sure you want to stop the process?", isPresented: $isConfirmCancelShown) {
Button("Stop", role: .destructive) {
worker.stop()
}
Button("Cancel", role: .cancel) {
isConfirmCancelShown = false
}
}
} else {
Button {
if let eula = selectedBuild?.eula {
selectedEula = SelectedEULA(url: URL(string: eula)!)
} else if let selectedBuild = selectedBuild {
worker.download(selectedBuild)
}
} label: {
Text("Download…")
}.disabled(selectedBuild == nil)
}
}
}
.sheet(item: $selectedEula) { eula in
EULAView(url: eula.url) {
if let selectedBuild = selectedBuild {
worker.download(selectedBuild)
}
}
}
.alert(item: $worker.lastSeenError) { lastSeenError in
Alert(title: Text(lastSeenError.message))
}
.padding()
.onAppear {
worker.refreshEsdCatalog()
}
.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)
}
}
}
@ViewBuilder
private var progressLabel: some View {
if let selectedBuild = selectedBuild {
Text(selectedBuild.name).font(.caption)
} else if !worker.isBusy {
Text("No build found.").font(.caption)
}
}
private func hasArchitecture(_ name: String) -> Bool {
worker.esdCatalog.contains(where: { $0.architecture == name })
}
private func refreshList() {
let archFiltered = worker.esdCatalog.filter({ $0.architecture == selected.architecture })
let languagesList = archFiltered.map({ DisplayString(id: $0.languageCode, display: $0.languagePretty )})
languages = Set(languagesList).sorted(using: KeyPathComparator(\.display))
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 })
}
}
private struct DisplayString: Identifiable, Hashable, Equatable {
var id: String
var display: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func ==(lhs: DisplayString, rhs: DisplayString) -> Bool {
lhs.display == rhs.display
}
}
private struct SelectedTuple: Equatable {
var architecture: String = ""
var language: String = ""
var edition: String = ""
static var `default`: SelectedTuple = {
var tuple = SelectedTuple()
#if arch(arm64)
tuple.architecture = "ARM64"
#elseif arch(x86_64)
tuple.architecture = "x64"
#else
tuple.architecture = "x86"
#endif
tuple.language = Worker.defaultLocale ?? "en-us"
tuple.edition = "Professional"
return tuple
}()
}
private struct SelectedEULA: Identifiable {
let url: URL
var id: URL {
url
}
}
#Preview {
SimpleContentView()
}

View file

@ -36,6 +36,8 @@ class Worker: ObservableObject {
@Published private(set) var progressStatus: String?
@Published var completedDownloadUrl: URL?
@Published var esdCatalog: [ESDCatalog.File] = []
private let api = UUPDumpAPI()
private var runningTask: (Task<Void, Never>)?
private var lastErrorLine: String?
@ -56,8 +58,17 @@ class Worker: ObservableObject {
UserDefaults.standard.bool(forKey: "ShowServerBuilds")
}
var defaultLocale: String {
UserDefaults.standard.string(forKey: "LastSelectedLocale") ?? Locale.preferredLanguages.first?.lowercased() ?? "netural"
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) {
@ -72,19 +83,7 @@ class Worker: ObservableObject {
}
func lookupPossibleLocale(fromBuildDetails buildDetails: UUPDetails, withPreferredLanguage language: String? = nil) -> 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"
]
var decisionLocale = language ?? defaultLocale
if !buildDetails.langList.contains(decisionLocale),
let convertor = localeMapper[decisionLocale]
{
// unable to find this locale, check naming conversation if possible
decisionLocale = convertor
}
var decisionLocale = language ?? Self.defaultLocale ?? "netural"
if !buildDetails.langList.contains(decisionLocale) {
// unable to find this locale, use the English if possible
decisionLocale = "en-us"
@ -182,16 +181,22 @@ class Worker: ObservableObject {
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 = script
process.currentDirectoryURL = url
process.executableURL = executableURL
process.currentDirectoryURL = currentDirectoryURL
process.environment = ["PATH": "\(executablePath):/usr/bin:/bin:/usr/sbin:/sbin"]
process.arguments = ["wim", ".", "1"]
process.arguments = args
let outputPipe = Pipe()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
if let line = self.extractLine(from: handle.availableData) {
NSLog("[convert.sh stdout]: %@", line)
NSLog("[%@ stdout]: %@", executableURL.lastPathComponent, line)
Task { @MainActor in
self.progressStatus = line
}
@ -200,7 +205,7 @@ class Worker: ObservableObject {
let errorPipe = Pipe()
errorPipe.fileHandleForReading.readabilityHandler = { handle in
if let line = self.extractLine(from: handle.availableData) {
NSLog("[convert.sh stderr]: %@", line)
NSLog("[%@ stderr]: %@", executableURL.lastPathComponent, line)
Task { @MainActor in
self.lastErrorLine = line
}
@ -236,7 +241,19 @@ class Worker: ObservableObject {
} onCancel: {
process.terminate()
}
completedDownloadUrl = findIso(at: url)
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) {
@ -287,6 +304,101 @@ class Worker: ObservableObject {
}
}
// 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")!
}
func refreshEsdCatalog(windows10: Bool = false) {
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 windows10 {
await downloader.enqueue(downloadUrl: windows10CatalogUrl, to: catalogUrl)
} else {
await downloader.enqueue(downloadUrl: windows11CatalogUrl, to: catalogUrl)
}
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)
esdCatalog = await esd.files
}
}
func download(_ file: ESDCatalog.File) {
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let baseUrl = cacheUrl.appendingPathComponent(UUID().uuidString)
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, 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)