Compare commits

...

61 commits
v1.0.0 ... main

Author SHA1 Message Date
osy
05e0b201da project: bumped version 2025-03-28 12:38:54 -07:00
osy
c0946e8781 github: update build action 2025-03-28 12:38:54 -07:00
Tiffany Fung
8c801a857c
Merge pull request #45 from changanmoon/main
Update translations for zh-Hans & zh-HK
2025-03-28 11:19:01 -07:00
osy
30f426038a
Merge pull request #42 from MMP0/patch-1
Update Japanese localization
2025-03-28 11:18:46 -07:00
osy
6a462109c3 esd2iso: update to v4.0.3 2025-03-28 11:05:57 -07:00
osy
53dc56900f downloader: fix log not recording error 2025-03-28 10:58:09 -07:00
osy
b08cbda85e main: support different builds through worproject 2025-03-28 10:57:00 -07:00
MMP0
39069074b4 Update for 38f64ea 2024-04-02 18:36:24 +09:00
osy
2dce2ff2c7 project: bumped version 2024-03-28 16:16:48 -07:00
osy
4f444d88c6 github: update deployment config 2024-03-28 16:04:02 -07:00
osy
effd4aeda5 project: change error to warning on latest Xcode 2024-03-28 15:37:10 -07:00
osy
38f64ea283 main: hide UUP-Dump under advanced options 2024-03-28 15:37:10 -07:00
osy
7e51b9b419 esd2iso: always use bash
Fixes #39
2024-03-28 15:37:10 -07:00
osy
e1e96ebf31 uup-dump: update git url
Fixes #41
2024-03-28 15:37:10 -07:00
osy
e03f718222
Merge pull request #25 from changanmoon/main
Add translations for Simplified Chinese and Traditional Chinese (Hong Kong)
2024-03-28 15:30:40 -07:00
osy
9761f74713
Merge pull request #24 from MMP0/patch-1
Update Japanese localization
2024-03-28 15:30:20 -07:00
Tiffany Fung
ca4e24700a
Update Localizable.strings 2024-03-26 19:22:20 +08:00
Tiffany Fung
de31fe1cc7
Update README.zh-HK.md 2024-01-02 09:44:37 +08:00
Tiffany Fung
c55b261cec
Update and rename README_zh-Hans.md to README.zh-Hans.md 2023-09-29 12:12:16 +08:00
Tiffany Fung
6a313e0658
Create README.zh-HK.md 2023-09-29 12:10:53 +08:00
Tiffany Fung
70460147f7
Create README_zh-Hans.md 2023-09-29 12:08:48 +08:00
Tiffany Fung
81d3bc7c9c
Update Localizable.strings 2023-09-06 18:02:40 +08:00
Tiffany Fung
1b93e42ce7
Update Localizable.strings 2023-09-06 14:05:02 +08:00
Tiffany Fung
010270f03f Change all the ellipsis for both Simplified Chinese and Traditional Chinese (Hong Kong) 2023-09-05 23:16:08 +08:00
Tiffany Fung
8206aa1567 Several corrections to previous translations 2023-09-05 23:03:14 +08:00
Tiffany Fung
baa045eff3 Add translation for Traditional Chinese (Hong Kong) 2023-09-05 22:51:38 +08:00
Tiffany Fung
97fa4cc048 Add translations for Simplified Chinese 2023-09-05 22:40:46 +08:00
Tiffany Fung
dd91aa3ccf Create localization files for Simplified Chinese and Traditional Chinese (Hong Kong) 2023-09-05 22:35:33 +08:00
MMP0
77e4d71f69 Update for a7368fd 2023-09-05 17:56:59 +09:00
osy
d0fbf0fe66 project: bumped version 2023-09-04 13:25:12 -07:00
osy
2d4b1bd4ec
Merge pull request #21 from MMP0/patch-1
Update Japanese localization
2023-09-04 13:22:15 -07:00
osy
5de7a87231
Merge pull request #20 from pan93412/sort-language-list
feat: sort the language list in details
2023-09-04 13:22:07 -07:00
osy
cc4f295b59
Merge pull request #19 from pan93412/l10n/zh-TW/230821
l10n: zh-Hant: add Chinese (Traditional) localization
2023-09-04 13:21:57 -07:00
osy
f0c2926081
Merge pull request #16 from WarningImHack3r/main
Remove ANSI characters from status line
2023-09-04 13:21:47 -07:00
osy
a7368fdd31 content: show notice when using build incompatible with virtualization 2023-09-04 13:14:11 -07:00
osy
8482fec6be worker: try to use same directory for the same file 2023-09-04 13:05:46 -07:00
osy
d76d953697 esd2iso: use consistent spacing 2023-09-04 13:04:49 -07:00
osy
5664c1bd67 esd2iso: add all images in esd 2023-09-04 13:02:57 -07:00
osy
604859d17a dependencies: update wimlib-imagex 2023-09-04 12:37:17 -07:00
MMP0
861a1fbe3d Update for 5086227 2023-08-21 11:08:02 +09:00
pan93412
ae7d8b1379
feat: sort the language list in details 2023-08-21 03:36:02 +08:00
pan93412
1259bc3057
l10n: zh-Hant: add Chinese (Traditional) localization 2023-08-21 03:20:13 +08:00
osy
7225052413 worker: more verbose logging in ESD creation 2023-08-12 14:57:43 -07:00
WarningImHack3r
65d00bfff6
Update regex 2023-08-12 20:42:57 +02:00
WarningImHack3r
b287caeeec
Remove ANSI characters from status line 2023-08-12 20:16:54 +02:00
osy
7c6efa9653 simple: hide duplicate display entries 2023-08-11 22:42:03 -07:00
osy
b8c9faa6cd project: bumped version 2023-08-11 18:24:22 -07:00
osy
b25ad06dc3 project: fix compile on Xcode 14 2023-08-11 18:24:22 -07:00
osy
9b00a0fdbb
Merge pull request #7 from MMP0/patch-1
Add Japanese localization
2023-08-11 18:21:00 -07:00
osy
733f9ae7b8 main: allow switching between UUP Dump and ESD Convert modes 2023-08-11 18:19:23 -07:00
osy
5086227d62 project: add new ESD catalog method
Resolves #8
2023-08-11 17:45:26 -07:00
osy
3a2e830d6e project: remove personalized file 2023-08-11 17:43:31 -07:00
MMP0
5f37338911 Fix typos 2023-08-04 08:46:55 +09:00
MMP0
c2a53cb03a Add Japanese localization 2023-08-03 12:28:29 +09:00
MMP0
449544ef37 Add Localizable.strings 2023-08-03 12:27:16 +09:00
osy
c2a27275fa project: bumped version 2023-07-30 23:32:17 -07:00
osy
0b7c12c138 main: allow showing/hiding sidebar from menu 2023-07-30 23:32:00 -07:00
Lakr Aream
38434ac286 Update Worker.swift 2023-07-28 22:06:45 -07:00
osy
1c9cd05849 uupdumpapi: fix parse error on some builds 2023-07-28 13:06:55 -07:00
osy
c19871b3da project: add exempt encryption status 2023-07-27 16:24:55 -07:00
osy
ad1247282b readme: updated readme and privacy policy 2023-07-27 16:20:52 -07:00
30 changed files with 2327 additions and 46 deletions

View file

@ -20,12 +20,12 @@ on:
env:
PRODUCT_NAME: CrystalFetch
BUILD_XCODE_PATH: /Applications/Xcode_14.2.app
BUILD_XCODE_PATH: /Applications/Xcode_16.2.app
jobs:
build:
name: Build
runs-on: macos-12
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@v3
@ -37,19 +37,19 @@ jobs:
[[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
- name: Build
run: |
xcodebuild archive -archivePath "$PRODUCT_NAME" -scheme "$PRODUCT_NAME" -configuration Release CODE_SIGN_IDENTITY="-" PRODUCT_BUNDLE_PREFIX="$PRODUCT_BUNDLE_PREFIX"
xcodebuild archive -archivePath "$PRODUCT_NAME" -scheme "$PRODUCT_NAME" -configuration Release CODE_SIGN_IDENTITY="-" PRODUCT_BUNDLE_PREFIX="$PRODUCT_BUNDLE_PREFIX" ONLY_ACTIVE_ARCH=No
tar -acf $PRODUCT_NAME.xcarchive.tgz $PRODUCT_NAME.xcarchive
env:
PRODUCT_NAME: ${{ env.PRODUCT_NAME }}
PRODUCT_BUNDLE_PREFIX: ${{ vars.PRODUCT_BUNDLE_PREFIX }}
- name: Upload
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.PRODUCT_NAME }}
path: ${{ env.PRODUCT_NAME }}.xcarchive.tgz
package:
name: Package
runs-on: macos-12
runs-on: macos-15
needs: [build]
if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
steps:
@ -60,6 +60,13 @@ jobs:
with:
p12-file-base64: ${{ secrets.SIGNING_CERTIFICATE_P12_DATA }}
p12-password: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
- name: Import App Store Connect API Key
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo $AUTHKEY_API_KEY | base64 --decode -o ~/.appstoreconnect/private_keys/AuthKey_$API_KEY.p8
env:
AUTHKEY_API_KEY: ${{ secrets.CONNECT_KEY }}
API_KEY: ${{ vars.CONNECT_KEY_ID }}
- name: Install Provisioning Profiles
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
@ -68,7 +75,7 @@ jobs:
PROFILE_DATA: ${{ vars.PROFILE_DATA }}
PROFILE_UUID: ${{ vars.PROFILE_UUID }}
- name: Download Artifact
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: ${{ env.PRODUCT_NAME }}
- name: Package for Release
@ -80,14 +87,16 @@ jobs:
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
PROFILE_UUID: ${{ vars.PROFILE_UUID }}
- name: Notarize app
run: npx notarize-cli --file "$PRODUCT_NAME.dmg" --bundle-id "$BUNDLE_ID"
run: |
xcrun notarytool submit --issuer "$ISSUER_UUID" --key-id "$API_KEY" --key "~/.appstoreconnect/private_keys/AuthKey_$API_KEY.p8" --team-id "$SIGNING_TEAM_ID" --wait "$PRODUCT_NAME.dmg"
xcrun stapler staple "$PRODUCT_NAME.dmg"
env:
BUNDLE_ID: ${{ vars.PRODUCT_BUNDLE_PREFIX }}.${{ env.PRODUCT_NAME }}
NOTARIZE_USERNAME: ${{ secrets.SIGNING_USERNAME }}
NOTARIZE_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
ISSUER_UUID: ${{ vars.CONNECT_ISSUER_ID }}
API_KEY: ${{ vars.CONNECT_KEY_ID }}
- name: Upload Artifact
if: github.event_name != 'release'
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.PRODUCT_NAME }}-dmg
path: ${{ env.PRODUCT_NAME }}.dmg
@ -103,17 +112,24 @@ jobs:
asset_content_type: application/octet-stream
submit:
name: Submit
runs-on: macos-12
runs-on: macos-15
needs: [build]
if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Import signing certificate into keychain
uses: apple-actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.SIGNING_CERTIFICATE_P12_DATA }}
p12-password: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
- name: Import App Store Connect API Key
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo $AUTHKEY_API_KEY | base64 --decode -o ~/.appstoreconnect/private_keys/AuthKey_$API_KEY.p8
env:
AUTHKEY_API_KEY: ${{ secrets.CONNECT_KEY }}
API_KEY: ${{ vars.CONNECT_KEY_ID }}
- name: Install Provisioning Profiles
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
@ -122,7 +138,7 @@ jobs:
PROFILE_DATA: ${{ vars.APP_STORE_PROFILE_DATA }}
PROFILE_UUID: ${{ vars.APP_STORE_PROFILE_UUID }}
- name: Download Artifact
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: ${{ env.PRODUCT_NAME }}
- name: Package for App Store
@ -135,15 +151,15 @@ jobs:
PROFILE_UUID: ${{ vars.APP_STORE_PROFILE_UUID }}
- name: Upload Artifact
if: github.event_name != 'release'
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.PRODUCT_NAME }}-pkg
path: ${{ env.PRODUCT_NAME }}.pkg
- name: Upload app to App Store Connect
if: github.event_name == 'release'
run: |
xcrun altool --upload-app -t macos -f "$PRODUCT_NAME.pkg" -u "$SUBMIT_USERNAME" -p "$SUBMIT_PASSWORD"
xcrun altool --upload-app -t macos -f "$PRODUCT_NAME.pkg" --apiKey "$API_KEY" --apiIssuer "$ISSUER_UUID"
env:
PRODUCT_NAME: ${{ env.PRODUCT_NAME }}
SUBMIT_USERNAME: ${{ secrets.SIGNING_USERNAME }}
SUBMIT_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
ISSUER_UUID: ${{ vars.CONNECT_ISSUER_ID }}
API_KEY: ${{ vars.CONNECT_KEY_ID }}

2
.gitmodules vendored
View file

@ -12,7 +12,7 @@
url = https://github.com/ebiggers/wimlib
[submodule "converter"]
path = converter
url = https://github.com/uup-dump/converter.git
url = https://git.uupdump.net/uup-dump/converter.git
[submodule "OpenSSL"]
path = OpenSSL
url = https://github.com/krzyzanowskim/OpenSSL.git

View file

@ -17,7 +17,7 @@
// Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 1.0.0
CURRENT_PROJECT_VERSION = 1
MARKETING_VERSION = 2.2.0
CURRENT_PROJECT_VERSION = 6
#include? "CodeSigning.xcconfig"

View file

@ -1 +0,0 @@
DEVELOPMENT_TEAM = WDNLXAD4W8

View file

