mirror of
https://github.com/TuringSoftware/CrystalFetch.git
synced 2025-12-25 14:59:57 +08:00
parent
3a2e830d6e
commit
5086227d62
11 changed files with 1140 additions and 25 deletions
|
|
@ -7,6 +7,10 @@
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
CEC09F0F2A6BB66200980857 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F0E2A6BB66200980857 /* Main.swift */; };
|
||||||
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F102A6BB66200980857 /* ContentView.swift */; };
|
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F102A6BB66200980857 /* ContentView.swift */; };
|
||||||
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CEC09F122A6BB66300980857 /* Assets.xcassets */; };
|
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CEC09F122A6BB66300980857 /* Assets.xcassets */; };
|
||||||
|
|
@ -487,6 +491,10 @@
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
CEC09F102A6BB66200980857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -893,11 +901,28 @@
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup 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 = {
|
CEC09F022A6BB66200980857 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
CEC0A3082A71BBA900980857 /* Build.xcconfig */,
|
CEC0A3082A71BBA900980857 /* Build.xcconfig */,
|
||||||
CEC09F0D2A6BB66200980857 /* Source */,
|
CEC09F0D2A6BB66200980857 /* Source */,
|
||||||
|
844A8F042A86F911009A389C /* Extras */,
|
||||||
CEC0A3022A70A6D500980857 /* converter */,
|
CEC0A3022A70A6D500980857 /* converter */,
|
||||||
CEC09F442A6F645400980857 /* cabextract */,
|
CEC09F442A6F645400980857 /* cabextract */,
|
||||||
CEC09FD42A6F771C00980857 /* mkisofs */,
|
CEC09FD42A6F771C00980857 /* mkisofs */,
|
||||||
|
|
@ -925,10 +950,13 @@
|
||||||
CEC09F0D2A6BB66200980857 /* Source */ = {
|
CEC09F0D2A6BB66200980857 /* Source */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
844A8EF92A860DF6009A389C /* ESDCatalog */,
|
||||||
CEC09F292A6DC37800980857 /* UUPDump */,
|
CEC09F292A6DC37800980857 /* UUPDump */,
|
||||||
CEC09F0E2A6BB66200980857 /* Main.swift */,
|
CEC09F0E2A6BB66200980857 /* Main.swift */,
|
||||||
CEC09F102A6BB66200980857 /* ContentView.swift */,
|
CEC09F102A6BB66200980857 /* ContentView.swift */,
|
||||||
|
844A8EFE2A86CA8C009A389C /* SimpleContentView.swift */,
|
||||||
CEC09F3C2A6EECC700980857 /* Downloader.swift */,
|
CEC09F3C2A6EECC700980857 /* Downloader.swift */,
|
||||||
|
844A8F022A86E86F009A389C /* EULAView.swift */,
|
||||||
CEC09F252A6DAB7E00980857 /* BuildConfigView.swift */,
|
CEC09F252A6DAB7E00980857 /* BuildConfigView.swift */,
|
||||||
CEC09F362A6E5B7600980857 /* BuildDetails.swift */,
|
CEC09F362A6E5B7600980857 /* BuildDetails.swift */,
|
||||||
CEC09F382A6E5D5C00980857 /* BuildEditions.swift */,
|
CEC09F382A6E5D5C00980857 /* BuildEditions.swift */,
|
||||||
|
|
@ -1529,6 +1557,7 @@
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
844A8F062A86F92F009A389C /* esd2iso.sh in Resources */,
|
||||||
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */,
|
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -1542,8 +1571,10 @@
|
||||||
files = (
|
files = (
|
||||||
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */,
|
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */,
|
||||||
CEC09F312A6DC8FB00980857 /* UUPPackage.swift in Sources */,
|
CEC09F312A6DC8FB00980857 /* UUPPackage.swift in Sources */,
|
||||||
|
844A8EFF2A86CA8C009A389C /* SimpleContentView.swift in Sources */,
|
||||||
CEC09F332A6DCA2C00980857 /* UUPDumpAPI.swift in Sources */,
|
CEC09F332A6DCA2C00980857 /* UUPDumpAPI.swift in Sources */,
|
||||||
CEC09F282A6DBED400980857 /* Worker.swift in Sources */,
|
CEC09F282A6DBED400980857 /* Worker.swift in Sources */,
|
||||||
|
844A8EFB2A860F91009A389C /* ESDCatalog.swift in Sources */,
|
||||||
CEC09F262A6DAB7E00980857 /* BuildConfigView.swift in Sources */,
|
CEC09F262A6DAB7E00980857 /* BuildConfigView.swift in Sources */,
|
||||||
CEC09F0F2A6BB66200980857 /* Main.swift in Sources */,
|
CEC09F0F2A6BB66200980857 /* Main.swift in Sources */,
|
||||||
CEC09F372A6E5B7600980857 /* BuildDetails.swift in Sources */,
|
CEC09F372A6E5B7600980857 /* BuildDetails.swift in Sources */,
|
||||||
|
|
@ -1552,6 +1583,7 @@
|
||||||
CEC09F2D2A6DC60500980857 /* UUPDetails.swift in Sources */,
|
CEC09F2D2A6DC60500980857 /* UUPDetails.swift in Sources */,
|
||||||
CEC09F352A6DF33C00980857 /* BuildsListView.swift in Sources */,
|
CEC09F352A6DF33C00980857 /* BuildsListView.swift in Sources */,
|
||||||
CEC09F392A6E5D5C00980857 /* BuildEditions.swift in Sources */,
|
CEC09F392A6E5D5C00980857 /* BuildEditions.swift in Sources */,
|
||||||
|
844A8F032A86E86F009A389C /* EULAView.swift in Sources */,
|
||||||
CEC09F2F2A6DC72000980857 /* UUPEditions.swift in Sources */,
|
CEC09F2F2A6DC72000980857 /* UUPEditions.swift in Sources */,
|
||||||
CEC09F2B2A6DC40900980857 /* UUPBuilds.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 [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.
|
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
|
[1]: https://github.com/TuringSoftware/CrystalFetch/actions?query=event%3Arelease+workflow%3ABuild
|
||||||
[2]: https://mac.getutm.app
|
[2]: https://mac.getutm.app
|
||||||
[3]: https://uupdump.net
|
[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 {
|
if let lastSelectedLocale = lastSelectedLocale {
|
||||||
selectedLocale = lastSelectedLocale
|
selectedLocale = lastSelectedLocale
|
||||||
} else {
|
} else {
|
||||||
selectedLocale = worker.defaultLocale
|
selectedLocale = Worker.defaultLocale ?? "netural"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>dl.delivery.mp.microsoft.com</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ actor Downloader {
|
||||||
|
|
||||||
private func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
private func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
totalDownloadedSize += bytesWritten
|
totalDownloadedSize += bytesWritten
|
||||||
progressCallback?(totalDownloadedSize, totalExpectedSize)
|
let expectedSize = totalExpectedSize > 0 ? totalExpectedSize : totalBytesExpectedToWrite
|
||||||
|
progressCallback?(totalDownloadedSize, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
private func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
|
@ -99,7 +100,7 @@ actor Downloader {
|
||||||
/// - downloadUrl: What to download
|
/// - downloadUrl: What to download
|
||||||
/// - destinationUrl: Where to put it
|
/// - destinationUrl: Where to put it
|
||||||
/// - size: Estimated size for progress updates
|
/// - 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)
|
let task = session.downloadTask(with: downloadUrl)
|
||||||
queue.append((task: task, destinationUrl: destinationUrl, retry: kMaxRetries))
|
queue.append((task: task, destinationUrl: destinationUrl, retry: kMaxRetries))
|
||||||
totalExpectedSize += size
|
totalExpectedSize += size
|
||||||
|
|
@ -107,6 +108,9 @@ actor Downloader {
|
||||||
|
|
||||||
/// Start downloading a single item from the queue and retry if the download is interrupted
|
/// Start downloading a single item from the queue and retry if the download is interrupted
|
||||||
private func dequeue() async throws {
|
private func dequeue() async throws {
|
||||||
|
guard !queue.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
let (task, destinationUrl, retry) = queue.removeFirst()
|
let (task, destinationUrl, retry) = queue.removeFirst()
|
||||||
let debugIdentifier = task.originalRequest?.url?.absoluteString ?? "(unknown request)"
|
let debugIdentifier = task.originalRequest?.url?.absoluteString ?? "(unknown request)"
|
||||||
NSLog("Downloading %@ to %@ (retries left: %d)", debugIdentifier, destinationUrl.path, retry)
|
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()
|
@StateObject private var worker = Worker()
|
||||||
|
|
||||||
var body: some Scene {
|
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)
|
ContentView().environmentObject(worker)
|
||||||
.frame(minWidth: 800, minHeight: 400)
|
.frame(minWidth: 800, minHeight: 400)
|
||||||
}.commands {
|
}.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 private(set) var progressStatus: String?
|
||||||
@Published var completedDownloadUrl: URL?
|
@Published var completedDownloadUrl: URL?
|
||||||
|
|
||||||
|
@Published var esdCatalog: [ESDCatalog.File] = []
|
||||||
|
|
||||||
private let api = UUPDumpAPI()
|
private let api = UUPDumpAPI()
|
||||||
private var runningTask: (Task<Void, Never>)?
|
private var runningTask: (Task<Void, Never>)?
|
||||||
private var lastErrorLine: String?
|
private var lastErrorLine: String?
|
||||||
|
|
@ -56,8 +58,17 @@ class Worker: ObservableObject {
|
||||||
UserDefaults.standard.bool(forKey: "ShowServerBuilds")
|
UserDefaults.standard.bool(forKey: "ShowServerBuilds")
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultLocale: String {
|
static nonisolated var defaultLocale: String? {
|
||||||
UserDefaults.standard.string(forKey: "LastSelectedLocale") ?? Locale.preferredLanguages.first?.lowercased() ?? "netural"
|
// 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) {
|
func refresh(findDefault: Bool = false) {
|
||||||
|
|
@ -72,19 +83,7 @@ class Worker: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lookupPossibleLocale(fromBuildDetails buildDetails: UUPDetails, withPreferredLanguage language: String? = nil) -> String {
|
func lookupPossibleLocale(fromBuildDetails buildDetails: UUPDetails, withPreferredLanguage language: String? = nil) -> String {
|
||||||
// There is a naming conversation required for UUP API and macOS API
|
var decisionLocale = language ?? Self.defaultLocale ?? "netural"
|
||||||
// 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
|
|
||||||
}
|
|
||||||
if !buildDetails.langList.contains(decisionLocale) {
|
if !buildDetails.langList.contains(decisionLocale) {
|
||||||
// unable to find this locale, use the English if possible
|
// unable to find this locale, use the English if possible
|
||||||
decisionLocale = "en-us"
|
decisionLocale = "en-us"
|
||||||
|
|
@ -182,16 +181,22 @@ class Worker: ObservableObject {
|
||||||
|
|
||||||
private func convert(files url: URL) async throws {
|
private func convert(files url: URL) async throws {
|
||||||
let script = Bundle.main.url(forResource: "convert", withExtension: "sh")!
|
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 executablePath = Bundle.main.executableURL!.deletingLastPathComponent().path
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = script
|
process.executableURL = executableURL
|
||||||
process.currentDirectoryURL = url
|
process.currentDirectoryURL = currentDirectoryURL
|
||||||
process.environment = ["PATH": "\(executablePath):/usr/bin:/bin:/usr/sbin:/sbin"]
|
process.environment = ["PATH": "\(executablePath):/usr/bin:/bin:/usr/sbin:/sbin"]
|
||||||
process.arguments = ["wim", ".", "1"]
|
process.arguments = args
|
||||||
let outputPipe = Pipe()
|
let outputPipe = Pipe()
|
||||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
if let line = self.extractLine(from: handle.availableData) {
|
if let line = self.extractLine(from: handle.availableData) {
|
||||||
NSLog("[convert.sh stdout]: %@", line)
|
NSLog("[%@ stdout]: %@", executableURL.lastPathComponent, line)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.progressStatus = line
|
self.progressStatus = line
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +205,7 @@ class Worker: ObservableObject {
|
||||||
let errorPipe = Pipe()
|
let errorPipe = Pipe()
|
||||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
if let line = self.extractLine(from: handle.availableData) {
|
if let line = self.extractLine(from: handle.availableData) {
|
||||||
NSLog("[convert.sh stderr]: %@", line)
|
NSLog("[%@ stderr]: %@", executableURL.lastPathComponent, line)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.lastErrorLine = line
|
self.lastErrorLine = line
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +241,19 @@ class Worker: ObservableObject {
|
||||||
} onCancel: {
|
} onCancel: {
|
||||||
process.terminate()
|
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) {
|
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 {
|
enum WorkerError: Error {
|
||||||
case conversionFailedUnknown(Int32)
|
case conversionFailedUnknown(Int32)
|
||||||
case conversionFailedMessage(String)
|
case conversionFailedMessage(String)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue