feat: speed up openjfx dependencies checking
SHA-1 of dependencies are defined in openjfx-dependencies.json to avoid network requests during startup. openjfx-dependencies.json can be generated using tools/generate-openjfx-dependencies.sh
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,7 +11,6 @@ hs_err_pid*
|
|||||||
*.log
|
*.log
|
||||||
.mine*
|
.mine*
|
||||||
NVIDIA
|
NVIDIA
|
||||||
*.json
|
|
||||||
|
|
||||||
# gradle build
|
# gradle build
|
||||||
/build/
|
/build/
|
||||||
|
|||||||
@@ -41,59 +41,104 @@
|
|||||||
*/
|
*/
|
||||||
package org.jackhuang.hmcl.util;
|
package org.jackhuang.hmcl.util;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
|
||||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
|
||||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
|
||||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.awt.*;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ForkJoinPool;
|
|
||||||
import java.util.concurrent.ForkJoinTask;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import static java.lang.Class.forName;
|
import static java.lang.Class.forName;
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static java.util.stream.Collectors.toSet;
|
||||||
import static org.jackhuang.hmcl.Metadata.HMCL_DIRECTORY;
|
import static org.jackhuang.hmcl.Metadata.HMCL_DIRECTORY;
|
||||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
import static org.jackhuang.hmcl.util.platform.JavaVersion.CURRENT_JAVA;
|
import static org.jackhuang.hmcl.util.platform.JavaVersion.CURRENT_JAVA;
|
||||||
|
|
||||||
/**
|
import java.awt.Dialog;
|
||||||
* Utility for patching self when missing dependencies.
|
import java.awt.GridBagConstraints;
|
||||||
* Copy from https://github.com/Col-E/Recaf/blob/master/src/main/java/me/coley/recaf/util/self/SelfDependencyPatcher.java
|
import java.awt.GridBagLayout;
|
||||||
*
|
import java.awt.Insets;
|
||||||
* @author Matt
|
import java.io.IOException;
|
||||||
*/
|
import java.io.InputStream;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JProgressBar;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.WindowConstants;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||||
|
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||||
|
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||||
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
// From: https://github.com/Col-E/Recaf/blob/7378b397cee664ae81b7963b0355ef8ff013c3a7/src/main/java/me/coley/recaf/util/self/SelfDependencyPatcher.java
|
||||||
public final class SelfDependencyPatcher {
|
public final class SelfDependencyPatcher {
|
||||||
private SelfDependencyPatcher() {
|
private SelfDependencyPatcher() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static class DependencyDescriptor {
|
||||||
|
|
||||||
|
private static final String REPOSITORY_URL = "https://maven.aliyun.com/repository/central/";
|
||||||
private static final Path DEPENDENCIES_DIR_PATH = HMCL_DIRECTORY.resolve("dependencies");
|
private static final Path DEPENDENCIES_DIR_PATH = HMCL_DIRECTORY.resolve("dependencies");
|
||||||
private static final String DEFAULT_JFX_VERSION = "16";
|
|
||||||
private static final Map<String, String> JFX_DEPENDENCIES = new HashMap<>();
|
|
||||||
|
|
||||||
static {
|
private static String currentArchClassifier() {
|
||||||
addJfxDependency("base");
|
switch (OperatingSystem.CURRENT_OS) {
|
||||||
addJfxDependency("controls");
|
case LINUX:
|
||||||
addJfxDependency("fxml");
|
return "linux";
|
||||||
addJfxDependency("graphics");
|
case OSX:
|
||||||
addJfxDependency("media");
|
return "mac";
|
||||||
// Fix #874: Remove the dependency on javafx.swing
|
default:
|
||||||
// addJfxDependency("swing");
|
return "win";
|
||||||
addJfxDependency("web");
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void addJfxDependency(String name) {
|
public String module;
|
||||||
JFX_DEPENDENCIES.put("javafx." + name, jfxUrl(name));
|
public String groupId;
|
||||||
|
public String artifactId;
|
||||||
|
public String version;
|
||||||
|
public Map<String, String> sha1;
|
||||||
|
|
||||||
|
public String filename() {
|
||||||
|
return artifactId + "-" + version + "-" + currentArchClassifier() + ".jar";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String sha1() {
|
||||||
|
return sha1.get(currentArchClassifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String url() {
|
||||||
|
return REPOSITORY_URL + groupId.replace('.', '/') + "/" + artifactId + "/" + version + "/" + filename();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path localPath() {
|
||||||
|
return DEPENDENCIES_DIR_PATH.resolve(filename());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String DEPENDENCIES_LIST_FILE = "/assets/openjfx-dependencies.json";
|
||||||
|
|
||||||
|
private static List<DependencyDescriptor> readDependencies() {
|
||||||
|
String content;
|
||||||
|
try (InputStream in = SelfDependencyPatcher.class.getResourceAsStream(DEPENDENCIES_LIST_FILE)) {
|
||||||
|
content = IOUtils.readFullyAsString(in, UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
return new Gson().fromJson(content, TypeToken.getParameterized(List.class, DependencyDescriptor.class).getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<DependencyDescriptor> JFX_DEPENDENCIES = readDependencies();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patch in any missing dependencies, if any.
|
* Patch in any missing dependencies, if any.
|
||||||
*/
|
*/
|
||||||
@@ -125,17 +170,17 @@ public final class SelfDependencyPatcher {
|
|||||||
|
|
||||||
// Otherwise we're free to download in Java 11+
|
// Otherwise we're free to download in Java 11+
|
||||||
LOG.info("Missing JavaFX dependencies, attempting to patch in missing classes");
|
LOG.info("Missing JavaFX dependencies, attempting to patch in missing classes");
|
||||||
// Check if dependencies need to be downloaded
|
|
||||||
if (!hasCachedDependencies()) {
|
// Download missing dependencies
|
||||||
LOG.info(" - No local cache, downloading dependencies...");
|
List<DependencyDescriptor> missingDependencies = checkMissingDependencies();
|
||||||
|
if (!missingDependencies.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
fetchDependencies();
|
fetchDependencies(missingDependencies);
|
||||||
} catch (Exception ex) {
|
} catch (IOException e) {
|
||||||
throw new PatchException("Failed to download dependencies", ex);
|
throw new PatchException("Failed to download dependencies", e);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LOG.info(" - Local cache found!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the dependencies
|
// Add the dependencies
|
||||||
try {
|
try {
|
||||||
loadFromCache();
|
loadFromCache();
|
||||||
@@ -155,10 +200,16 @@ public final class SelfDependencyPatcher {
|
|||||||
*/
|
*/
|
||||||
private static void loadFromCache() throws IOException, ReflectiveOperationException {
|
private static void loadFromCache() throws IOException, ReflectiveOperationException {
|
||||||
LOG.info(" - Loading dependencies...");
|
LOG.info(" - Loading dependencies...");
|
||||||
List<Path> jarPaths = new ArrayList<>();
|
|
||||||
List<String> jfxDepFile = JFX_DEPENDENCIES.values().stream().map(SelfDependencyPatcher::getFileName).collect(Collectors.toList());
|
Set<String> modules = JFX_DEPENDENCIES.stream()
|
||||||
Files.walk(DEPENDENCIES_DIR_PATH).filter(p -> jfxDepFile.contains(p.getFileName().toString())).forEach(jarPaths::add);
|
.map(it -> it.module)
|
||||||
JavaFXPatcher.patch(JFX_DEPENDENCIES.keySet(), jarPaths.toArray(new Path[0]));
|
.collect(toSet());
|
||||||
|
|
||||||
|
Path[] jars = JFX_DEPENDENCIES.stream()
|
||||||
|
.map(it -> it.localPath())
|
||||||
|
.toArray(Path[]::new);
|
||||||
|
|
||||||
|
JavaFXPatcher.patch(modules, jars);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,106 +217,57 @@ public final class SelfDependencyPatcher {
|
|||||||
*
|
*
|
||||||
* @throws IOException When the files cannot be fetched or saved.
|
* @throws IOException When the files cannot be fetched or saved.
|
||||||
*/
|
*/
|
||||||
private static void fetchDependencies() throws Exception {
|
private static void fetchDependencies(List<DependencyDescriptor> dependencies) throws IOException {
|
||||||
// Get dir to store dependencies in
|
|
||||||
Path dependenciesDir = DEPENDENCIES_DIR_PATH;
|
|
||||||
if (!Files.isDirectory(dependenciesDir)) {
|
|
||||||
Files.createDirectories(dependenciesDir);
|
|
||||||
}
|
|
||||||
ProgressFrame dialog = new ProgressFrame(i18n("download.javafx"));
|
ProgressFrame dialog = new ProgressFrame(i18n("download.javafx"));
|
||||||
|
dialog.setVisible(true);
|
||||||
|
|
||||||
ForkJoinTask<Void> task = ForkJoinPool.commonPool().submit(() -> {
|
int progress = 0;
|
||||||
// Download each dependency
|
for (DependencyDescriptor dependency : dependencies) {
|
||||||
Collection<String> dependencies = JFX_DEPENDENCIES.values();
|
int currentProgress = ++progress;
|
||||||
int i = 1;
|
|
||||||
for (String dependencyUrlPath : dependencies) {
|
|
||||||
URL depURL = new URL(dependencyUrlPath);
|
|
||||||
Path dependencyFilePath = DEPENDENCIES_DIR_PATH.resolve(getFileName(dependencyUrlPath));
|
|
||||||
int finalI = i;
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
dialog.setStatus(dependencyUrlPath);
|
dialog.setStatus(dependency.url());
|
||||||
dialog.setProgress(finalI, dependencies.size());
|
dialog.setProgress(currentProgress, dependencies.size());
|
||||||
});
|
});
|
||||||
Files.copy(depURL.openStream(), dependencyFilePath, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
checksum(dependencyFilePath, dependencyUrlPath);
|
LOG.info("Downloading " + dependency.url());
|
||||||
i++;
|
Files.createDirectories(dependency.localPath().getParent());
|
||||||
|
Files.copy(new URL(dependency.url()).openStream(), dependency.localPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
verifyChecksum(dependency);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.dispose();
|
dialog.dispose();
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
dialog.setVisible(true);
|
|
||||||
|
|
||||||
task.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static List<DependencyDescriptor> checkMissingDependencies() {
|
||||||
* @return {@code true} when the dependencies directory has files in it.
|
List<DependencyDescriptor> missing = new ArrayList<>();
|
||||||
*/
|
|
||||||
private static boolean hasCachedDependencies() {
|
|
||||||
if (!Files.isDirectory(DEPENDENCIES_DIR_PATH))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (String url : JFX_DEPENDENCIES.values()) {
|
for (DependencyDescriptor dependency : JFX_DEPENDENCIES) {
|
||||||
Path dependencyFilePath = DEPENDENCIES_DIR_PATH.resolve(getFileName(url));
|
if (!Files.exists(dependency.localPath())) {
|
||||||
if (!Files.exists(dependencyFilePath))
|
missing.add(dependency);
|
||||||
return false;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checksum(dependencyFilePath, url);
|
verifyChecksum(dependency);
|
||||||
} catch (ChecksumMismatchException e) {
|
} catch (ChecksumMismatchException e) {
|
||||||
return false;
|
LOG.warning("Corrupted dependency " + dependency.filename() + ": " + e.getMessage());
|
||||||
} catch (IOException ignored) {
|
missing.add(dependency);
|
||||||
// Ignore other situations
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void checksum(Path dependencyFilePath, String dependencyUrlPath) throws IOException {
|
return missing;
|
||||||
String expectedHash = NetworkUtils.doGet(NetworkUtils.toURL(dependencyUrlPath + ".sha1"));
|
}
|
||||||
String actualHash = Hex.encodeHex(DigestUtils.digest("SHA-1", dependencyFilePath));
|
|
||||||
|
private static void verifyChecksum(DependencyDescriptor dependency) throws IOException, ChecksumMismatchException {
|
||||||
|
String expectedHash = dependency.sha1();
|
||||||
|
String actualHash = Hex.encodeHex(DigestUtils.digest("SHA-1", dependency.localPath()));
|
||||||
if (!expectedHash.equalsIgnoreCase(actualHash)) {
|
if (!expectedHash.equalsIgnoreCase(actualHash)) {
|
||||||
throw new ChecksumMismatchException("SHA-1", expectedHash, actualHash);
|
throw new ChecksumMismatchException("SHA-1", expectedHash, actualHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param url Full url path.
|
|
||||||
* @return Name of file at url.
|
|
||||||
*/
|
|
||||||
private static String getFileName(String url) {
|
|
||||||
return url.substring(url.lastIndexOf('/') + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param component Name of the component.
|
|
||||||
* @return Formed URL for the component.
|
|
||||||
*/
|
|
||||||
private static String jfxUrl(String component) {
|
|
||||||
return jfxUrl(component, DEFAULT_JFX_VERSION);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String jfxUrl(String component, String version) {
|
|
||||||
// https://repo1.maven.org/maven2/org/openjfx/javafx-%s/%s/javafx-%s-%s-%s.jar
|
|
||||||
return String.format("https://maven.aliyun.com/repository/central/org/openjfx/javafx-%s/%s/javafx-%s-%s-%s.jar",
|
|
||||||
component, version, component, version, getMvnName());
|
|
||||||
// return String.format("https://bmclapi.bangbang93.com/maven/org/openjfx/javafx-%s/%s/javafx-%s-%s-%s.jar",
|
|
||||||
// component, version, component, version, getMvnName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String getMvnName() {
|
|
||||||
switch (OperatingSystem.CURRENT_OS) {
|
|
||||||
case LINUX:
|
|
||||||
return "linux";
|
|
||||||
case OSX:
|
|
||||||
return "mac";
|
|
||||||
default:
|
|
||||||
return "win";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class PatchException extends Exception {
|
public static class PatchException extends Exception {
|
||||||
PatchException(String message, Throwable cause) {
|
PatchException(String message, Throwable cause) {
|
||||||
super(message, cause);
|
super(message, cause);
|
||||||
@@ -276,8 +278,6 @@ public final class SelfDependencyPatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static class ProgressFrame extends JDialog {
|
public static class ProgressFrame extends JDialog {
|
||||||
private int totalTasks = 0;
|
|
||||||
private int finishedTasks = 0;
|
|
||||||
|
|
||||||
private final JProgressBar progressBar;
|
private final JProgressBar progressBar;
|
||||||
private final JLabel progressText;
|
private final JLabel progressText;
|
||||||
|
|||||||
68
HMCL/src/main/resources/assets/openjfx-dependencies.json
Normal file
68
HMCL/src/main/resources/assets/openjfx-dependencies.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"module": "javafx.base",
|
||||||
|
"groupId": "org.openjfx",
|
||||||
|
"artifactId": "javafx-base",
|
||||||
|
"version": "16",
|
||||||
|
"sha1": {
|
||||||
|
"linux": "7d6b85ec89e99ea40c2a63bb1f23d43e05cd2557",
|
||||||
|
"mac": "ea97d8a5ab95d070df86de1215f29f08378c98c8",
|
||||||
|
"win": "e0564fea3f27dd3a10fa62e5005612333fc244bc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "javafx.controls",
|
||||||
|
"groupId": "org.openjfx",
|
||||||
|
"artifactId": "javafx-controls",
|
||||||
|
"version": "16",
|
||||||
|
"sha1": {
|
||||||
|
"linux": "116b127e512d23ddb84c62017c256e7f67f5b9eb",
|
||||||
|
"mac": "8df933b095bdd8203dc937e39e3a9f199654e47e",
|
||||||
|
"win": "65cdeae29c67d25932dcc66ca4f7d269923631ba"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "javafx.fxml",
|
||||||
|
"groupId": "org.openjfx",
|
||||||
|
"artifactId": "javafx-fxml",
|
||||||
|
"version": "16",
|
||||||
|
"sha1": {
|
||||||
|
"linux": "c7147c450773c3d4fd038ac9d1a6fdebbc3c11e0",
|
||||||
|
"mac": "7e4f07a331991485560673a398d6988cc805b67f",
|
||||||
|
"win": "369f646fc29d2223cc98cfc3c059f2409b3e8fcb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "javafx.graphics",
|
||||||
|
"groupId": "org.openjfx",
|
||||||
|
"artifactId": "javafx-graphics",
|
||||||
|
"version": "16",
|
||||||
|
"sha1": {
|
||||||
|
"linux": "01bf2fd9f083daa2f492c4c58c4f7d5acb6f4b7d",
|
||||||
|
"mac": "a45de3d40e217f7ba22cecf54abc281d13a172f6",
|
||||||
|
"win": "0cd18f8477818b40cfdbcfec7e2fc15f632f0a2f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "javafx.media",
|
||||||
|
"groupId": "org.openjfx",
|
||||||
|
"artifactId": "javafx-media",
|
||||||
|
"version": "16",
|
||||||
|
"sha1": {
|
||||||
|
"linux": "10edb5fd8eb64b3312b31225c396e1850b9d178b",
|
||||||
|
"mac": "9e0248083267cdc22b2b698f0b0b62b7399ad32d",
|
||||||
|
"win": "6d776658906fc25051734bc6b03f214cc72c808e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "javafx.web",
|
||||||
|
"groupId": "org.openjfx",
|
||||||
|
"artifactId": "javafx-web",
|
||||||
|
"version": "16",
|
||||||
|
"sha1": {
|
||||||
|
"linux": "522f626e1798a7d589d9b187ca5c74c5c2f8a0ee",
|
||||||
|
"mac": "32b0e13b649ea828e7d185187f178680cc04e564",
|
||||||
|
"win": "d2f80089a4ae1629a5ec14637abf4f6d70b96dab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
30
tools/generate-openjfx-dependencies.sh
Executable file
30
tools/generate-openjfx-dependencies.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
modules=(base controls fxml graphics media web)
|
||||||
|
arches=(linux mac win)
|
||||||
|
version=16
|
||||||
|
|
||||||
|
echo '['
|
||||||
|
for module in ${modules[@]}; do
|
||||||
|
if [[ ! "$module" == "${modules[0]}" ]]; then
|
||||||
|
echo ','
|
||||||
|
fi
|
||||||
|
echo ' {'
|
||||||
|
echo ' "module": "javafx.'$module'",'
|
||||||
|
echo ' "groupId": "org.openjfx",'
|
||||||
|
echo ' "artifactId": "javafx-'$module'",'
|
||||||
|
echo ' "version": "'$version'",'
|
||||||
|
echo ' "sha1": {'
|
||||||
|
for arch in ${arches[@]}; do
|
||||||
|
if [[ ! "$arch" == "${arches[0]}" ]]; then
|
||||||
|
echo ','
|
||||||
|
fi
|
||||||
|
echo -n ' "'$arch'": "'$(curl -Ss "https://repo1.maven.org/maven2/org/openjfx/javafx-$module/$version/javafx-$module-$version-$arch.jar.sha1")'"'
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo ' }'
|
||||||
|
echo -n ' }'
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo ']'
|
||||||
Reference in New Issue
Block a user