@ -7,6 +7,12 @@
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 */; };
84EB35722A870EA7004F252E /* ShowWindowButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB35712A870EA6004F252E /* ShowWindowButtonView.swift */; };
CE39C8A22D97003600A83CE8 /* MCTCatalogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */; };
CEC09F0F2A6BB66200980857 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F0E2A6BB66200980857 /* Main.swift */; };
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC09F102A6BB66200980857 /* ContentView.swift */; };
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CEC09F122A6BB66300980857 /* Assets.xcassets */; };
@ -360,6 +366,8 @@
CEC0A2FC2A70A48200980857 /* aria2c in Copy Executables */ = {isa = PBXBuildFile; fileRef = CEC0A2F32A70A43100980857 /* aria2c */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
CEC0A3002A70A6CD00980857 /* convert.sh in Copy Scripts */ = {isa = PBXBuildFile; fileRef = CEC0A2FE2A70A6CD00980857 /* convert.sh */; };
CEC0A3012A70A6CD00980857 /* convert_ve_plugin in Copy Scripts */ = {isa = PBXBuildFile; fileRef = CEC0A2FF2A70A6CD00980857 /* convert_ve_plugin */; };
CEC8BC502A7B55D80042878F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CEC8BC522A7B55D80042878F /* Localizable.strings */; };
FFB1B52A2A929CEC00B95D56 /* CrystalFetch-InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = FFB1B5282A929CEC00B95D56 /* CrystalFetch-InfoPlist.strings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -487,6 +495,12 @@
/* 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>"; };
84EB35712A870EA6004F252E /* ShowWindowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowWindowButtonView.swift; sourceTree = "<group>"; };
CE39C8A12D97003600A83CE8 /* MCTCatalogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCTCatalogs.swift; sourceTree = "<group>"; };
CEC09F0B2A6BB66200980857 /* CrystalFetch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrystalFetch.app; sourceTree = BUILT_PRODUCTS_DIR; };
CEC09F0E2A6BB66200980857 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Main.swift; sourceTree = "<group>"; };
CEC09F102A6BB66200980857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -841,6 +855,12 @@
CEC0A2FE2A70A6CD00980857 /* convert.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = convert.sh; sourceTree = "<group>"; };
CEC0A2FF2A70A6CD00980857 /* convert_ve_plugin */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = convert_ve_plugin; sourceTree = "<group>"; };
CEC0A3082A71BBA900980857 /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = "<group>"; };
CEC8BC512A7B55D80042878F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
CEC8BC532A7B56280042878F /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
F65FBD842AA77422007637B0 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F65FBD852AA77426007637B0 /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = "<group>"; };
FFB1B5272A929A2800B95D56 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
FFB1B5292A929CEC00B95D56 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/CrystalFetch-InfoPlist.strings"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -893,11 +913,29 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
844A8EF92A860DF6009A389C /* ESDCatalog */ = {
isa = PBXGroup;
children = (
844A8EFA2A860F91009A389C /* ESDCatalog.swift */,
CE39C8A12D97003600A83CE8 /* MCTCatalogs.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,18 +963,24 @@
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 */,
CEC09F342A6DF33C00980857 /* BuildsListView.swift */,
84EB35712A870EA6004F252E /* ShowWindowButtonView.swift */,
CEC09F272A6DBED400980857 /* Worker.swift */,
CEC09F122A6BB66300980857 /* Assets.xcassets */,
CEC09F172A6BB66300980857 /* CrystalFetch.entitlements */,
CEC09F3E2A6F151800980857 /* CrystalFetch-Info.plist */,
FFB1B5282A929CEC00B95D56 /* CrystalFetch-InfoPlist.strings */,
CEC8BC522A7B55D80042878F /* Localizable.strings */,
);
path = Source;
sourceTree = "<group>";
@ -1506,6 +1550,10 @@
knownRegions = (
en,
Base,
ja,
"zh-Hant",
"zh-Hans",
"zh-HK",
);
mainGroup = CEC09F022A6BB66200980857;
packageReferences = (
@ -1529,7 +1577,10 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
844A8F062A86F92F009A389C /* esd2iso.sh in Resources */,
CEC09F132A6BB66300980857 /* Assets.xcassets in Resources */,
CEC8BC502A7B55D80042878F /* Localizable.strings in Resources */,
FFB1B52A2A929CEC00B95D56 /* CrystalFetch-InfoPlist.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1540,10 +1591,13 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CE39C8A22D97003600A83CE8 /* MCTCatalogs.swift in Sources */,
CEC09F112A6BB66200980857 /* ContentView.swift in Sources */,
CEC09F312A6DC8FB00980857 /* UUPPackage.swift in Sources */,
844A8EFF2A86CA8C009A389C /* SimpleContentView.swift in Sources */,
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 */,
@ -1551,7 +1605,9 @@
CEC09F3D2A6EECC700980857 /* Downloader.swift in Sources */,
CEC09F2D2A6DC60500980857 /* UUPDetails.swift in Sources */,
CEC09F352A6DF33C00980857 /* BuildsListView.swift in Sources */,
84EB35722A870EA7004F252E /* ShowWindowButtonView.swift in Sources */,
CEC09F392A6E5D5C00980857 /* BuildEditions.swift in Sources */,
844A8F032A86E86F009A389C /* EULAView.swift in Sources */,
CEC09F2F2A6DC72000980857 /* UUPEditions.swift in Sources */,
CEC09F2B2A6DC40900980857 /* UUPBuilds.swift in Sources */,
);
@ -1948,12 +2004,36 @@
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
CEC8BC522A7B55D80042878F /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
CEC8BC512A7B55D80042878F /* en */,
CEC8BC532A7B56280042878F /* ja */,
FFB1B5272A929A2800B95D56 /* zh-Hant */,
F65FBD842AA77422007637B0 /* zh-Hans */,
F65FBD852AA77426007637B0 /* zh-HK */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
FFB1B5282A929CEC00B95D56 /* CrystalFetch-InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
FFB1B5292A929CEC00B95D56 /* zh-Hant */,
);
name = "CrystalFetch-InfoPlist.strings";
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
CEC09F182A6BB66300980857 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CEC0A3082A71BBA900980857 /* Build.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -2018,6 +2098,7 @@
baseConfigurationReference = CEC0A3082A71BBA900980857 /* Build.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -2197,6 +2278,7 @@
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=llc.turing).CrystalFetch.mkisofs";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
WARNING_CFLAGS = "-Wno-error=incompatible-function-pointer-types";
};
name = Debug;
};
@ -2240,6 +2322,7 @@
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=llc.turing).CrystalFetch.mkisofs";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
WARNING_CFLAGS = "-Wno-error=incompatible-function-pointer-types";
};
name = Release;
};

313
Extras/esd2iso.sh Executable file
View file

@ -0,0 +1,313 @@
#!/bin/bash
#
# 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.3 (17-September-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"
for (( i = 6; i <= esdImageCount; i++ )); do
images+=("$i")
done
#---------------
# 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
#
#-------------------
#-------------------
# Bug fix - 16-Sept-2023 PER
#
# aria2c seems to have an issue with Sonoma: https://github.com/aria2/aria2/issues/2083
# This results in an error 134 that was displayed when trying to download the
# Windows 11 ARM catalog from Microsoft.
#
# suggested workaround is to set LC_MESSAGES environment variable
#-------------------
export LC_MESSAGES="C"
#-------------------
#
# 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

6
PRIVACY.md Normal file
View file

@ -0,0 +1,6 @@
Privacy Policy
==============
CrystalFetch will make network requests to uupdump.net and microsoft.com.
* UUPDump collects IP addresses in order to enforce rate limiting of API requests.
* Microsoft collects IP addresses for a variety of purposes as detailed in their [privacy statement](https://privacy.microsoft.com/en-us/privacystatement).

View file

@ -22,6 +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

32
README.zh-HK.md Normal file
View file

@ -0,0 +1,32 @@
CrystalFetch
============
[![Build](https://github.com/TuringSoftware/CrystalFetch/workflows/Build/badge.svg?branch=main&event=push)][1]
CrystalFetch 是一個 macOS 應用程式,用於製作 Windows® 11 安裝程式 ISO 映像檔。它可以與 [UTM 虛擬電腦][3]及其他虛擬電腦解決方案一齊使用。
注意CrystalFetch 與 Microsoft 無關聯,安裝 Windows® 11 需要有效的許可證(產品金鑰)。
<p align="center">
<img alt="CrystalFetch logo" src="Source/Assets.xcassets/AppIcon.appiconset/icon_128x128.png" srcset="Source/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png 2x" /><br />
<img alt="CrystalFetch screenshot" src="Extras/screen.png" />
</p>
構建
--------
1. 確保你使用 `git submodule update --init` 來獲取子模組。
2. 如你有 Apple Developer 付費賬戶,請複製 `CodeSigning.xcconfig.sample``CodeSigning.xcconfig`,並在此檔案中填寫你的開發人員訊息。
3. 如你沒有 Apple Developer 付費賬戶你需要禁用庫驗證。對於項目中的每一個構建目標轉至“Signing & Capabilities”之後選取“Disable Library Validation”核取方塊。
4. 現在你就可以由 Xcode 構建並執行此項目了。
致謝
-------
CrystalFetch 使用了 [UUPDump][3] 的 API 與轉換程式碼。
CrystalFetch 使用了由 Technogeezer 製作的 [esd2iso][4]。
此項目與 Microsoft Corporation 無關聯。Windows® 為 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

32
README.zh-Hans.md Normal file
View file

@ -0,0 +1,32 @@
CrystalFetch
============
[![Build](https://github.com/TuringSoftware/CrystalFetch/workflows/Build/badge.svg?branch=main&event=push)][1]
CrystalFetch 是一个用于创建 Windows® 11 安装 ISO 映像的 macOS 应用程序。它可以与 [UTM 虚拟机][3]及其他虚拟机解决方案一起使用。
注意CrystalFetch 不隶属于 Microsoft微软。安装 Windows® 11 需要有效的许可证(产品密钥)。
<p align="center">
<img alt="CrystalFetch logo" src="Source/Assets.xcassets/AppIcon.appiconset/icon_128x128.png" srcset="Source/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png 2x" /><br />
<img alt="CrystalFetch screenshot" src="Extras/screen.png" />
</p>
编译
--------
1. 确保使用 `git submodule update --init` 来获取子模块。
2. 若你有付费的 Apple Developer 账号,请将 `CodeSigning.xcconfig.sample` 拷贝到 `CodeSigning.xcconfig`,并用你的开发者信息来填写该文件。
3. 若你没有付费的 Apple Developer 账号需要禁用库验证library validation。对于此项目中的每个编译目标build target请前往“Signing & Capabilities”并勾选“Disable Library Validation”。
4. 现在可以从 Xcode 编译和运行此项目了。
致谢
-------
CrystalFetch 使用了 [UUPDump][3] 的 API 与转换脚本。
CrystalFetch 使用了由 Technogeezer 编写的 [esd2iso][4]。
此项目不隶属于 Microsoft Corporation。Windows® 是 Microsoft Corporation 的注册商标。
[1]: https://github.com/TuringSoftware/CrystalFetch/actions?query=event%3Arelease+workflow%3ABuild
[2]: https://mac.getutm.app
[3]: https://uupdump.net
[4]: https://communities.vmware.com/t5/VMware-Fusion-Documents/w11arm-esd2iso-a-utility-to-create-Windows-11-ARM-ISOs-from/ta-p/2957381

View file

@ -52,7 +52,7 @@ struct BuildConfigView: View {
}.padding(.bottom, 5)
Section("Language") {
Picker("", selection: $selectedLocale) {
ForEach(details.languages) { language in
ForEach(details.sortedLanguages) { language in
Text(language.display).tag(language.code)
}
}.onChange(of: selectedLocale) { newValue in
@ -78,6 +78,9 @@ struct BuildConfigView: View {
}))
}
}
if build.arch == "arm64" && Float(build.build) ?? 0 < 21390.0 {
Text("Note: This build does not work for virtualization on Apple Silicon.")
}
}.disabled(worker.isBusy)
}
Spacer()
@ -136,7 +139,7 @@ struct BuildConfigView: View {
if let lastSelectedLocale = lastSelectedLocale {
selectedLocale = lastSelectedLocale
} else {
selectedLocale = worker.defaultLocale
selectedLocale = Worker.defaultLocale ?? "netural"
}
}
}

View file

@ -32,6 +32,10 @@ struct BuildDetails {
let arch: String
let build: String
let created: Date
var sortedLanguages: [Language] {
return languages.sorted(using: KeyPathComparator(\.display))
}
static var empty = BuildDetails()

View file

@ -54,6 +54,12 @@ struct ContentView: View {
ProgressView()
}
}
ToolbarItem(placement: .navigation) {
ShowWindowButtonView(id: "ESDConvert") {
Label("Simple…", systemImage: "arrowshape.turn.up.backward.fill")
}.disabled(worker.isBusy)
.help("Build installation for the latest release through ESD conversion.")
}
ToolbarItem(placement: .principal) {
Button {
worker.refresh(findDefault: true)

View file

@ -2,12 +2,28 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>crystalfetch</string>
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>dl.delivery.mp.microsoft.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>tlu.dl.delivery.mp.microsoft.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>

View file

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

View file

@ -0,0 +1,364 @@
//
// Copyright © 2023 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
actor ESDCatalog {
struct File: Equatable {
let name: String
let languageCode: String
let languagePretty: String
let edition: String
let editionPretty: String
let architecture: String
let architecturePretty: String
let size: Int64
let sha1: String
let filePath: String
let isRetailOnly: Bool
let eula: String?
}
private(set) var files: [File]
init(from data: Data) async throws {
let result: ESDCatalogParser.Result = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let parser = XMLParser(data: data)
let coordinator = ESDCatalogParser()
coordinator.continuation = continuation
parser.delegate = coordinator
if !parser.parse() && coordinator.continuation != nil {
continuation.resume(throwing: parser.parserError ?? ESDCatalogError.unknown)
}
}
}
files = try result.files.map { parseFile in
let languageCode = parseFile.LanguageCode ?? "default"
guard let localization = result.localization[languageCode] else {
throw ESDCatalogError.localizationNotFound(languageCode)
}
return try Self.parseResult(parseFile, localization: localization, eulas: result.eulas)
}
}
private static func parseResult(_ result: ESDCatalogParser.File, localization: [String: String], eulas: [String: String?]) throws -> File {
guard let name = result.FileName else {
throw ESDCatalogError.missingElement("FileName", "Unknown")
}
guard let languageCode = result.LanguageCode else {
throw ESDCatalogError.missingElement("LanguageCode", name)
}
guard let languagePretty = result.Language else {
throw ESDCatalogError.missingElement("Language", name)
}
guard let edition = result.Edition else {
throw ESDCatalogError.missingElement("Edition", name)
}
guard let editionLoc = result.Edition_Loc else {
throw ESDCatalogError.missingElement("Edition_Loc", name)
}
let editionPretty = localize(editionLoc, localization: localization)
guard let architecture = result.Architecture else {
throw ESDCatalogError.missingElement("Architecture", name)
}
guard let architectureLoc = result.Architecture_Loc else {
throw ESDCatalogError.missingElement("Architecture_Loc", name)
}
let architecturePretty = localize(architectureLoc, localization: localization)
guard let size = result.Size else {
throw ESDCatalogError.missingElement("Size", name)
}
guard let sha1 = result.Sha1 else {
throw ESDCatalogError.missingElement("Sha1", name)
}
guard let filePath = result.FilePath else {
throw ESDCatalogError.missingElement("FilePath", name)
}
guard let isRetailOnly = result.IsRetailOnly else {
throw ESDCatalogError.missingElement("IsRetailOnly", name)
}
let eula = eulas[languageCode] ?? nil
return File(name: name,
languageCode: languageCode,
languagePretty: languagePretty,
edition: edition,
editionPretty: editionPretty,
architecture: architecture,
architecturePretty: architecturePretty,
size: size,
sha1: sha1,
filePath: filePath,
isRetailOnly: isRetailOnly,
eula: eula)
}
private static func localize(_ string: String, localization: [String: String]) -> String {
let lookup = String(string.dropFirst().dropLast())
return localization[lookup] ?? lookup
}
}
fileprivate class ESDCatalogParser: NSObject, XMLParserDelegate {
enum Inside: String {
case MCT
case Catalogs
case Catalog
case PublishedMedia
case Files
case File
case Languages
case Language
case EULAs
case EULA
}
struct File {
var FileName: String?
var LanguageCode: String?
var Language: String?
var Edition: String?
var Architecture: String?
var Size: Int64?
var Sha1: String?
var FilePath: String?
var Architecture_Loc: String?
var Edition_Loc: String?
var IsRetailOnly: Bool?
}
struct Language {
var LanguageCode: String
var Localization: [String: String] = [:]
}
struct EULA {
var LanguageCode: String?
var URL: String?
}
typealias Result = (files: [File], localization: [String: [String: String]], eulas: [String: String?])
var continuation: CheckedContinuation<Result, Error>?
private var inside: Inside?
private var field: String?
private var currentRecord: Any?
private var files: [File] = []
private var localization: [String: [String: String]] = [:]
private var eulas: [String: String?] = [:]
func parserDidStartDocument(_ xmlParser: XMLParser) {
inside = nil
}
func parserDidEndDocument(_ xmlParser: XMLParser) {
if let c = continuation {
continuation = nil
c.resume(returning: (files, localization, eulas))
}
}
func parser(_ xmlParser: XMLParser, parseErrorOccurred parseError: Error) {
if let c = continuation {
continuation = nil
c.resume(throwing: parseError)
}
xmlParser.abortParsing()
}
func parser(_ xmlParser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
switch inside {
case .MCT:
guard expect(elementName, named: .Catalogs, in: xmlParser) else {
return
}
inside = .Catalogs
case .Catalogs:
guard expect(elementName, named: .Catalog, in: xmlParser) else {
return
}
inside = .Catalog
case .Catalog:
guard expect(elementName, named: .PublishedMedia, in: xmlParser) else {
return
}
inside = .PublishedMedia
case .PublishedMedia:
if elementName == Inside.Files.rawValue {
inside = .Files
} else if elementName == Inside.Languages.rawValue {
inside = .Languages
} else if elementName == Inside.EULAs.rawValue {
inside = .EULAs
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
}
case .Files:
guard expect(elementName, named: .File, in: xmlParser) else {
return
}
inside = .File
currentRecord = File()
case .Languages:
guard expect(elementName, named: .Language, in: xmlParser) else {
return
}
guard let languageCode = attributeDict["LanguageCode"] else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.attributeNotFound("LanguageCode"))
return
}
inside = .Language
currentRecord = Language(LanguageCode: languageCode)
case .EULAs:
guard expect(elementName, named: .EULA, in: xmlParser) else {
return
}
inside = .EULA
currentRecord = EULA()
case nil:
guard expect(elementName, named: .MCT, in: xmlParser) else {
return
}
inside = .MCT
case .File, .Language, .EULA:
guard field == nil else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return
}
field = elementName
}
}
func parser(_ xmlParser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
guard field == nil else {
if field == elementName {
field = nil
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
}
return
}
guard elementName == inside?.rawValue else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return
}
switch inside! {
case .MCT:
inside = nil
case .Catalogs:
inside = .MCT
case .Catalog:
inside = .Catalogs
case .PublishedMedia:
inside = .Catalog
case .File:
inside = .Files
files.append(currentRecord as! File)
case .Language:
inside = .Languages
let record = currentRecord as! Language
localization[record.LanguageCode] = record.Localization
case .EULA:
inside = .EULAs
let record = currentRecord as! EULA
if let languageCode = record.LanguageCode {
eulas[languageCode] = record.URL
}
case .Files, .Languages, .EULAs:
inside = .PublishedMedia
}
}
func parser(_ xmlParser: XMLParser, foundCharacters string: String) {
guard let field = field else {
return
}
if inside == .File {
parseFileElement(string, for: field)
} else if inside == .Language {
parseLanguageElement(string, for: field)
} else if inside == .EULA {
parseEULAElement(string, for: field)
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.unexpectedString(string, field))
}
}
private func parseFileElement(_ element: String, for field: String) {
var record = currentRecord as! File
switch field {
case "FileName": record.FileName = element
case "LanguageCode": record.LanguageCode = element
case "Language": record.Language = element
case "Edition": record.Edition = element
case "Architecture": record.Architecture = element
case "Size": record.Size = Int64(element)
case "Sha1": record.Sha1 = element
case "FilePath": record.FilePath = element
case "Architecture_Loc": record.Architecture_Loc = element
case "Edition_Loc": record.Edition_Loc = element
case "IsRetailOnly": record.IsRetailOnly = element == "True"
default: break
}
currentRecord = record
}
private func parseLanguageElement(_ element: String, for field: String) {
var record = currentRecord as! Language
record.Localization[field] = element
currentRecord = record
}
private func parseEULAElement(_ element: String, for field: String) {
var record = currentRecord as! EULA
switch field {
case "LanguageCode": record.LanguageCode = element
case "URL": record.URL = element
default: break
}
currentRecord = record
}
private func expect(_ elementName: String, named element: Inside, in xmlParser: XMLParser) -> Bool {
guard elementName == element.rawValue else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return false
}
return true
}
}
enum ESDCatalogError: Error {
case unknown
case invalidElement(String)
case attributeNotFound(String)
case unexpectedString(String, String)
case localizationNotFound(String)
case missingElement(String, String)
}
extension ESDCatalogError: LocalizedError {
var errorDescription: String? {
switch self {
case .unknown: return NSLocalizedString("Unknown parser error.", comment: "ESDCatalog")
case .invalidElement(let element): return String.localizedStringWithFormat(NSLocalizedString("Failed to parse element '%@'", comment: "ESDCatalog"), element)
case .attributeNotFound(let string): return String.localizedStringWithFormat(NSLocalizedString("Attribute '%@' not found", comment: "ESDCatalog"), string)
case .unexpectedString(let string, let field): return String.localizedStringWithFormat(NSLocalizedString("Unexpected string '%@' for field '%@'", comment: "ESDCatalog"), string, field)
case .localizationNotFound(let string): return String.localizedStringWithFormat(NSLocalizedString("Localization '%@' not found", comment: "ESDCatalog"), string)
case .missingElement(let element, let fileName): return String.localizedStringWithFormat(NSLocalizedString("Missing element '%@' in '%@'", comment: "ESDCatalog"), element, fileName)
}
}
}

View file

@ -0,0 +1,246 @@
//
// Copyright © 2025 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
actor MCTCatalogs {
struct Version: Equatable {
let latestCabUrl: URL?
let releases: [Release]
}
struct Release: Equatable, Hashable, Identifiable {
let build: String
let date: Date?
let cabUrl: URL
var id: Int {
hashValue
}
}
enum Windows: Int {
case windows10 = 10
case windows11 = 11
}
private(set) var versions: [Windows: Version] = [:]
init(from data: Data) async throws {
let result = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let parser = XMLParser(data: data)
let coordinator = MCTCatalogsParser()
coordinator.continuation = continuation
parser.delegate = coordinator
if !parser.parse() && coordinator.continuation != nil {
continuation.resume(throwing: parser.parserError ?? ESDCatalogError.unknown)
}
}
}
let results = try result.map { try Self.parseVersion($0) }
for (number, version) in results {
if let windows = Windows(rawValue: number) {
versions[windows] = version
} else {
NSLog("Ignoring unknown Windows version: %@", number)
}
}
}
private static func parseVersion(_ result: MCTCatalogsParser.Version) throws -> (number: Int, version: Version) {
let latestCabUrl: URL?
if let latestCabLink = result.latestCabLink {
latestCabUrl = URL(string: latestCabLink)!
} else {
latestCabUrl = nil
}
guard let number = Int(result.number) else {
throw ESDCatalogError.missingElement("number", "version")
}
let releases = try result.releases.map { try Self.parseRelease($0) }
return (number, Version(latestCabUrl: latestCabUrl, releases: releases))
}
private static func parseRelease(_ result: MCTCatalogsParser.Release) throws -> Release {
let date: Date?
if let dateString = result.date {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
date = dateFormatter.date(from: dateString)
} else {
date = nil
}
guard let cabLink = result.cabLink else {
throw ESDCatalogError.missingElement("cabLink", "release")
}
let cabUrl: URL = URL(string: cabLink)!
return Release(build: result.build, date: date, cabUrl: cabUrl)
}
}
fileprivate class MCTCatalogsParser: NSObject, XMLParserDelegate {
enum Inside: String {
case productsDb
case versions
case version
case latestCabLink
case releases
case release
case date
case cabLink
}
struct Version {
var number: String
var latestCabLink: String?
var releases: [Release] = []
}
struct Release {
var build: String
var date: String?
var cabLink: String?
}
var continuation: CheckedContinuation<[Version], Error>?
private var inside: Inside?
private var versions: [Version] = []
private var currentVersion: Version?
private var currentRelease: Release?
func parserDidStartDocument(_ xmlParser: XMLParser) {
inside = nil
}
func parserDidEndDocument(_ xmlParser: XMLParser) {
if let c = continuation {
continuation = nil
c.resume(returning: versions)
}
}
func parser(_ xmlParser: XMLParser, parseErrorOccurred parseError: Error) {
if let c = continuation {
continuation = nil
c.resume(throwing: parseError)
}
xmlParser.abortParsing()
}
func parser(_ xmlParser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
switch inside {
case nil:
guard expect(elementName, named: .productsDb, in: xmlParser) else {
return
}
inside = .productsDb
case .productsDb:
guard expect(elementName, named: .versions, in: xmlParser) else {
return
}
inside = .versions
case .versions:
guard expect(elementName, named: .version, in: xmlParser) else {
return
}
guard let number = attributeDict["number"] else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.attributeNotFound("number"))
return
}
inside = .version
currentVersion = Version(number: number)
case .version:
guard let seen = expect(elementName, namedOneOf: [.latestCabLink, .releases], in: xmlParser) else {
return
}
inside = seen
case .releases:
guard expect(elementName, named: .release, in: xmlParser) else {
return
}
guard let build = attributeDict["build"] else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.attributeNotFound("build"))
return
}
inside = .release
currentRelease = Release(build: build)
case .release:
guard let seen = expect(elementName, namedOneOf: [.date, .cabLink], in: xmlParser) else {
return
}
inside = seen
default:
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
}
}
func parser(_ xmlParser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
guard elementName == inside?.rawValue else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return
}
switch inside! {
case .productsDb:
inside = nil
case .versions:
inside = .productsDb
case .version:
versions.append(currentVersion!)
currentVersion = nil
inside = .versions
case .latestCabLink, .releases:
inside = .version
case .release:
currentVersion!.releases.append(currentRelease!)
currentRelease = nil
inside = .releases
case .date, .cabLink:
inside = .release
}
}
func parser(_ xmlParser: XMLParser, foundCharacters string: String) {
if inside == .latestCabLink {
currentVersion!.latestCabLink = string
} else if inside == .date {
currentRelease!.date = string
} else if inside == .cabLink {
currentRelease!.cabLink = string
} else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.unexpectedString(string, inside?.rawValue ?? ""))
}
}
private func expect(_ elementName: String, named element: Inside, in xmlParser: XMLParser) -> Bool {
guard elementName == element.rawValue else {
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return false
}
return true
}
private func expect(_ elementName: String, namedOneOf elements: [Inside], in xmlParser: XMLParser) -> Inside? {
for element in elements {
if elementName == element.rawValue {
return element
}
}
parser(xmlParser, parseErrorOccurred: ESDCatalogError.invalidElement(elementName))
return nil
}
}

71
Source/EULAView.swift Normal file
View file

@ -0,0 +1,71 @@
//
// 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)
}
}
}
}
struct EULAView_Previews: PreviewProvider {
static var previews: some View {
EULAView(url: URL(string: "https://www.google.com/")!) {
}
}
}

View file

@ -19,11 +19,22 @@ import SwiftUI
@main
struct Main: App {
@StateObject private var worker = Worker()
@AppStorage("ShowAdvancedOptions") private var showAdvancedOptions: Bool = false
var body: some Scene {
WindowGroup {
WindowGroup(id: "ESDConvert") {
SimpleContentView().environmentObject(worker)
.frame(minWidth: 500, idealWidth: 500, minHeight: 300, idealHeight: 300)
}.handlesExternalEvents(matching: Set(["ESDConvert"]))
WindowGroup(id: "UUPDump") {
ContentView().environmentObject(worker)
.frame(minWidth: 800, minHeight: 400)
}
}.commands {
SidebarCommands()
CommandGroup(after: .sidebar) {
Toggle("Show Advanced Options", isOn: $showAdvancedOptions)
}
}.handlesExternalEvents(matching: Set(["UUPDump"]))
}
}

View file

@ -0,0 +1,64 @@
//
// 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 ShowWindowButtonView<Label: View>: View {
let id: String
let label: () -> Label
@Environment(\.dismiss) private var dismiss
var body: some View {
if #available(macOS 13, *) {
ShowWindowButtonViewNew(id: id, label: label)
} else {
Button(action: {
open(id: id)
dismiss()
}, label: label)
}
}
private func open(id: String) {
let url = URL(string: "crystalfetch://\(id)")!
NSWorkspace.shared.open(url)
}
}
@available(macOS 13, *)
private struct ShowWindowButtonViewNew<Label: View>: View {
let id: String
let label: () -> Label
@Environment(\.openWindow) private var openWindow
@Environment(\.dismiss) private var dismiss
var body: some View {
Button(action: {
openWindow(id: id)
dismiss()
}, label: label)
}
}
struct ShowWindowButtonView_Previews: PreviewProvider {
static var previews: some View {
ShowWindowButtonView(id: "UUPDump") {
Text("All builds…")
}
}
}

View file

@ -0,0 +1,257 @@
//
// 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
@AppStorage("ShowAdvancedOptions") private var showAdvancedOptions: Bool = false
@State private var isConfirmCancelShown: Bool = false
@State private var isDownloadCompleted: Bool = false
@State private var windowsVersion: MCTCatalogs.Windows = .windows11
@State private var selectedBuild: MCTCatalogs.Release?
@State private var selected: SelectedTuple = .default
@State private var selectedFile: ESDCatalog.File?
@State private var selectedEula: SelectedEULA?
@State private var languages: [DisplayString] = []
@State private var editions: [DisplayString] = []
private let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM-yyyy"
return dateFormatter
}()
var body: some View {
VStack {
Form {
Picker("Version", selection: $windowsVersion) {
Text("Windows® 11").tag(MCTCatalogs.Windows.windows11)
Text("Windows® 10").tag(MCTCatalogs.Windows.windows10)
}.pickerStyle(.radioGroup)
buildsPicker
Picker("Architecture", selection: $selected.architecture) {
if hasArchitecture("ARM64") {
Text("Apple Silicon").tag("ARM64")
}
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)
}
}
if windowsVersion == .windows10 && selected.architecture == "ARM64" {
Text("Note: This build does not work for virtualization on Apple Silicon.")
}
}.disabled(worker.isBusy)
.onChange(of: windowsVersion) { newValue in
if selectedBuild == nil {
worker.refreshEsdCatalog(windowsVersion: newValue)
} else {
selectedBuild = nil
}
}
.onChange(of: selectedBuild) { newValue in
worker.refreshEsdCatalog(windowsVersion: windowsVersion, release: newValue)
}
.onChange(of: worker.esdCatalog) { _ in
refreshList()
}
.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 {
if showAdvancedOptions {
ShowWindowButtonView(id: "UUPDump") {
Text("All builds…")
}.disabled(worker.isBusy)
.help("Build custom installation for any build through UUP Dump.")
}
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 = selectedFile?.eula {
selectedEula = SelectedEULA(url: URL(string: eula)!)
} else if let selectedFile = selectedFile {
worker.download(selectedFile)
}
} label: {
Text("Download…")
}.disabled(selectedFile == nil)
}
}
}
.sheet(item: $selectedEula) { eula in
EULAView(url: eula.url) {
if let selectedFile = selectedFile {
worker.download(selectedFile)
}
}
}
.alert(item: $worker.lastSeenError) { lastSeenError in
Alert(title: Text(lastSeenError.message))
}
.padding()
.onAppear {
worker.refreshCatalogUrls()
worker.refreshEsdCatalog()
refreshList()
}
.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
var buildsPicker: some View {
if let builds = worker.mctCatalogs[windowsVersion]?.releases {
Picker("Build", selection: $selectedBuild) {
Text("Latest").tag(nil as MCTCatalogs.Release?)
ForEach(builds) { build in
if let date = build.date {
Text("\(build.build) (\(dateFormatter.string(from: date)))").tag(build)
} else {
Text(build.build).tag(build)
}
}
}
}
}
@ViewBuilder
private var progressLabel: some View {
if let selectedFile = selectedFile {
Text(selectedFile.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))
selectedFile = 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(display)
}
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
}
}
struct SimpleContentView_Previews: PreviewProvider {
static var previews: some View {
SimpleContentView()
}
}

View file

@ -33,7 +33,11 @@ struct UUPDetails: Codable {
let values = try decoder.container(keyedBy: CodingKeys.self)
apiVersion = try values.decode(String.self, forKey: .apiVersion)
langList = try values.decode([String].self, forKey: .langList)
langFancyNames = try values.decode([String: String].self, forKey: .langFancyNames)
if langList.isEmpty {
langFancyNames = [:]
} else {
langFancyNames = try values.decode([String: String].self, forKey: .langFancyNames)
}
updateInfo = try values.decode(UpdateInfo.self, forKey: .updateInfo)
}
}
@ -70,9 +74,9 @@ extension UUPDetails {
arch = try values.decode(String.self, forKey: .arch)
build = try values.decode(String.self, forKey: .build)
checkBuild = try values.decode(String.self, forKey: .checkBuild)
sku = try values.decode(Int.self, forKey: .sku)
created = try values.decode(Int.self, forKey: .created)
sha256ready = try values.decode(Bool.self, forKey: .sha256ready)
sku = try values.decodeIfPresent(Int.self, forKey: .sku) ?? 0
created = try values.decodeIfPresent(Int.self, forKey: .created) ?? 0
sha256ready = try values.decodeIfPresent(Bool.self, forKey: .sha256ready) ?? false
}
}
}

View file

@ -35,6 +35,9 @@ class Worker: ObservableObject {
@Published private(set) var progress: Float?
@Published private(set) var progressStatus: String?
@Published var completedDownloadUrl: URL?
@Published var mctCatalogs: [MCTCatalogs.Windows: MCTCatalogs.Version] = [:]
@Published var esdCatalog: [ESDCatalog.File] = []
private let api = UUPDumpAPI()
private var runningTask: (Task<Void, Never>)?
@ -56,8 +59,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) {
@ -71,10 +83,25 @@ class Worker: ObservableObject {
}
}
func lookupPossibleLocale(fromBuildDetails buildDetails: UUPDetails, withPreferredLanguage language: String? = nil) -> String {
var decisionLocale = language ?? Self.defaultLocale ?? "netural"
if !buildDetails.langList.contains(decisionLocale) {
// unable to find this locale, use the English if possible
decisionLocale = "en-us"
}
if !buildDetails.langList.contains(decisionLocale) {
// unable to find the en-us, use the first value supported if possible
decisionLocale = buildDetails.langList.first ?? ""
}
// if still not possible, throw the error UNSUPPORTED_LANG from server side
return decisionLocale
}
func refreshDetails(uuid: String, language: String? = nil, _ onCompletion: @escaping (BuildDetails, BuildEditions) -> Void) {
withBusyIndication { [self] in
let detailsResponse = try await api.fetchDetails(for: uuid)
let editionsResponse = try await api.fetchEditions(for: uuid, language: language ?? defaultLocale)
let language = lookupPossibleLocale(fromBuildDetails: detailsResponse, withPreferredLanguage: language)
let editionsResponse = try await api.fetchEditions(for: uuid, language: language)
onCompletion(BuildDetails(from: detailsResponse), BuildEditions(from: editionsResponse))
}
}
@ -144,10 +171,14 @@ class Worker: ObservableObject {
}
private nonisolated func extractLine(from data: Data) -> String? {
if let string = String(data: data, encoding: .ascii)?.filter({ $0.isASCII }) {
if let string = String(data: data, encoding: .utf8) {
let lines = string.split(whereSeparator: \.isNewline)
if let line = lines.filter({ !$0.isEmpty }).last {
return String(line)
let stringLine = String(line)
if let pattern = try? NSRegularExpression(pattern: "\\033\\[(0|1).*?m") {
return pattern.stringByReplacingMatches(in: stringLine, range: NSRange(location: 0, length: stringLine.count), withTemplate: "")
}
return stringLine
}
}
return nil
@ -155,16 +186,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
}
@ -173,7 +210,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
}
@ -209,7 +246,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) {
@ -260,6 +309,142 @@ 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")!
}
private var worprojectUrl: URL {
URL(string: "https://worproject.com/dldserv/esd/getversions.php")!
}
func refreshCatalogUrls() {
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let catalogsUrl = cacheUrl.appendingPathComponent("mctcatalogs.xml")
withBusyIndication { [self] in
let fm = FileManager.default
let downloader = Downloader()
try? fm.removeItem(at: catalogsUrl)
await downloader.enqueue(downloadUrl: worprojectUrl, to: catalogsUrl)
do {
try await downloader.start()
let data = try Data(contentsOf: catalogsUrl)
let catalogs = try await MCTCatalogs(from: data)
mctCatalogs = await catalogs.versions
} catch {
NSLog("Ignoring error while trying to fetch MCT catalogs: %@", error.localizedDescription)
}
}
}
func refreshEsdCatalog(windowsVersion: MCTCatalogs.Windows = .windows11, release: MCTCatalogs.Release? = nil) {
let cacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let catalogUrl = cacheUrl.appendingPathComponent("catalog.cab")
let productsUrl = cacheUrl.appendingPathComponent("products.xml")
withBusyIndication { [self] in
let fm = FileManager.default
let downloader = Downloader()
try? fm.removeItem(at: catalogUrl)
// if we specify a release, then use it
if let release = release {
await downloader.enqueue(downloadUrl: release.cabUrl, to: catalogUrl)
try await downloader.start()
} else {
// next try hard coded cab url
if windowsVersion == .windows10 {
await downloader.enqueue(downloadUrl: windows10CatalogUrl, to: catalogUrl)
} else {
await downloader.enqueue(downloadUrl: windows11CatalogUrl, to: catalogUrl)
}
do {
try await downloader.start()
} catch {
// finally, see if we got a new latest cab url
if let url = mctCatalogs[windowsVersion]?.latestCabUrl {
await downloader.enqueue(downloadUrl: url, to: catalogUrl)
try await downloader.start()
} else {
throw error // otherwise we throw the original error
}
}
}
try await 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 uuid = file.sha1.count > 0 ? file.sha1 : UUID().uuidString
let baseUrl = cacheUrl.appendingPathComponent(uuid)
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, "-v", 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)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,78 @@
/* Sources */
// ESDCatalog/ESDCatalog.swift
"Unknown parser error." = "不明な解析エラーです。";
"Failed to parse element '%@'" = "要素“%@”の解析に失敗しました";
"Attribute '%@' not found" = "属性“%@”が見つかりません";
"Unexpected string '%@' for field '%@'" = "フィールド“%2$@”に予期しない文字列“%1$@”があります";
"Localization '%@' not found" = "ローカリゼーション“%@”が見つかりません";
"Missing element '%@' in '%@'" = "“%2$@”に要素“%1$@”がありません";
// UUPDump/UUPDumpAPI.swift
"Cannot find data from the server response." = "サーバの応答からデータが見つかりません。";
"Error returned from server: %@" = "サーバからエラーが返されました: %@";
// UUPDump/PrettyString.swift
"Intel x86" = "Intel32ビット";
"Intel x64" = "Intel64ビット";
"Apple Silicon" = "Appleシリコン";
"Unknown" = "不明";
"Development" = "Dev";
"Beta" = "Beta";
"Canary" = "Canary";
"Release Preview" = "Release Preview";
"Retail" = "Retail";
// Main.swift
"Show Advanced Options" = "詳細オプションを表示";
// ContentView.swift
"Prerelease Builds" = "プレリリースビルド";
"Show unstable releases which previews upcoming features." = "今後の機能をプレビューする不安定なリリースを表示します。";
"Server Builds" = "サーバビルド";
"Show builds for running in a server environment." = "サーバ環境で実行するためのビルドを表示します。";
"Simple…" = "シンプル…";
"Build installation for the latest release through ESD conversion." = "ESD変換により最新リリースのインストールをビルドします。";
"Refresh" = "更新";
// SimpleContentView.swift
"Version" = "バージョン";
"Windows® 11" = "Windows® 11";
"Windows® 10" = "Windows® 10";
"Architecture" = "アーキテクチャ";
"Language" = "言語";
"Edition" = "エディション";
"Note: This build does not work for virtualization on Apple Silicon." = "注意: このビルドはAppleシリコン上の仮想化では動作しません。";
"All builds…" = "すべてのビルド…";
"Build custom installation for any build through UUP Dump." = "UUP dumpにより任意のビルドのカスタムインストールをビルドします。";
"Cancel" = "キャンセル";
"Are you sure you want to stop the process?" = "処理を中止してもよろしいですか?";
"Stop" = "中止";
"Download…" = "ダウンロード…";
"No build found." = "ビルドが見つかりません。";
// EULAView.swift
"Failed to load EULA." = "使用許諾契約の読み込みに失敗しました。";
"Accept" = "同意";
// BuildConfigView.swift
"Channel" = "チャネル";
"Build" = "ビルド";
"Created" = "作成日時";
"Editions" = "エディション";
"I agree that I have a valid license to use this product." = "この製品を使用するための有効なライセンスを所有していることに同意します。";
// BuildDetails.swift
"Unknown Language" = "不明な言語";
// BuildEditions.swift
"Unknown Edition" = "不明なエディション";
// Worker.swift
"Fetching files list..." = "ファイルリストを取得中…";
"Starting download..." = "ダウンロードを開始中…";
"Downloading %@ of %@..." = "ダウンロード中…(%1$@ / %2$@";
"Converting download to ISO..." = "ダウンロードをISOに変換中…";
"Saving ISO..." = "ISOを保存中…";
"The conversion script failed with error code %d" = "変換スクリプトはエラーコード%dで失敗しました";

