diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index 24e8acfd6a..04cd06060a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -505,6 +505,17 @@ public void setDownloadThreads(int downloadThreads) { this.downloadThreads.set(downloadThreads); } + @SerializedName("hardlink") + private final BooleanProperty hardlink = new SimpleBooleanProperty(false); + + public BooleanProperty hardlinkProperty() { return hardlink; } + + public boolean getHardlink() { + return hardlink.get(); + } + + public void setHardlink(boolean hardlink) { this.hardlink.set(hardlink); } + @SerializedName("downloadType") private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_RAW_PROVIDER_ID); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java index 66b9973a71..4f5b5c3305 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.ResponseCodeException; import javax.net.ssl.SSLHandshakeException; @@ -60,6 +61,9 @@ private DownloadProviders() { @SuppressWarnings("unused") private static final InvalidationListener observer; + @SuppressWarnings("unused") + private static final InvalidationListener hardlinkObserver; + static { String bmclapiRoot = "https://bmclapi2.bangbang93.com"; String bmclapiRootOverride = System.getProperty("hmcl.bmclapi.override"); @@ -86,6 +90,9 @@ private DownloadProviders() { config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads()); }, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty()); + hardlinkObserver = FXUtils.observeWeak(() -> FileUtils.setHardLink(config().getHardlink()), + config().hardlinkProperty()); + provider = new DownloadProviderWrapper(MOJANG); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java index 859c7c2557..459bb743a8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java @@ -105,63 +105,90 @@ public DownloadSettingsPage() { content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource); } + ComponentList downloadList = new ComponentList(); { - VBox downloadThreads = new VBox(16); - downloadThreads.getStyleClass().add("card-non-transparent"); { + VBox downloadThreads = new VBox(16); + downloadList.getContent().add(downloadThreads); { - JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto")); - VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0)); - chkAutoDownloadThreads.selectedProperty().bindBidirectional(config().autoDownloadThreadsProperty()); - downloadThreads.getChildren().add(chkAutoDownloadThreads); - - chkAutoDownloadThreads.selectedProperty().addListener((a, b, newValue) -> { - if (newValue) { - config().downloadThreadsProperty().set(FetchTask.DEFAULT_CONCURRENCY); - } - }); - } + { + JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto")); + VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0)); + chkAutoDownloadThreads.selectedProperty().bindBidirectional(config().autoDownloadThreadsProperty()); + downloadThreads.getChildren().add(chkAutoDownloadThreads); + + chkAutoDownloadThreads.selectedProperty().addListener((a, b, newValue) -> { + if (newValue) { + config().downloadThreadsProperty().set(FetchTask.DEFAULT_CONCURRENCY); + } + }); + } + + { + HBox hbox = new HBox(8); + hbox.setStyle("-fx-view-order: -1;"); // prevent the indicator from being covered by the hint + hbox.setAlignment(Pos.CENTER); + hbox.setPadding(new Insets(0, 0, 0, 30)); + hbox.disableProperty().bind(config().autoDownloadThreadsProperty()); + Label label = new Label(i18n("settings.launcher.download.threads")); + + JFXSlider slider = new JFXSlider(1, 256, 64); + HBox.setHgrow(slider, Priority.ALWAYS); + + JFXTextField threadsField = new JFXTextField(); + FXUtils.setLimitWidth(threadsField, 60); + FXUtils.bindInt(threadsField, config().downloadThreadsProperty()); + + AtomicBoolean changedByTextField = new AtomicBoolean(false); + FXUtils.onChangeAndOperate(config().downloadThreadsProperty(), value -> { + changedByTextField.set(true); + slider.setValue(value.intValue()); + changedByTextField.set(false); + }); + slider.valueProperty().addListener((value, oldVal, newVal) -> { + if (changedByTextField.get()) return; + config().downloadThreadsProperty().set(value.getValue().intValue()); + }); + + hbox.getChildren().setAll(label, slider, threadsField); + downloadThreads.getChildren().add(hbox); + } + { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + VBox.setMargin(hintPane, new Insets(0, 0, 0, 30)); + hintPane.disableProperty().bind(config().autoDownloadThreadsProperty()); + hintPane.setText(i18n("settings.launcher.download.threads.hint")); + downloadThreads.getChildren().add(hintPane); + } + } + } + { + VBox hardLink = new VBox(16); + downloadList.getContent().add(hardLink); { - HBox hbox = new HBox(8); - hbox.setStyle("-fx-view-order: -1;"); // prevent the indicator from being covered by the hint - hbox.setAlignment(Pos.CENTER); - hbox.setPadding(new Insets(0, 0, 0, 30)); - hbox.disableProperty().bind(config().autoDownloadThreadsProperty()); - Label label = new Label(i18n("settings.launcher.download.threads")); - - JFXSlider slider = new JFXSlider(1, 256, 64); - HBox.setHgrow(slider, Priority.ALWAYS); - - JFXTextField threadsField = new JFXTextField(); - FXUtils.setLimitWidth(threadsField, 60); - FXUtils.bindInt(threadsField, config().downloadThreadsProperty()); - - AtomicBoolean changedByTextField = new AtomicBoolean(false); - FXUtils.onChangeAndOperate(config().downloadThreadsProperty(), value -> { - changedByTextField.set(true); - slider.setValue(value.intValue()); - changedByTextField.set(false); - }); - slider.valueProperty().addListener((value, oldVal, newVal) -> { - if (changedByTextField.get()) return; - config().downloadThreadsProperty().set(value.getValue().intValue()); - }); - - hbox.getChildren().setAll(label, slider, threadsField); - downloadThreads.getChildren().add(hbox); + JFXCheckBox chkHardLink = new JFXCheckBox(i18n("settings.launcher.download.hardlink")); + hardLink.getChildren().add(chkHardLink); + VBox.setMargin(chkHardLink, new Insets(8, 0, 0, 0)); + chkHardLink.selectedProperty().bindBidirectional(config().hardlinkProperty()); } { HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + hardLink.getChildren().add(hintPane); + VBox.setMargin(hintPane, new Insets(0, 0, 0, 30)); + hintPane.setText(i18n("settings.launcher.download.hardlink.hint")); + } + + { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hardLink.getChildren().add(hintPane); VBox.setMargin(hintPane, new Insets(0, 0, 0, 30)); - hintPane.disableProperty().bind(config().autoDownloadThreadsProperty()); - hintPane.setText(i18n("settings.launcher.download.threads.hint")); - downloadThreads.getChildren().add(hintPane); + hintPane.setText(i18n("settings.launcher.download.hardlink.note")); } } - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadThreads); + content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadList); } { diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 3e813e492f..f11bf38f94 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1355,6 +1355,9 @@ settings.launcher.download.threads.auto=Automatically Determine settings.launcher.download.threads.hint=Too many threads may cause your system to freeze, and your download speed may be affected by your ISP and download servers. It is not always the case that more threads increase your download speed. settings.launcher.download_source=Download Source settings.launcher.download_source.auto=Automatically Choose Download Sources +settings.launcher.download.hardlink=Hard Link Instead of Copying +settings.launcher.download.hardlink.hint=Using hard links saves significant disk space and time by creating multiple file entries for the same file data, rather than duplicating the data itself. To enable this, ensure your cache folder is located on the same drive or file system partition as your downloads folder. On Windows, you must also enable Developer Mode in your system settings. +settings.launcher.download.hardlink.note=Files linked in the assets, libraries, mods, resourcepacks, shaderpacks directories as well as other files downloaded via HMCL are hard links and should not be modified directly. If you need to alter a file, make a copy of it first and then delete the original source file. settings.launcher.enable_game_list=Show instance list in homepage settings.launcher.font=Font settings.launcher.font.anti_aliasing=Anti-aliasing diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 43603a3cbb..6caf55e6a6 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1153,6 +1153,9 @@ settings.launcher.download.threads.auto=自动选择线程数 settings.launcher.download.threads.hint=线程数过高可能导致系统卡顿。你的下载速度会受到互联网运营商、下载源服务器等方面的影响。调高下载线程数不一定能大幅提升总下载速度。 settings.launcher.download_source=下载源 settings.launcher.download_source.auto=自动选择下载源 +settings.launcher.download.hardlink=使用硬链接替代复制 +settings.launcher.download.hardlink.hint=硬链接通过创建指向相同文件数据的多条文件条目(而非复制数据本身),能显著节省磁盘空间和时间。启用此功能时,请确保缓存文件夹与下载文件夹位于同一驱动器或文件系统分区。在Windows系统中,还需在系统设置中启用开发者模式。 +settings.launcher.download.hardlink.note=assets,libraries,mods,resourcepacks,shaderpacks目录中的文件,以及其他通过HMCL下载的文件均为硬链接,请勿直接修改。如需修改文件,请先创建副本,再删除原始文件。 settings.launcher.enable_game_list=在主页内显示版本列表 settings.launcher.font=字体 settings.launcher.font.anti_aliasing=抗锯齿 (重启后生效) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultCacheRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultCacheRepository.java index 88c3e1bd94..524982b251 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultCacheRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultCacheRepository.java @@ -185,7 +185,7 @@ public Path cacheLibrary(Library library, Path path, boolean forge) throws IOExc hash = DigestUtils.digestToString(SHA1, path); Path cache = getFile(SHA1, hash); - FileUtils.copyFile(path, cache); + FileUtils.linkFile(path, cache); Lock writeLock = lock.writeLock(); writeLock.lock(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java index b3b4d8dc1d..e448ecf38b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java @@ -161,7 +161,7 @@ protected EnumCheckETag shouldCheckETag() { Optional cache = repository.checkExistentFile(candidate, integrityCheck.getAlgorithm(), integrityCheck.getChecksum()); if (cache.isPresent()) { try { - FileUtils.copyFile(cache.get(), file); + FileUtils.linkFile(cache.get(), file); LOG.trace("Successfully verified file " + file + " from " + uris.get(0)); return EnumCheckETag.CACHED; } catch (IOException e) { @@ -181,7 +181,7 @@ protected void beforeDownload(URI uri) { @Override protected void useCachedResult(Path cache) throws IOException { - FileUtils.copyFile(cache, file); + FileUtils.linkFile(cache, file); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java index 3e08b993b2..4bea172268 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java @@ -117,12 +117,12 @@ protected boolean fileExists(String algorithm, String hash) { public void tryCacheFile(Path path, String algorithm, String hash) throws IOException { Path cache = getFile(algorithm, hash); if (Files.isRegularFile(cache)) return; - FileUtils.copyFile(path, cache); + FileUtils.linkFile(path, cache); } public Path cacheFile(Path path, String algorithm, String hash) throws IOException { Path cache = getFile(algorithm, hash); - FileUtils.copyFile(path, cache); + FileUtils.linkFile(path, cache); return cache; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 0a7043f236..cbf2b17829 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -45,6 +45,7 @@ * @author huang */ public final class FileUtils { + private static volatile boolean hardLink = false; private FileUtils() { } @@ -452,8 +453,11 @@ public static void forceDelete(Path file) Files.delete(file); } - public static void copyFile(Path srcFile, Path destFile) - throws IOException { + public static void setHardLink(boolean value) { + hardLink = value; + } + + private static boolean checkCopy(Path srcFile, Path destFile) throws IOException { Objects.requireNonNull(srcFile, "Source must not be null"); Objects.requireNonNull(destFile, "Destination must not be null"); if (!Files.exists(srcFile)) @@ -461,12 +465,52 @@ public static void copyFile(Path srcFile, Path destFile) if (Files.isDirectory(srcFile)) throw new IOException("Source '" + srcFile + "' exists but is a directory"); Files.createDirectories(destFile.getParent()); - if (Files.exists(destFile) && !Files.isWritable(destFile)) + boolean destExist = Files.exists(destFile); + if (destExist && !Files.isWritable(destFile)) throw new IOException("Destination '" + destFile + "' exists but is read-only"); + return destExist; + } + + private static boolean isSameDisk(Path srcFile, Path destFile) { + do { + destFile = destFile.getParent(); + } while (!Files.exists(destFile)); + + try { + return Files.getFileStore(srcFile).equals(Files.getFileStore(destFile)); + } catch (IOException e) { + LOG.warning(e.toString()); + return true; + } + } + private static void copy(Path srcFile, Path destFile) throws IOException { Files.copy(srcFile, destFile, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING); } + public static void copyFile(Path srcFile, Path destFile) throws IOException { + checkCopy(srcFile, destFile); + copy(srcFile, destFile); + } + + public static void linkFile(Path srcFile, Path destFile) throws IOException { + boolean exist = checkCopy(srcFile, destFile); + if (hardLink && isSameDisk(srcFile, destFile)) { + if (exist) Files.delete(destFile); + try { + Files.createLink(destFile, srcFile); + return; + } catch (FileAlreadyExistsException e) { + LOG.warning("File has already been created by another thread", e); + return; + } catch (Throwable e) { + hardLink = false; + LOG.warning("Failed to create hardlink", e); + } + } + copy(srcFile, destFile); + } + public static List listFilesByExtension(Path file, String extension) { try (Stream list = Files.list(file)) { return list.filter(it -> Files.isRegularFile(it) && extension.equals(getExtension(it)))