Compare commits

...
8 Commits
25 changed files with 1819 additions and 700 deletions

No files matched your search

+4
View File
@@ -21,5 +21,9 @@ jobs:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
+19 -59
View File
@@ -9,9 +9,19 @@ permissions:
contents: write
jobs:
dev-check-update:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- uses: actions/checkout@v5
- name: Set up JDK
@@ -22,7 +32,7 @@ jobs:
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
@@ -34,66 +44,16 @@ jobs:
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/dev.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
if: ${{ env.continue == 'true' }}
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTE
files: |
HMCL-${{ env.HMCL_VERSION }}.exe
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.sh
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
stable-check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
- name: Check for existing tags
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/stable.html" >> RELEASE_NOTE
if [ "${{ matrix.channel }}" = "stable" ]; then
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
fi
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/${{ matrix.channel }}.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
+6 -2
View File
@@ -19,8 +19,12 @@ jobs:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Build with Gradle
run: ./gradlew build --no-daemon
run: ./gradlew build --no-daemon --parallel
env:
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
@@ -47,4 +51,4 @@ jobs:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
HMCL/build/libs/HMCL-*.sh.sha256
@@ -20,7 +20,6 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
@@ -38,18 +37,15 @@ import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.*;
import java.net.Proxy;
import java.nio.file.Path;
import java.util.*;
@JsonAdapter(value = Config.Adapter.class)
public final class Config implements Observable {
public final class Config extends ObservableSetting {
public static final int CURRENT_VERSION = 2;
public static final int CURRENT_UI_VERSION = 0;
@@ -67,54 +63,15 @@ public final class Config implements Observable {
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
private static final List<ObservableField<Config>> FIELDS;
static {
final MethodHandles.Lookup lookup = MethodHandles.lookup();
Field[] fields = Config.class.getDeclaredFields();
var configFields = new ArrayList<ObservableField<Config>>(fields.length);
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers))
continue;
configFields.add(ObservableField.of(lookup, field));
}
FIELDS = List.copyOf(configFields);
}
@Nullable
public static Config fromJson(String json) throws JsonParseException {
return CONFIG_GSON.fromJson(json, Config.class);
}
private transient final ObservableHelper helper = new ObservableHelper(this);
private transient final DirtyTracker tracker = new DirtyTracker();
private transient final Map<String, JsonElement> unknownFields = new HashMap<>();
public Config() {
var shouldBeWrite = Collections.<Observable>newSetFromMap(new IdentityHashMap<>());
Collections.addAll(shouldBeWrite, configVersion, uiVersion);
for (var field : FIELDS) {
Observable observable = field.get(this);
if (shouldBeWrite.contains(observable))
tracker.markDirty(observable);
else
tracker.track(observable);
observable.addListener(helper);
}
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
tracker.markDirty(configVersion);
tracker.markDirty(uiVersion);
register();
}
public String toJson() {
@@ -775,53 +732,10 @@ public final class Config implements Observable {
return configurations;
}
public static final class Adapter implements JsonSerializer<Config>, JsonDeserializer<Config> {
public static final class Adapter extends ObservableSetting.Adapter<Config> {
@Override
public JsonElement serialize(Config config, Type typeOfSrc, JsonSerializationContext context) {
if (config == null)
return JsonNull.INSTANCE;
JsonObject result = new JsonObject();
for (var field : FIELDS) {
Observable observable = field.get(config);
if (config.tracker.isDirty(observable)) {
field.serialize(result, config, context);
}
}
config.unknownFields.forEach(result::add);
return result;
}
@Override
public Config deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json.isJsonNull())
return null;
if (!json.isJsonObject())
throw new JsonParseException("Config is not an object: " + json);
Config config = new Config();
var values = new LinkedHashMap<>(json.getAsJsonObject().asMap());
for (ObservableField<Config> field : FIELDS) {
JsonElement value = values.remove(field.getSerializedName());
if (value == null) {
for (String alternateName : field.getAlternateNames()) {
value = values.remove(alternateName);
if (value != null)
break;
}
}
if (value != null) {
config.tracker.markDirty(field.get(config));
field.deserialize(config, value, context);
}
}
config.unknownFields.putAll(values);
return config;
protected Config createInstance() {
return new Config();
}
}
}
@@ -250,7 +250,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
twoLineListItem.addTag(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
}
case PENDING, SNAPSHOT -> {
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("version.game.april_fools"));
@@ -455,7 +455,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
return switch (filter) {
case RELEASE -> versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING;
|| versionType == RemoteVersion.Type.PENDING
|| versionType == RemoteVersion.Type.UNOBFUSCATED;
case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD -> versionType == RemoteVersion.Type.OLD;
@@ -112,7 +112,14 @@ public final class Versions {
JFXButton deleteButton = new JFXButton(i18n("button.delete"));
deleteButton.getStyleClass().add("dialog-error");
deleteButton.setOnAction(e -> profile.getRepository().removeVersionFromDisk(version));
deleteButton.setOnAction(e -> {
Task.supplyAsync(Schedulers.io(), () -> profile.getRepository().removeVersionFromDisk(version))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || !Boolean.TRUE.equals(result)) {
Controllers.dialog(i18n("version.manage.remove.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}).start();
});
Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton);
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.translator.Translator_lzh;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -79,6 +80,8 @@ public final class MinecraftWiki {
if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
if (wikiVersion.equals("22w13oneblockatatime"))
wikiVersion = "22w13oneBlockAtATime";
else
wikiVersion = StringUtils.removeSuffix(wikiVersion, "_unobfuscated");
return locale.i18n("wiki.version.game.snapshot", wikiVersion) + variantSuffix;
}
}
@@ -1569,6 +1569,7 @@ version.manage.redownload_assets_index=Update Game Assets
version.manage.remove=Delete Instance
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s".
version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"?
version.manage.remove.failed=Failed to delete the instance. Some files might be in use.
version.manage.remove_assets=Delete All Assets
version.manage.remove_libraries=Delete All Libraries
version.manage.rename=Rename Instance
@@ -1353,6 +1353,7 @@ version.manage.redownload_assets_index=更新遊戲資源檔案
version.manage.remove=刪除該實例
version.manage.remove.confirm.trash=真的要刪除實例「%s」嗎? 你可以在系統的資源回收筒 (或垃圾桶) 中還原目錄「%s」來找回該實例。
version.manage.remove.confirm.independent=由於該實例啟用了「(全域/實例特定) 遊戲設定 → 執行路徑 → 各實例獨立」設定,刪除該實例將導致該遊戲的存檔等資料一同被刪除!真的要刪除實例「%s」嗎?
version.manage.remove.failed=刪除實例失敗。可能檔案被占用。
version.manage.remove_assets=刪除所有遊戲資源檔案
version.manage.remove_libraries=刪除所有支援庫檔案
version.manage.rename=重新命名該實例
@@ -1363,6 +1363,7 @@ version.manage.redownload_assets_index=更新游戏资源文件
version.manage.remove=删除该实例
version.manage.remove.confirm.trash=真的要删除实例“%s”吗?你可以在系统的回收站中还原“%s”文件夹来找回该实例。
version.manage.remove.confirm.independent=由于该游戏启用了“(全局/实例特定) 游戏设置 → 版本隔离 → 各实例独立”选项,删除该实例将导致该游戏的存档等数据一同被删除!真的要删除实例“%s”吗?
version.manage.remove.failed=删除实例失败。可能文件被占用。
version.manage.remove_assets=删除所有游戏资源文件
version.manage.remove_libraries=删除所有库文件
version.manage.rename=重命名该实例
File diff suppressed because it is too large. Load diff
@@ -548,7 +548,7 @@ gregic-skies;555;;Gregic Skies;;
;557;;齿轮颂歌:暮光;Gear Carol;
pluma;558;;Pluma, a Journey to the Future!;;Pluma
all-the-mods-7-to-the-sky;559;;All the Mods 7 - To the Sky;;ATM7S
;560;;月见苍穹传说:大地演化;TskimiCanopyCraft: Terra Evolution;TSCC:TE
;560;;月见苍穹传说;TskimiCanopyCraft;TSCC
;561;;最后的战役:劫后余生;The Last Stand:Aftermath;LSA/TLS
;562;;小猪配平;Peppa Pig's Burning Broom;2P2B
zerblands-remastered;563;;Zerblands-Remastered;;
@@ -623,7 +623,7 @@ crop-block;632;;Crop Block;;
;634;;Technical Electrical;;
cobblemon-forge;635;;Cobblemon Official Modpack;;
sbeevs-industrial-revolution;636;;Sbeev's Industrial Revolution;;
;637;;Gensokyo Reimagined QOL;;
;637;;Gensokyo Reimagined QOL;;GR
statech-industry;638;;StaTech Industry;;
vault-hunters-1-18-2;639;;宝藏猎人3;Vault Hunters 3rd Edition;
survival-hard-rock;640;;生存硬摇滚;Survival Hard Rock;
@@ -791,7 +791,7 @@ craftingcraft;800;;合成工艺;CraftingCraft;
chemillas-reborn;804;;化学:重生;Chemilla's Reborn;CR
;805;;群峦传说·异世界行纪;Terra Firma Craft:Travel in Another World;TFC:TAW
milkyway;806;;Create: Milkyway;;
farming-crossing-4;807;;Farming Crossing 4;;
farming-crossing-4;807;;农业森友会 4;Farming Crossing 4;
hardrock-terrafirmacraft-4-realistic-extreme;808;;HardRock TerraFirmaCraft;;TFCH/HTFC
terrafirma-rebirth;809;;群峦:重生;TerraFirma: Rebirth;TFCR
create-live-5-skyblock;810;;Create Live 5;;
@@ -868,7 +868,7 @@ aeon-automata;881;;Aeon Automata;;AA
;882;;被恐惧支配2:收容失效;Dominated by Fear 2 Containment Breach;DoF2
;883;;剑拔弩张之时;;TC
project-architect-2;884;;Project Architect 2;;
;885;;路网通途 / 更好的我的世界铁路;Railway Network Access;RNA
;885;;路网通途 / 接入铁路网络;Railway Network Access;RNA
create-mekanized;886;;Create: Mekanized;;
create-perfect-world-2;887;;Create: Perfect World 2;;
create-stellar;888;;Create Stellar;;
@@ -1048,7 +1048,7 @@ beyond-depth;1062;;Beyond Depth;;
;1064;;真幻世界;Realfantacy World;R.F.
minenufactory;1065;;创世工厂;Minenufactory;MF
craft-to-exile-2;1066;;放逐之路2;Craft to Exile 2 (VR Support);CTE2
;1067;;龙境III:菌土重来;Dragon Odyssey:Return of the Fungal Earth;DO3
;1067;;龙境III:菌土重来;Dragon Odyssey: Fungreda;DO3
the-culinary-journey;1068;;美食的旅途;The Culinary Journey;TCJ
;1069;;缥缈方舟;;PMFZ
;1070;;东方匠魂传;Touhou Tinker Fantasy;TTF
@@ -1075,7 +1075,7 @@ roguelike-adventures-and-dungeons-3;1090;;冒险与地牢3;Roguelike Adventures
;1092;;玄理2:洗剪版;EnigTech 2 - Dishwasher's Cut;ET2DC
wheelchair-gregtech;1093;;Wheelchair Gregtech;;
egged-up;1094;;Egged Up;;
;1095;;香草纪元:食旅纪行;VanillaEra: FaresChron;
;1095;;香草纪元:食旅纪行;VanillaEra: FaresChron;VEFC
;1096;;愚者;The Fool;TF
;1097;;时间之书2:星梦指南;BookofTime II Stardream Compass;BKSC
;1098;;群峦之上:无垠伊土;EndlessFirma;EnF
@@ -1131,7 +1131,7 @@ endless-horror-the-ultimate-horror-modpack;1146;;Endless Horror - The ULTIMATE H
picky;1148;;Picky;;
resource-factory;1149;;资源工厂;Resource Factory;
stacia-2-expert;1150;;Stacia 2 Expert;;S2E
;1151;;田园乐事;;
;1151;;田园乐事;Conutryside_Delight;
;1152;;世界之旅;Journey of the world;JOTW
;1153;;清新优化;Fresh optimization;FRO
;1154;;遗世之匠;Touhou:The Forgotten Swordsmith;
@@ -1159,8 +1159,7 @@ society-sunlit-valley;1176;;Society: Sunlit Valley;;
ciscos-fantasy-medieval-rpg-dragonfyre;1177;;Cisco's Fantasy Medieval RPG [Dragonfyre];;
all-the-mods-10-sky;1178;;All the Mods 10: To the Sky;;ATM10S
;1179;;谐伴匠途;;
the-cursed-wanderer-reborn;1180;;咒游诸界:重生;The Cursed Wanderer: Reborn;TCWR
;1181;;Turbo Vulkan;;TuVk
;1181;;Turbo Vulkan;;TV
uls-zsurvival-craft;1182;;Z-Liminex;;
;1183;;MITE 重生 3;MITE Reborn 3;MR
reclamation-reclaim-the-world;1184;;Reclamation - Reclaim the World!;;
@@ -1179,3 +1178,49 @@ pvzw;1195;;植物大战僵尸:世界;Plants VS Zombies : World;PVZW
new-century-adventure;1197;;新世纪之险;New Century Adventure;NCA
repalmon;1198;;Re帕鲁梦;;RPLM
structural-ingenuity;1199;;结构型奇思妙想;Structural Ingenuity;
arcaneaxis-ars;1200;;秘法象限;ArcaneAxis;ARS
gregtech-easy;1201;;格雷科技简单版;GregTech Easy;GTE
;1202;;Creation Exploring the Road of;;CETR
multiblockful-skies;1203;;Multiblockful Skies;;
;1204;;刀剑物语;SwordStory;SS
;1205;;无垠征途;The Unbounded Expedition;TUE
;1206;;云栖谧土;Nephosia;
custom-battleroyale-complete;1207;;完整自定义大逃杀;Custome BattleRoyale Complete;CBRC
paradise-of-lucky-blocks;1208;;Paradise of Lucky Blocks;;
;1209;;极致优化;Xavier Optimized;XO
;1210;;神秘时代4花园;Thaumcraft IV Sky Islands;
;1211;;弥散往生;Diffusion: Soulfare;
mana-metal-new-horizons;1212;;魔法金屬:新視野;Mana Metal: New Horizons;M3NH
farmy-skyblock;1213;;Farmy Skyblock;;
bloody-skyblock-modpack;1214;;Bloody Skyblock;;
starages;1215;;Star Space Ages;;
create-spectral-cuisine;1216;;Create: Spectral Cuisine;;
project-infinity-0-1;1217;;Project Infinity 0.1;;
create-live-6;1218;;Create Live 6;;
;1219;;全面包围-硬核惊变;Full encirclement;FLET
;1220;;科波菲尔;Copperfield;CPF
;1221;;试用核心/I.O.P的水槽基础整合;Trial Essential;
tnp-limitless-8;1222;;TNP Limitless 8;;
;1223;;重度机械症;Mechanomania;
;1224;;寻此苦旅3;Per Aspera3;PA3
calamity-rpg-iii-wounds-of-the-world;1225;;Calamity RPG III : Wounds of the World;;
;1226;;烛枫公会优化整合包;;ZFGH
legends-reborn-medieval;1227;;Legends Reborn: Medieval;;LRM
;1228;;双衰变;;DEGENE
nd-new-dawn;1229;;New Dawn;;ND
;1230;;星遥优化;StarCarefree Optimized;SO
;1231;;寄生仙途;;JSXT
;1232;;神秘启旅;Thaum Journey;
nightfallcraft-the-casket-of-reveries;1233;;远梦之棺;NightfallCraft - The Casket of Reveries;
openworld-battleroyale;1234;;开放世界大逃杀;Openworld BattleRoyale;OBR
;1235;;未尽之路;Unfinished Path;UP
;1236;;命运齿轮;Fate's Machinery;FOM
linggango;1237;;Linggango;;
create-industrialized-tech;1238;;Create: Industrialized Tech;;
;1239;;灵璟铁路都市;LingJing Train City;LJTC
erstwhile-journey-2;1240;;往昔之旅2;Erstwhile Journey 2;EJ2
;1241;;诡厄:使徒;;
;1242;;时间之书 : 星之旅行-重制版;BookofTime StarTravel;BKSL
cook-in-skyland-elegant-feast;1243;;天空厨房:典雅盛宴;Cook in Skyland Elegant Feast;CiS2
ftb-stoneblock-4;1244;;FTB Stoneblock 4;;SB4
techevdiscovery;1245;;TechEv || Discovery;;
@@ -128,6 +128,7 @@ public class RemoteVersion implements Comparable<RemoteVersion> {
RELEASE,
SNAPSHOT,
OLD,
PENDING
PENDING,
UNOBFUSCATED,
}
}
@@ -73,6 +73,7 @@ public final class GameRemoteVersion extends RemoteVersion {
case SNAPSHOT -> Type.SNAPSHOT;
case UNKNOWN -> Type.UNCATEGORIZED;
case PENDING -> Type.PENDING;
case UNOBFUSCATED -> Type.UNOBFUSCATED;
default -> Type.OLD;
};
}
@@ -28,6 +28,7 @@ public enum ReleaseType {
OLD_BETA("old-beta"),
OLD_ALPHA("old-alpha"),
PENDING("pending"),
UNOBFUSCATED("unobfuscated"),
UNKNOWN("unknown");
private final String id;
@@ -1,232 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.gson;
import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.ListProperty;
import javafx.beans.property.MapProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SetProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.TypeUtils;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/// @author Glavo
public abstract class ObservableField<T> {
public static <T> ObservableField<T> of(MethodHandles.Lookup lookup, Field field) {
String name;
List<String> alternateNames;
SerializedName serializedName = field.getAnnotation(SerializedName.class);
if (serializedName == null) {
name = field.getName();
alternateNames = List.of();
} else {
name = serializedName.value();
alternateNames = List.of(serializedName.alternate());
}
VarHandle varHandle;
try {
varHandle = lookup.unreflectVarHandle(field);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(e);
}
if (ObservableList.class.isAssignableFrom(field.getType())) {
Type listType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), List.class);
if (!(listType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the list type of " + field.getName());
return new CollectionField<>(name, alternateNames, varHandle, listType, listType);
} else if (ObservableSet.class.isAssignableFrom(field.getType())) {
Type setType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Set.class);
if (!(setType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the set type of " + field.getName());
ParameterizedType listType = TypeUtils.newParameterizedTypeWithOwner(
null,
List.class,
((ParameterizedType) setType).getActualTypeArguments()[0]
);
return new CollectionField<>(name, alternateNames, varHandle, setType, listType);
} else if (ObservableMap.class.isAssignableFrom(field.getType())) {
Type mapType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Map.class);
if (!(mapType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the map type of " + field.getName());
return new MapField<>(name, alternateNames, varHandle, mapType);
} else if (Property.class.isAssignableFrom(field.getType())) {
Type propertyType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Property.class);
if (!(propertyType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the element type of " + field.getName());
Type elementType = ((ParameterizedType) propertyType).getActualTypeArguments()[0];
return new PropertyField<>(name, alternateNames, varHandle, elementType);
} else {
throw new IllegalArgumentException("Field " + field.getName() + " is not a property or observable collection");
}
}
protected final String serializedName;
protected final List<String> alternateNames;
protected final VarHandle varHandle;
private ObservableField(String serializedName, List<String> alternateNames, VarHandle varHandle) {
this.serializedName = serializedName;
this.alternateNames = alternateNames;
this.varHandle = varHandle;
}
public String getSerializedName() {
return serializedName;
}
public List<String> getAlternateNames() {
return alternateNames;
}
public Observable get(T value) {
return (Observable) varHandle.get(value);
}
public abstract void serialize(JsonObject result, T value, JsonSerializationContext context);
public abstract void deserialize(T value, JsonElement element, JsonDeserializationContext context);
private static final class PropertyField<T> extends ObservableField<T> {
private final Type elementType;
PropertyField(String serializedName, List<String> alternate, VarHandle varHandle, Type elementType) {
super(serializedName, alternate, varHandle);
this.elementType = elementType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
Property<?> property = (Property<?>) get(value);
if (property instanceof RawPreservingProperty<?> rawPreserving) {
JsonElement rawJson = rawPreserving.getRawJson();
if (rawJson != null) {
result.add(getSerializedName(), rawJson);
return;
}
}
JsonElement serialized = context.serialize(property.getValue(), elementType);
if (serialized != null && !serialized.isJsonNull())
result.add(getSerializedName(), serialized);
}
@Override
@SuppressWarnings({"unchecked", "rawtypes"})
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
Property property = (Property) get(value);
try {
property.setValue(context.deserialize(element, elementType));
} catch (Throwable e) {
if (property instanceof RawPreservingProperty<?>) {
((RawPreservingProperty<?>) property).setRawJson(element);
} else {
throw e;
}
}
}
}
private static final class CollectionField<T> extends ObservableField<T> {
private final Type collectionType;
/// When deserializing a Set, we first deserialize it into a `List`, then put the elements into the Set.
private final Type listType;
CollectionField(String serializedName, List<String> alternate, VarHandle varHandle,
Type collectionType, Type listType) {
super(serializedName, alternate, varHandle);
this.collectionType = collectionType;
this.listType = listType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), collectionType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
List<?> deserialized = context.deserialize(element, listType);
Object fieldValue = get(value);
if (fieldValue instanceof ListProperty) {
((ListProperty<Object>) fieldValue).set(FXCollections.observableList((List<Object>) deserialized));
} else if (fieldValue instanceof ObservableList) {
((ObservableList<Object>) fieldValue).setAll(deserialized);
} else if (fieldValue instanceof SetProperty) {
((SetProperty<Object>) fieldValue).set(FXCollections.observableSet(new HashSet<>(deserialized)));
} else if (fieldValue instanceof ObservableSet) {
ObservableSet<Object> set = (ObservableSet<Object>) fieldValue;
set.clear();
set.addAll(deserialized);
} else {
throw new JsonParseException("Unsupported field type: " + fieldValue.getClass());
}
}
}
private static final class MapField<T> extends ObservableField<T> {
private final Type mapType;
MapField(String serializedName, List<String> alternate, VarHandle varHandle, Type mapType) {
super(serializedName, alternate, varHandle);
this.mapType = mapType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), mapType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T config, JsonElement element, JsonDeserializationContext context) {
Map<Object, Object> deserialized = context.deserialize(element, mapType);
ObservableMap<Object, Object> map = (ObservableMap<Object, Object>) varHandle.get(config);
if (map instanceof MapProperty<?, ?>)
((MapProperty<Object, Object>) map).set(FXCollections.observableMap(deserialized));
else {
map.clear();
map.putAll(deserialized);
}
}
}
}
@@ -0,0 +1,369 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.gson;
import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ListProperty;
import javafx.beans.property.MapProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SetProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.TypeUtils;
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.NotNull;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.*;
/// Represents a settings object with multiple [Observable] fields.
///
/// All instance fields in this object, unless marked as `transient`, are considered observable fields.
/// The field types should be subclasses of one of [javafx.beans.property.Property], [javafx.collections.ObservableList], [javafx.collections.ObservableSet], or [javafx.collections.ObservableMap].
///
/// This class implements the [Observable] interface.
/// If any field in a settings object changes, all listeners installed via [#addListener(InvalidationListener)] will be triggered.
///
/// For each observable field, this object tracks whether it has been modified. When serializing, fields that have never been modified will not be serialized by default.
///
/// All subclasses of this class must call [#register()] once in their constructor.
///
/// @author Glavo
public abstract class ObservableSetting implements Observable {
private static final ClassValue<List<? extends ObservableField<?>>> FIELDS = new ClassValue<>() {
@Override
protected List<? extends ObservableField<?>> computeValue(@NotNull Class<?> type) {
if (!ObservableSetting.class.isAssignableFrom(type))
throw new AssertionError("Type: " + type);
try {
var allFields = new ArrayList<ObservableField<?>>();
for (Class<?> current = type;
current != ObservableSetting.class;
current = current.getSuperclass()) {
final MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(current, MethodHandles.lookup());
Field[] fields = current.getDeclaredFields();
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers))
continue;
allFields.add(ObservableField.of(lookup, field));
}
}
return allFields;
} catch (IllegalAccessException e) {
throw new ExceptionInInitializerError(e);
}
}
};
protected transient final ObservableHelper helper = new ObservableHelper(this);
protected transient final Map<String, JsonElement> unknownFields = new HashMap<>();
protected transient final DirtyTracker tracker = new DirtyTracker();
private boolean registered = false;
protected final void register() {
if (registered)
return;
registered = true;
@SuppressWarnings("unchecked")
var fields = (List<ObservableField<ObservableSetting>>) FIELDS.get(this.getClass());
for (var field : fields) {
Observable observable = field.get(this);
tracker.track(observable);
observable.addListener(helper);
}
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
}
private static sealed abstract class ObservableField<T> {
public static <T> ObservableField<T> of(MethodHandles.Lookup lookup, Field field) {
String name;
List<String> alternateNames;
SerializedName serializedName = field.getAnnotation(SerializedName.class);
if (serializedName == null) {
name = field.getName();
alternateNames = List.of();
} else {
name = serializedName.value();
alternateNames = List.of(serializedName.alternate());
}
VarHandle varHandle;
try {
varHandle = lookup.unreflectVarHandle(field);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(e);
}
if (ObservableList.class.isAssignableFrom(field.getType())) {
Type listType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), List.class);
if (!(listType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the list type of " + field.getName());
return new CollectionField<>(name, alternateNames, varHandle, listType, listType);
} else if (ObservableSet.class.isAssignableFrom(field.getType())) {
Type setType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Set.class);
if (!(setType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the set type of " + field.getName());
ParameterizedType listType = TypeUtils.newParameterizedTypeWithOwner(
null,
List.class,
((ParameterizedType) setType).getActualTypeArguments()[0]
);
return new CollectionField<>(name, alternateNames, varHandle, setType, listType);
} else if (ObservableMap.class.isAssignableFrom(field.getType())) {
Type mapType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Map.class);
if (!(mapType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the map type of " + field.getName());
return new MapField<>(name, alternateNames, varHandle, mapType);
} else if (Property.class.isAssignableFrom(field.getType())) {
Type propertyType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Property.class);
if (!(propertyType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the element type of " + field.getName());
Type elementType = ((ParameterizedType) propertyType).getActualTypeArguments()[0];
return new PropertyField<>(name, alternateNames, varHandle, elementType);
} else {
throw new IllegalArgumentException("Field " + field.getName() + " is not a property or observable collection");
}
}
protected final String serializedName;
protected final List<String> alternateNames;
protected final VarHandle varHandle;
private ObservableField(String serializedName, List<String> alternateNames, VarHandle varHandle) {
this.serializedName = serializedName;
this.alternateNames = alternateNames;
this.varHandle = varHandle;
}
public String getSerializedName() {
return serializedName;
}
public List<String> getAlternateNames() {
return alternateNames;
}
public Observable get(T value) {
return (Observable) varHandle.get(value);
}
public abstract void serialize(JsonObject result, T value, JsonSerializationContext context);
public abstract void deserialize(T value, JsonElement element, JsonDeserializationContext context);
private static final class PropertyField<T> extends ObservableField<T> {
private final Type elementType;
PropertyField(String serializedName, List<String> alternate, VarHandle varHandle, Type elementType) {
super(serializedName, alternate, varHandle);
this.elementType = elementType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
Property<?> property = (Property<?>) get(value);
if (property instanceof RawPreservingProperty<?> rawPreserving) {
JsonElement rawJson = rawPreserving.getRawJson();
if (rawJson != null) {
result.add(getSerializedName(), rawJson);
return;
}
}
JsonElement serialized = context.serialize(property.getValue(), elementType);
if (serialized != null && !serialized.isJsonNull())
result.add(getSerializedName(), serialized);
}
@Override
@SuppressWarnings({"unchecked", "rawtypes"})
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
Property property = (Property) get(value);
try {
property.setValue(context.deserialize(element, elementType));
} catch (Throwable e) {
if (property instanceof RawPreservingProperty<?>) {
((RawPreservingProperty<?>) property).setRawJson(element);
} else {
throw e;
}
}
}
}
private static final class CollectionField<T> extends ObservableField<T> {
private final Type collectionType;
/// When deserializing a Set, we first deserialize it into a `List`, then put the elements into the Set.
private final Type listType;
CollectionField(String serializedName, List<String> alternate, VarHandle varHandle,
Type collectionType, Type listType) {
super(serializedName, alternate, varHandle);
this.collectionType = collectionType;
this.listType = listType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), collectionType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
List<?> deserialized = context.deserialize(element, listType);
Object fieldValue = get(value);
if (fieldValue instanceof ListProperty) {
((ListProperty<Object>) fieldValue).set(FXCollections.observableList((List<Object>) deserialized));
} else if (fieldValue instanceof ObservableList) {
((ObservableList<Object>) fieldValue).setAll(deserialized);
} else if (fieldValue instanceof SetProperty) {
((SetProperty<Object>) fieldValue).set(FXCollections.observableSet(new HashSet<>(deserialized)));
} else if (fieldValue instanceof ObservableSet) {
ObservableSet<Object> set = (ObservableSet<Object>) fieldValue;
set.clear();
set.addAll(deserialized);
} else {
throw new JsonParseException("Unsupported field type: " + fieldValue.getClass());
}
}
}
private static final class MapField<T> extends ObservableField<T> {
private final Type mapType;
MapField(String serializedName, List<String> alternate, VarHandle varHandle, Type mapType) {
super(serializedName, alternate, varHandle);
this.mapType = mapType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), mapType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T config, JsonElement element, JsonDeserializationContext context) {
Map<Object, Object> deserialized = context.deserialize(element, mapType);
ObservableMap<Object, Object> map = (ObservableMap<Object, Object>) varHandle.get(config);
if (map instanceof MapProperty<?, ?>)
((MapProperty<Object, Object>) map).set(FXCollections.observableMap(deserialized));
else {
map.clear();
map.putAll(deserialized);
}
}
}
}
public static abstract class Adapter<T extends ObservableSetting>
implements JsonSerializer<T>, JsonDeserializer<T> {
protected abstract T createInstance();
@Override
public JsonElement serialize(T setting, Type typeOfSrc, JsonSerializationContext context) {
if (setting == null)
return JsonNull.INSTANCE;
@SuppressWarnings("unchecked")
var fields = (List<ObservableField<T>>) FIELDS.get(setting.getClass());
JsonObject result = new JsonObject();
for (var field : fields) {
Observable observable = field.get(setting);
if (setting.tracker.isDirty(observable)) {
field.serialize(result, setting, context);
}
}
setting.unknownFields.forEach(result::add);
return result;
}
@Override
public T deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json.isJsonNull())
return null;
if (!json.isJsonObject())
throw new JsonParseException("Config is not an object: " + json);
T setting = createInstance();
@SuppressWarnings("unchecked")
var fields = (List<ObservableField<T>>) FIELDS.get(setting.getClass());
var values = new LinkedHashMap<>(json.getAsJsonObject().asMap());
for (ObservableField<T> field : fields) {
JsonElement value = values.remove(field.getSerializedName());
if (value == null) {
for (String alternateName : field.getAlternateNames()) {
value = values.remove(alternateName);
if (value != null)
break;
}
}
if (value != null) {
setting.tracker.markDirty(field.get(setting));
field.deserialize(setting, value, context);
}
}
setting.unknownFields.putAll(values);
return setting;
}
}
}
@@ -22,6 +22,7 @@ import javafx.beans.property.SimpleObjectProperty;
import org.jetbrains.annotations.Nullable;
/// @author Glavo
/// @see ObservableSetting
public class RawPreservingObjectProperty<T> extends SimpleObjectProperty<T> implements RawPreservingProperty<T> {
private JsonElement rawJson;
@@ -144,8 +144,7 @@ public final class NetworkUtils {
}
connection.setConnectTimeout(TIME_OUT);
connection.setReadTimeout(TIME_OUT);
if (connection instanceof HttpURLConnection) {
var httpConnection = (HttpURLConnection) connection;
if (connection instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
httpConnection.setInstanceFollowRedirects(false);
}
@@ -276,7 +275,11 @@ public final class NetworkUtils {
}
public static String doGet(URI uri) throws IOException {
return readFullyAsString(resolveConnection(createHttpConnection(uri)));
URLConnection connection = createConnection(uri);
if (connection instanceof HttpURLConnection httpURLConnection) {
connection = resolveConnection(httpURLConnection);
}
return readFullyAsString(connection);
}
public static String doGet(List<URI> uris) throws IOException {
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.util.platform;
import org.jackhuang.hmcl.util.KeyValuePairUtils;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import java.io.BufferedReader;
import java.io.IOException;
@@ -150,13 +150,35 @@ public enum OperatingSystem {
OSVersion.Windows windowsVersion = null;
Kernel32 kernel32 = Kernel32.INSTANCE;
WinReg reg = WinReg.INSTANCE;
// Get Windows version number
if (kernel32 != null) {
WinTypes.OSVERSIONINFOEXW osVersionInfo = new WinTypes.OSVERSIONINFOEXW();
if (kernel32.GetVersionExW(osVersionInfo)) {
windowsVersion = new OSVersion.Windows(osVersionInfo.dwMajorVersion, osVersionInfo.dwMinorVersion, osVersionInfo.dwBuildNumber);
} else
System.err.println("Failed to obtain OS version number (" + kernel32.GetLastError() + ")");
if (reg != null) {
var baseVersion = OSVersion.Windows.parse(System.getProperty("os.version"));
int majorVersion = baseVersion.major();
int minorVersion = baseVersion.minor();
int buildNumber = baseVersion.build();
int revision = baseVersion.revision();
Object currentBuild = reg.queryValue(WinReg.HKEY.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuild");
if (currentBuild instanceof String currentBuildStr) {
try {
buildNumber = Integer.parseInt(currentBuildStr);
} catch (NumberFormatException e) {
System.err.println("Invalid Windows build number: " + currentBuildStr);
}
}
if (majorVersion >= 10) {
Object ubr = reg.queryValue(WinReg.HKEY.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "UBR");
if (ubr instanceof Integer ubrValue)
revision = ubrValue;
}
windowsVersion = new OSVersion.Windows(majorVersion, minorVersion, buildNumber, revision);
}
if (windowsVersion == null) {
@@ -201,6 +223,16 @@ public enum OperatingSystem {
if (osName.equals("Windows 10") && windowsVersion.isAtLeast(OSVersion.WINDOWS_11))
osName = "Windows 11";
if (windowsVersion.isAtLeast(OSVersion.WINDOWS_10) && reg != null) {
Object displayVersion = reg.queryValue(WinReg.HKEY.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "DisplayVersion");
if (displayVersion instanceof String displayVersionStr
&& displayVersionStr.matches("\\d{2}H\\d")) {
osName = osName + " " + displayVersionStr;
}
}
SYSTEM_NAME = osName;
SYSTEM_VERSION = windowsVersion;
SYSTEM_BUILD_NUMBER = windowsVersion.build();
@@ -100,7 +100,7 @@ public abstract sealed class GameVersionNumber implements Comparable<GameVersion
}
public boolean isAprilFools() {
if (this instanceof Special)
if (this instanceof Special && !value.endsWith("_unobfuscated"))
return true;
if (this instanceof Snapshot snapshot) {
@@ -1,5 +1,12 @@
{
"versions": [
{
"id": "25w45a_unobfuscated",
"type" : "unobfuscated",
"url": "https://piston-meta.mojang.com/v1/packages/7a3c149f148b6aa5ac3af48c4f701adea7e5b615/25w45a_unobfuscated.json",
"time" : "2025-11-04T14:07:08+00:00",
"releaseTime" : "2025-11-04T14:07:08+00:00"
},
{
"id": "1.19_deep_dark_experimental_snapshot-1",
"type": "pending",
@@ -848,4 +848,10 @@
1.21.9-rc1
1.21.9
1.21.10-rc1
1.21.10
1.21.10
25w41a
25w42a
25w43a
25w44a
25w45a
25w45a_unobfuscated
@@ -121,6 +121,7 @@ public final class GameVersionNumberTest {
assertFalse(isAprilFools("25w21a"));
assertFalse(isAprilFools("13w12~"));
assertFalse(isAprilFools("15w14b"));
assertFalse(isAprilFools("25w45a_unobfuscated"));
}
@Test
@@ -272,6 +273,8 @@ public final class GameVersionNumberTest {
"24w13a",
"24w14potato",
"24w14a",
"25w45a",
"25w45a_unobfuscated",
"Unknown",
"100.0"
);
@@ -58,19 +58,23 @@ public abstract class ParseModDataTask extends DefaultTask {
private static final String MOD_SEPARATOR = ",";
private static final Pattern[] CURSEFORGE_PATTERNS = {
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons)/+(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons|scripts)/+(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/projects/(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/mc-mods/minecraft/(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/legacy/mc-mods/minecraft/(\\d+)-(?<modid>[\\w-]+)"),
};
private static String parseCurseforge(String url) {
URI res = URI.create(url);
URI res = URI.create(url.replace(" ", "%20"));
if (!"http".equals(res.getScheme()) && !"https".equals(res.getScheme())) {
return "";
}
if ("edge.forgecdn.net".equals(res.getHost())) {
return "";
}
for (Pattern pattern : CURSEFORGE_PATTERNS) {
Matcher matcher = pattern.matcher(res.getPath());
if (matcher.matches()) {