View file

@ -0,0 +1,161 @@
/* No comment provided by engineer. */
"Accept" = "接受";
/* No comment provided by engineer. */
"All builds…" = "所有構建⋯";
/* PrettyString */
"Apple Silicon" = "Apple 晶片";
/* No comment provided by engineer. */
"Architecture" = "架構";
/* No comment provided by engineer. */
"Are you sure you want to stop the process?" = "要停止這個程序嗎?";
/* ESDCatalog */
"Attribute '%@' not found" = "未找到屬性「%@」";
/* PrettyString */
"Beta" = "Beta";
/* No comment provided by engineer. */
"Build" = "構建";
/* No comment provided by engineer. */
"Build custom installation for any build through UUP Dump." = "透過 UUP Dump 為任意構建製作自定安裝映像檔。";
/* No comment provided by engineer. */
"Build installation for the latest release through ESD conversion." = "透過 ESD 轉換為最新的 Release 構建安裝映像檔。";
/* PrettyString */
"Canary" = "Canary";
/* No comment provided by engineer. */
"Cancel" = "取消";
/* UUPDumpAPI */
"Cannot find data from the server response." = "無法由伺服器回應找到資料。";
/* No comment provided by engineer. */
"Channel" = "頻道";
/* Worker */
"Converting download to ISO..." = "正在轉換下載的資料至 ISO⋯";
/* No comment provided by engineer. */
"Created" = "製作時間";
/* PrettyString */
"Development" = "Development";
/* No comment provided by engineer. */
"Download…" = "下載⋯";
/* Worker */
"Downloading %@ of %@..." = "正在下載 %1$@ / %2$@⋯";
/* No comment provided by engineer. */
"Edition" = "版本";
/* No comment provided by engineer. */
"Editions" = "版本";
/* UUPDumpAPI */
"Error returned from server: %@" = "伺服器返回錯誤:%@";
/* EULAView */
"Failed to load EULA." = "無法載入 EULA。";
/* ESDCatalog */
"Failed to parse element '%@'" = "無法解析元素「%@」";
/* Worker */
"Fetching files list..." = "正在獲取檔案清單⋯";
/* No comment provided by engineer. */
"I agree that I have a valid license to use this product." = "我同意我擁有使用這個產品的有效許可。";
/* PrettyString */
"Intel x64" = "Intel x64";
/* PrettyString */
"Intel x86" = "Intel x86";
/* No comment provided by engineer. */
"Language" = "語言";
/* ESDCatalog */
"Localization '%@' not found" = "無法找到本地化項目「%@」";
/* ESDCatalog */
"Missing element '%@' in '%@'" = "「%2$@」中遺失元素「%1$@」";
/* No comment provided by engineer. */
"No build found." = "無法找到構建。";
/* No comment provided by engineer. */
"Note: This build does not work for virtualization on Apple Silicon." = "注意:這個構建不適用於 Apple 晶片上的虛擬化。";
/* No comment provided by engineer. */
"Prerelease Builds" = "預先發布構建";
/* No comment provided by engineer. */
"Refresh" = "重新整理";
/* PrettyString */
"Release Preview" = "Release Preview";
/* PrettyString */
"Retail" = "Retail";
/* Worker */
"Saving ISO..." = "正在儲存 ISO⋯";
/* No comment provided by engineer. */
"Server Builds" = "伺服器構建";
/* No comment provided by engineer. */
"Show Advanced Options" = "顯示進階選項";
/* No comment provided by engineer. */
"Show builds for running in a server environment." = "顯示用於伺服器環境運行的構建。";
/* No comment provided by engineer. */
"Show unstable releases which previews upcoming features." = "顯示不穩定的發行版本,當中可以預覽即將推出的功能。";
/* No comment provided by engineer. */
"Simple…" = "簡化⋯";
/* Worker */
"Starting download..." = "正在開始下載⋯";
/* No comment provided by engineer. */
"Stop" = "停止";
/* Worker */
"The conversion script failed with error code %d" = "無法執行轉換,錯誤碼為 %d";
/* ESDCatalog */
"Unexpected string '%@' for field '%@'" = "「%2$@」欄位當中出現意外字符串「%1$@」";
/* PrettyString */
"Unknown" = "未知";
/* BuildEditions */
"Unknown Edition" = "未知的版本";
/* BuildDetails */
"Unknown Language" = "未知的語言";
/* ESDCatalog */
"Unknown parser error." = "未知的解析器錯誤。";
/* No comment provided by engineer. */
"Version" = "版本";
/* No comment provided by engineer. */
"Windows® 10" = "Windows® 10";
/* No comment provided by engineer. */
"Windows® 11" = "Windows® 11";

View file

@ -0,0 +1,161 @@
/* No comment provided by engineer. */
"Accept" = "接受";
/* No comment provided by engineer. */
"All builds…" = "全部版本…";
/* PrettyString */
"Apple Silicon" = "Apple 芯片";
/* No comment provided by engineer. */
"Architecture" = "架构";
/* No comment provided by engineer. */
"Are you sure you want to stop the process?" = "你确定要停止此进程吗?";
/* ESDCatalog */
"Attribute '%@' not found" = "属性“%@”未找到";
/* PrettyString */
"Beta" = "Beta";
/* No comment provided by engineer. */
"Build" = "构建版本";
/* No comment provided by engineer. */
"Build custom installation for any build through UUP Dump." = "通过 UUP Dump 为任何构建版本创建自定义安装程序。";
/* No comment provided by engineer. */
"Build installation for the latest release through ESD conversion." = "通过 ESD 转换为最新版本构建安装程序。";
/* PrettyString */
"Canary" = "Canary";
/* No comment provided by engineer. */
"Cancel" = "取消";
/* UUPDumpAPI */
"Cannot find data from the server response." = "无法从服务器响应中找到数据。";
/* No comment provided by engineer. */
"Channel" = "通道";
/* Worker */
"Converting download to ISO..." = "正在将下载转换为 ISO…";
/* No comment provided by engineer. */
"Created" = "创建时间";
/* PrettyString */
"Development" = "开发";
/* No comment provided by engineer. */
"Download…" = "下载…";
/* Worker */
"Downloading %@ of %@..." = "正在下载 %1$@,共 %2$@…";
/* No comment provided by engineer. */
"Edition" = "版本";
/* No comment provided by engineer. */
"Editions" = "版本";
/* UUPDumpAPI */
"Error returned from server: %@" = "服务器返回错误:%@";
/* EULAView */
"Failed to load EULA." = "无法加载 EULA (最终用户许可协议)。";
/* ESDCatalog */
"Failed to parse element '%@'" = "无法解析元素“%@”";
/* Worker */
"Fetching files list..." = "获取文件列表…";
/* No comment provided by engineer. */
"I agree that I have a valid license to use this product." = "我同意我拥有使用此产品的有效许可证。";
/* PrettyString */
"Intel x64" = "Intel x64";
/* PrettyString */
"Intel x86" = "Intel x86";
/* No comment provided by engineer. */
"Language" = "语言";
/* ESDCatalog */
"Localization '%@' not found" = "本地化条目“%@”未找到";
/* ESDCatalog */
"Missing element '%@' in '%@'" = "“%2$@”中缺少元素“%1$@”";
/* No comment provided by engineer. */
"No build found." = "未找到构建版本。";
/* No comment provided by engineer. */
"Note: This build does not work for virtualization on Apple Silicon." = "注意:此版本无法在 Apple 芯片上用于虚拟化。";
/* No comment provided by engineer. */
"Prerelease Builds" = "预发行构建版本";
/* No comment provided by engineer. */
"Refresh" = "刷新";
/* PrettyString */
"Release Preview" = "发行预览版";
/* PrettyString */
"Retail" = "零售版";
/* Worker */
"Saving ISO..." = "正在保存 ISO…";
/* No comment provided by engineer. */
"Server Builds" = "服务器构建版本";
/* No comment provided by engineer. */
"Show Advanced Options" = "显示高级选项";
/* No comment provided by engineer. */
"Show builds for running in a server environment." = "显示在服务器环境中运行的构建版本。";
/* No comment provided by engineer. */
"Show unstable releases which previews upcoming features." = "显示不稳定的发行版本,其中可预览即将推出的功能。";
/* No comment provided by engineer. */
"Simple…" = "简单模式…";
/* Worker */
"Starting download..." = "开始下载…";
/* No comment provided by engineer. */
"Stop" = "停止";
/* Worker */
"The conversion script failed with error code %d" = "转换脚本失败,错误代码为 %d";
/* ESDCatalog */
"Unexpected string '%@' for field '%@'" = "字段“%2$@”中存在意外字符串“%1$@”";
/* PrettyString */
"Unknown" = "未知";
/* BuildEditions */
"Unknown Edition" = "未知版本";
/* BuildDetails */
"Unknown Language" = "未知语言";
/* ESDCatalog */
"Unknown parser error." = "未知的解析器错误。";
/* No comment provided by engineer. */
"Version" = "版本";
/* No comment provided by engineer. */
"Windows® 10" = "Windows® 10";
/* No comment provided by engineer. */
"Windows® 11" = "Windows® 11";

View file

@ -0,0 +1,3 @@
/* Bundle name */
"CFBundleName" = "CrystalFetch";

View file

@ -0,0 +1,156 @@
/* No comment provided by engineer. */
"Accept" = "接受";
/* No comment provided by engineer. */
"All builds…" = "所有組建…";
/* PrettyString */
"Apple Silicon" = "Apple Silicon";
/* No comment provided by engineer. */
"Architecture" = "系統架構";
/* No comment provided by engineer. */
"Are you sure you want to stop the process?" = "您確定要停止程序嗎?";
/* ESDCatalog */
"Attribute '%@' not found" = "找不到「%@」屬性";
/* PrettyString */
"Beta" = "Beta版通道";
/* No comment provided by engineer. */
"Build" = "組建";
/* No comment provided by engineer. */
"Build custom installation for any build through UUP Dump." = "透過UUP Dump建構任何組建的自訂安裝程式。";
/* No comment provided by engineer. */
"Build installation for the latest release through ESD conversion." = "透過ESD轉換建構最新發行版本的安裝程式。";
/* PrettyString */
"Canary" = "Canary版通道";
/* No comment provided by engineer. */
"Cancel" = "取消";
/* UUPDumpAPI */
"Cannot find data from the server response." = "從伺服器回應中無法找到資料。";
/* No comment provided by engineer. */
"Channel" = "頻道";
/* Worker */
"Converting download to ISO..." = "正在將下載的資料轉換成ISO……";
/* No comment provided by engineer. */
"Created" = "建立於";
/* PrettyString */
"Development" = "Dev版通道";
/* No comment provided by engineer. */
"Download…" = "下載…";
/* Worker */
"Downloading %@ of %@..." = "已下載%1$@(共%2$@)……";
/* No comment provided by engineer. */
"Edition" = "版本";
/* No comment provided by engineer. */
"Editions" = "版本";
/* UUPDumpAPI */
"Error returned from server: %@" = "伺服器回傳錯誤:%@";
/* EULAView */
"Failed to load EULA." = "無法載入EULA。";
/* ESDCatalog */
"Failed to parse element '%@'" = "無法解析「%@」元素";
/* Worker */
"Fetching files list..." = "正在抓取檔案清單……";
/* No comment provided by engineer. */
"I agree that I have a valid license to use this product." = "我確定我有使用本產品的合法授權。";
/* PrettyString */
"Intel x64" = "Intel x64";
/* PrettyString */
"Intel x86" = "Intel x86";
/* No comment provided by engineer. */
"Language" = "語言";
/* ESDCatalog */
"Localization '%@' not found" = "找不到「%@」本地化資料";
/* ESDCatalog */
"Missing element '%@' in '%@'" = "「%2$@」中缺少「%1$@」元素";
/* No comment provided by engineer. */
"No build found." = "找不到組建。";
/* No comment provided by engineer. */
"Prerelease Builds" = "預先發布組建";
/* No comment provided by engineer. */
"Refresh" = "重新整理";
/* PrettyString */
"Release Preview" = "發行預覽通道";
/* PrettyString */
"Retail" = "零售版本通道";
/* Worker */
"Saving ISO..." = "正在儲存ISO……";
/* No comment provided by engineer. */
"Server Builds" = "伺服器組建";
/* No comment provided by engineer. */
"Show builds for running in a server environment." = "顯示用來在伺服器環境運作的組建。";
/* No comment provided by engineer. */
"Show unstable releases which previews upcoming features." = "顯示不穩定的發行版本,可以預覽即將推出的功能。";
/* No comment provided by engineer. */
"Simple…" = "簡單…";
/* Worker */
"Starting download..." = "開始下載……";
/* No comment provided by engineer. */
"Stop" = "停止";
/* Worker */
"The conversion script failed with error code %d" = "轉換文稿執行失敗,錯誤碼 %d";
/* ESDCatalog */
"Unexpected string '%@' for field '%@'" = "「%2$@」欄位中的「%1$@」字串不符預期";
/* PrettyString */
"Unknown" = "不明";
/* BuildEditions */
"Unknown Edition" = "不明版本";
/* BuildDetails */
"Unknown Language" = "不明語言";
/* ESDCatalog */
"Unknown parser error." = "不明的解析器錯誤。";
/* No comment provided by engineer. */
"Version" = "版本";
/* No comment provided by engineer. */
"Windows® 10" = "Windows® 10";
/* No comment provided by engineer. */
"Windows® 11" = "Windows® 11";

@ -1 +1 @@
Subproject commit ae44786dcd57c6668233c51fa1c6e3a60fd459cd
Subproject commit 4baa0ab7b8ad5c7f0a109e22e9a6c8621a7edd42