diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index 2d12de7d4..075a79a29 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -29,6 +29,7 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.*; import javafx.collections.ObservableMap; import javafx.event.Event; @@ -44,10 +45,7 @@ import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.*; -import javafx.scene.layout.ColumnConstraints; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; +import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.scene.text.Text; @@ -57,9 +55,8 @@ import javafx.stage.Stage; import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; -import org.glavo.png.PNGType; -import org.glavo.png.PNGWriter; -import org.glavo.png.javafx.PNGJavaFXUtils; +import org.jackhuang.hmcl.task.CacheFileTask; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.util.*; @@ -82,13 +79,18 @@ import javax.imageio.stream.ImageInputStream; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; +import java.awt.image.BufferedImage; import java.io.*; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.ref.WeakReference; import java.net.*; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.List; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -169,18 +171,8 @@ public final class FXUtils { ); private static final Map builtinImageCache = new ConcurrentHashMap<>(); - private static final Map remoteImageCache = new ConcurrentHashMap<>(); public static void shutdown() { - for (Map.Entry entry : remoteImageCache.entrySet()) { - try { - Files.deleteIfExists(entry.getValue()); - } catch (IOException e) { - LOG.warning(String.format("Failed to delete cache file %s.", entry.getValue()), e); - } - remoteImageCache.remove(entry.getKey()); - } - builtinImageCache.clear(); } @@ -903,78 +895,55 @@ public final class FXUtils { } } - /** - * Load image from the internet. It will cache the data of images for the further usage. - * The cached data will be deleted when HMCL is closed or hidden. - * - * @param url the url of image. The image resource should be a file on the internet. - * @return the image resource within the jar. - */ - public static Image newRemoteImage(String url) { - return newRemoteImage(url, 0, 0, false, false, false); + public static Task getRemoteImageTask(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { + return new CacheFileTask(URI.create(url)) + .thenApplyAsync(file -> { + try (var channel = FileChannel.open(file, StandardOpenOption.READ)) { + var header = new byte[12]; + var buffer = ByteBuffer.wrap(header); + + //noinspection StatementWithEmptyBody + while (channel.read(buffer) > 0) { + } + + channel.position(0L); + if (!buffer.hasRemaining()) { + // WebP File + if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F' && + header[8] == 'W' && header[9] == 'E' && header[10] == 'B' && header[11] == 'P') { + + WebPImageReaderSpi spi = new WebPImageReaderSpi(); + ImageReader reader = spi.createReaderInstance(null); + BufferedImage bufferedImage; + try (ImageInputStream imageInput = ImageIO.createImageInputStream(Channels.newInputStream(channel))) { + reader.setInput(imageInput, true, true); + bufferedImage = reader.read(0, reader.getDefaultReadParam()); + } finally { + reader.dispose(); + } + return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth); + } + } + + Image image = new Image(Channels.newInputStream(channel), requestedWidth, requestedHeight, preserveRatio, smooth); + if (image.isError()) + throw image.getException(); + return image; + } + }); } - /** - * Load image from the internet. It will cache the data of images for the further usage. - * The cached data will be deleted when HMCL is closed or hidden. - * - * @param url the url of image. The image resource should be a file on the internet. - * @param requestedWidth the image's bounding box width - * @param requestedHeight the image's bounding box height - * @param preserveRatio indicates whether to preserve the aspect ratio of - * the original image when scaling to fit the image within the - * specified bounding box - * @param smooth indicates whether to use a better quality filtering - * algorithm or a faster one when scaling this image to fit within - * the specified bounding box - * @return the image resource within the jar. - */ - public static Image newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth, boolean backgroundLoading) { - Path currentPath = remoteImageCache.get(url); - if (currentPath != null) { - if (Files.isReadable(currentPath)) { - try (InputStream inputStream = Files.newInputStream(currentPath)) { - return new Image(inputStream, requestedWidth, requestedHeight, preserveRatio, smooth); - } catch (IOException e) { - LOG.warning("An exception encountered while reading data from cached image file.", e); - } - } - - // The file is unavailable or unreadable. - remoteImageCache.remove(url); - - try { - Files.deleteIfExists(currentPath); - } catch (IOException e) { - LOG.warning("An exception encountered while deleting broken cached image file.", e); - } - } - - Image image = new Image(url, requestedWidth, requestedHeight, preserveRatio, smooth, backgroundLoading); - image.progressProperty().addListener((observable, oldValue, newValue) -> { - if (newValue.doubleValue() >= 1.0 && !image.isError() && image.getPixelReader() != null && image.getWidth() > 0.0 && image.getHeight() > 0.0) { - Task.runAsync(() -> { - Path newPath = Files.createTempFile("hmcl-net-resource-cache-", ".cache"); - try ( // Make sure the file is released from JVM before we put the path into remoteImageCache. - OutputStream outputStream = Files.newOutputStream(newPath); - PNGWriter writer = new PNGWriter(outputStream, PNGType.RGBA, PNGWriter.DEFAULT_COMPRESS_LEVEL) - ) { - writer.write(PNGJavaFXUtils.asArgbImage(image)); - } catch (IOException e) { - try { - Files.delete(newPath); - } catch (IOException e2) { - e2.addSuppressed(e); - throw e2; - } - throw e; + public static ObservableValue newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { + var image = new SimpleObjectProperty(); + getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + image.set(result); + } else { + LOG.warning("An exception encountered while loading remote image: " + url, exception); } - if (remoteImageCache.putIfAbsent(url, newPath) != null) { - Files.delete(newPath); // The image has been loaded in another task. Delete the image here in order not to pollute the tmp folder. - } - }).start(); - } - }); + }) + .start(); return image; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java index 8f8556113..01a9b2942 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -190,10 +190,12 @@ public final class HTMLRenderer { } } - Image image = FXUtils.newRemoteImage(uri.toString(), width, height, true, true, false); - if (image.isError()) { - LOG.warning("Failed to load image: " + uri, image.getException()); - } else { + try { + Image image = FXUtils.getRemoteImageTask(uri.toString(), width, height, true, true) + .run(); + if (image == null) + throw new AssertionError("Image loading task returned null"); + ImageView imageView = new ImageView(image); if (hyperlink != null) { URI target = resolveLink(hyperlink); @@ -204,6 +206,8 @@ public final class HTMLRenderer { } children.add(imageView); return; + } catch (Throwable e) { + LOG.warning("Failed to load image: " + uri, e); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java index 7d8fdc134..525209e1e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java @@ -68,6 +68,7 @@ import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent; import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable { protected final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); @@ -542,7 +543,8 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP .collect(Collectors.toList())); if (StringUtils.isNotBlank(dataItem.getIconUrl())) { - imageView.setImage(FXUtils.newRemoteImage(dataItem.getIconUrl(), 40, 40, true, true, true)); + LOG.debug("Icon: " + dataItem.getIconUrl()); + imageView.imageProperty().bind(FXUtils.newRemoteImage(dataItem.getIconUrl(), 40, 40, true, true)); } } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index 6de0971de..2a8e5d1ab 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -220,7 +220,7 @@ public class DownloadPage extends Control implements DecoratorPage { { ImageView imageView = new ImageView(); if (StringUtils.isNotBlank(getSkinnable().addon.getIconUrl())) { - imageView.setImage(FXUtils.newRemoteImage(getSkinnable().addon.getIconUrl(), 40, 40, true, true, true)); + imageView.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.getIconUrl(), 40, 40, true, true)); } descriptionPane.getChildren().add(FXUtils.limitingSize(imageView, 40, 40)); @@ -359,7 +359,7 @@ public class DownloadPage extends Control implements DecoratorPage { .collect(Collectors.toList())); if (StringUtils.isNotBlank(addon.getIconUrl())) { - imageView.setImage(FXUtils.newRemoteImage(addon.getIconUrl(), 40, 40, true, true, true)); + imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 40, 40, true, true)); } } else { content.setTitle(i18n("mods.broken_dependency.title")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java index 1163c0e8c..80c068d44 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java @@ -120,5 +120,43 @@ public final class SwingFXUtils { pw.setPixels(0, 0, bw, bh, pf, data, offset, scan); return wimg; } + + public static WritableImage toFXImage(BufferedImage bimg, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { + if (requestedWidth <= 0. || requestedHeight <= 0.) { + return toFXImage(bimg, null); + } + + int width = (int) requestedWidth; + int height = (int) requestedHeight; + + // Calculate actual dimensions if preserveRatio is true + if (preserveRatio) { + double originalWidth = bimg.getWidth(); + double originalHeight = bimg.getHeight(); + double scaleX = requestedWidth / originalWidth; + double scaleY = requestedHeight / originalHeight; + double scale = Math.min(scaleX, scaleY); + + width = (int) (originalWidth * scale); + height = (int) (originalHeight * scale); + } + + // Create scaled BufferedImage + BufferedImage scaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE); + Graphics2D g2d = scaledImage.createGraphics(); + try { + if (smooth) { + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + } + + g2d.drawImage(bimg, 0, 0, width, height, null); + } finally { + g2d.dispose(); + } + + return toFXImage(scaledImage, null); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java new file mode 100644 index 000000000..6f67c7c7a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java @@ -0,0 +1,107 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +package org.jackhuang.hmcl.task; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * Download a file to cache repository. + * + * @author Glavo + */ +public final class CacheFileTask extends FetchTask { + + public CacheFileTask(@NotNull URI uri) { + super(List.of(uri), DEFAULT_RETRY); + } + + public CacheFileTask(@NotNull URI uri, int retry) { + super(List.of(uri), retry); + } + + @Override + protected EnumCheckETag shouldCheckETag() { + // Check cache + for (URI uri : uris) { + try { + setResult(repository.getCachedRemoteFile(uri)); + return EnumCheckETag.CACHED; + } catch (IOException ignored) { + } + } + return EnumCheckETag.CHECK_E_TAG; + } + + @Override + protected void useCachedResult(Path cache) { + setResult(cache); + } + + @Override + protected Context getContext(URLConnection connection, boolean checkETag) throws IOException { + assert checkETag; + + Path temp = Files.createTempFile("hmcl-download-", null); + OutputStream fileOutput = Files.newOutputStream(temp); + + return new Context() { + @Override + public void write(byte[] buffer, int offset, int len) throws IOException { + fileOutput.write(buffer, offset, len); + } + + @Override + public void close() throws IOException { + try { + fileOutput.close(); + } catch (IOException e) { + LOG.warning("Failed to close file: " + temp, e); + } + + if (!isSuccess()) { + try { + Files.deleteIfExists(temp); + } catch (IOException e) { + LOG.warning("Failed to delete file: " + temp, e); + } + return; + } + + try { + setResult(repository.cacheRemoteFile(connection, temp)); + } finally { + try { + Files.deleteIfExists(temp); + } catch (IOException e) { + LOG.warning("Failed to delete file: " + temp, e); + } + } + } + }; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java index b7f27dc72..15402087d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java @@ -44,9 +44,10 @@ import static org.jackhuang.hmcl.util.Lang.threadPool; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public abstract class FetchTask extends Task { + protected static final int DEFAULT_RETRY = 3; + protected final List uris; protected final int retry; - protected boolean caching; protected CacheRepository repository = CacheRepository.getInstance(); public FetchTask(@NotNull List<@NotNull URI> uris, int retry) { @@ -61,10 +62,6 @@ public abstract class FetchTask extends Task { setExecutor(download()); } - public void setCaching(boolean caching) { - this.caching = caching; - } - public void setCacheRepository(CacheRepository repository) { this.repository = repository; } 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 ee6224685..ec6895aa0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java @@ -84,6 +84,7 @@ public class FileDownloadTask extends FetchTask { private final Path file; private final IntegrityCheck integrityCheck; + private boolean caching; private Path candidate; private final ArrayList integrityCheckHandlers = new ArrayList<>(); @@ -132,7 +133,7 @@ public class FileDownloadTask extends FetchTask { * @param integrityCheck the integrity check to perform, null if no integrity check is to be performed */ public FileDownloadTask(List uris, Path path, IntegrityCheck integrityCheck) { - this(uris, path, integrityCheck, 3); + this(uris, path, integrityCheck, DEFAULT_RETRY); } /** @@ -155,6 +156,10 @@ public class FileDownloadTask extends FetchTask { return file; } + public void setCaching(boolean caching) { + this.caching = caching; + } + public FileDownloadTask setCandidate(Path candidate) { this.candidate = candidate; return this; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/GetTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/GetTask.java index c0fab32db..119b8f42a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/GetTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/GetTask.java @@ -36,8 +36,6 @@ import static java.nio.charset.StandardCharsets.UTF_8; */ public final class GetTask extends FetchTask { - private static final int DEFAULT_RETRY = 3; - private final Charset charset; public GetTask(URI url) { 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 8049f8d81..0e7fae9c7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java @@ -200,20 +200,20 @@ public class CacheRepository { // conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified()); } - public void cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException { - cacheData(connection, () -> { + public Path cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException { + return cacheData(connection, () -> { String hash = DigestUtils.digestToString(SHA1, downloaded); Path cached = cacheFile(downloaded, SHA1, hash); return new CacheResult(hash, cached); }); } - public void cacheText(URLConnection connection, String text) throws IOException { - cacheBytes(connection, text.getBytes(UTF_8)); + public Path cacheText(URLConnection connection, String text) throws IOException { + return cacheBytes(connection, text.getBytes(UTF_8)); } - public void cacheBytes(URLConnection connection, byte[] bytes) throws IOException { - cacheData(connection, () -> { + public Path cacheBytes(URLConnection connection, byte[] bytes) throws IOException { + return cacheData(connection, () -> { String hash = DigestUtils.digestToString(SHA1, bytes); Path cached = getFile(SHA1, hash); FileUtils.writeBytes(cached, bytes); @@ -221,9 +221,9 @@ public class CacheRepository { }); } - private void cacheData(URLConnection connection, ExceptionalSupplier cacheSupplier) throws IOException { + private Path cacheData(URLConnection connection, ExceptionalSupplier cacheSupplier) throws IOException { String eTag = connection.getHeaderField("ETag"); - if (StringUtils.isBlank(eTag)) return; + if (StringUtils.isBlank(eTag)) return null; URI uri; try { uri = NetworkUtils.dropQuery(connection.getURL().toURI()); @@ -240,6 +240,7 @@ public class CacheRepository { } finally { lock.writeLock().unlock(); } + return cacheResult.cachedFile; } private static final class CacheResult {