From 5086227d6246510aa0ad96fcf5f0fdd715467c24 Mon Sep 17 00:00:00 2001 From: osy Date: Fri, 11 Aug 2023 17:45:22 -0700 Subject: [PATCH] project: add new ESD catalog method Resolves #8 --- CrystalFetch.xcodeproj/project.pbxproj | 32 +++ Extras/esd2iso.sh | 302 ++++++++++++++++++++ README.md | 3 + Source/BuildConfigView.swift | 2 +- Source/CrystalFetch-Info.plist | 5 + Source/Downloader.swift | 8 +- Source/ESDCatalog/ESDCatalog.swift | 364 +++++++++++++++++++++++++ Source/EULAView.swift | 69 +++++ Source/Main.swift | 7 +- Source/SimpleContentView.swift | 219 +++++++++++++++ Source/Worker.swift | 154 +++++++++-- 11 files changed, 1140 insertions(+), 25 deletions(-) create mode 100755 Extras/esd2iso.sh create mode 100644 Source/ESDCatalog/ESDCatalog.swift create mode 100644 Source/EULAView.swift create mode 100644 Source/SimpleContentView.swift diff --git a/CrystalFetch.xcodeproj/project.pbxproj b/CrystalFetch.xcodeproj/project.pbxproj index 4386eef..f22b667 100644 --- a/CrystalFetch.xcodeproj/project.pbxproj +++ b/CrystalFetch.xcodeproj/project.pbxproj @@ -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 = ""; }; + 844A8EFE2A86CA8C009A389C /* SimpleContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleContentView.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -893,11 +901,28 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 844A8EF92A860DF6009A389C /* ESDCatalog */ = { + isa = PBXGroup; + children = ( + 844A8EFA2A860F91009A389C /* ESDCatalog.swift */, + ); + path = ESDCatalog; + sourceTree = ""; + }; + 844A8F042A86F911009A389C /* Extras */ = { + isa = PBXGroup; + children = ( + 844A8F052A86F92F009A389C /* esd2iso.sh */, + ); + path = Extras; + sourceTree = ""; + }; 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 */, ); diff --git a/Extras/esd2iso.sh b/Extras/esd2iso.sh new file mode 100755 index 0000000..e9bd5c7 --- /dev/null +++ b/Extras/esd2iso.sh @@ -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 diff --git a/README.md b/README.md index 9f68ed5..db3092d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Source/BuildConfigView.swift b/Source/BuildConfigView.swift index 84d7fb5..8090166 100644 --- a/Source/BuildConfigView.swift +++ b/Source/BuildConfigView.swift @@ -136,7 +136,7 @@ struct BuildConfigView: View { if let lastSelectedLocale = lastSelectedLocale { selectedLocale = lastSelectedLocale } else { - selectedLocale = worker.defaultLocale + selectedLocale = Worker.defaultLocale ?? "netural" } } } diff --git a/Source/CrystalFetch-Info.plist b/Source/CrystalFetch-Info.plist index b14cc0f..462eb93 100644 --- a/Source/CrystalFetch-Info.plist +++ b/Source/CrystalFetch-Info.plist @@ -13,6 +13,11 @@ NSExceptionAllowsInsecureHTTPLoads + dl.delivery.mp.microsoft.com + + NSExceptionAllowsInsecureHTTPLoads + + ITSAppUsesNonExemptEncryption diff --git a/Source/Downloader.swift b/Source/Downloader.swift index 0c7e9c8..8ff426c 100644 --- a/Source/Downloader.swift +++ b/Source/Downloader.swift @@ -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) diff --git a/Source/ESDCatalog/ESDCatalog.swift b/Source/ESDCatalog/ESDCatalog.swift new file mode 100644 index 0000000..c6fa448 --- /dev/null +++ b/Source/ESDCatalog/ESDCatalog.swift @@ -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? + 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) + } + } +} diff --git a/Source/EULAView.swift b/Source/EULAView.swift new file mode 100644 index 0000000..7349c70 --- /dev/null +++ b/Source/EULAView.swift @@ -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 + + @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/")!) { + + } +} diff --git a/Source/Main.swift b/Source/Main.swift index 780c64c..77551fb 100644 --- a/Source/Main.swift +++ b/Source/Main.swift @@ -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 { diff --git a/Source/SimpleContentView.swift b/Source/SimpleContentView.swift new file mode 100644 index 0000000..82ec519 --- /dev/null +++ b/Source/SimpleContentView.swift @@ -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() +} diff --git a/Source/Worker.swift b/Source/Worker.swift index de15684..020a61a 100644 --- a/Source/Worker.swift +++ b/Source/Worker.swift @@ -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)? 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)