mirror of
https://github.com/TuringSoftware/CrystalFetch.git
synced 2024-09-20 06:55:58 +08:00
parent
3a2e830d6e
commit
5086227d62
|
@ -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
302
Extras/esd2iso.sh
Executable 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
|
|
@ -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
|
||||
|
|
|
@ -136,7 +136,7 @@ struct BuildConfigView: View {
|
|||
if let lastSelectedLocale = lastSelectedLocale {
|
||||
selectedLocale = lastSelectedLocale
|
||||
} else {
|
||||
selectedLocale = worker.defaultLocale
|
||||
selectedLocale = Worker.defaultLocale ?? "netural"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
364
Source/ESDCatalog/ESDCatalog.swift
Normal file
364
Source/ESDCatalog/ESDCatalog.swift
Normal 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
69
Source/EULAView.swift
Normal 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/")!) {
|
||||
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
219
Source/SimpleContentView.swift
Normal file
219
Source/SimpleContentView.swift
Normal 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()
|
||||
}
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue