diff --git a/build.gradle b/build.gradle index 0ee1b23..001d040 100644 --- a/build.gradle +++ b/build.gradle @@ -3,12 +3,17 @@ plugins { id 'application' id 'io.freefair.lombok' version '9.1.0' id 'org.openjfx.javafxplugin' version '0.1.0' - id 'com.gluonhq.gluonfx-gradle-plugin' version '1.0.28' id 'com.github.johnrengelman.shadow' version '8.1.1' } group = 'game.s4mc' -version = '1.0' +version = '1.1' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(23) + } +} application { mainClass = 'game.s4mc.Launcher' diff --git a/src/main/java/game/s4mc/App.java b/src/main/java/game/s4mc/App.java index 1c2a153..cba3eb7 100644 --- a/src/main/java/game/s4mc/App.java +++ b/src/main/java/game/s4mc/App.java @@ -35,6 +35,8 @@ import java.net.URI; import java.util.List; import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.function.BiConsumer; import java.util.prefs.Preferences; @@ -146,6 +148,7 @@ private List createTopBarSettings() { languageSelector.setMaxWidth(120); languageSelector.getItems().addAll( "English", + "한국어", "Русский", "Українська" ); @@ -222,7 +225,7 @@ private void processNextMod(List queue, int index, Button btnControl) { .thenAccept(remoteVersion -> { Platform.runLater(() -> { if (remoteVersion != null) { - boolean match = remoteVersion.equals(mod.getVersion()); + boolean match = VersionChecker.versionsMatch(mod.getVersion(), remoteVersion); mod.setUpToDate(match); if (!match) { mod.setNewVersion(remoteVersion); @@ -233,12 +236,17 @@ private void processNextMod(List queue, int index, Button btnControl) { }); }) .whenComplete((_, _) -> { - try { - Thread.sleep(2000); - } catch (InterruptedException ignored) { - } - - processNextMod(queue, index + 1, btnControl); + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "s4mc-rate-limit"); + t.setDaemon(true); + return t; + }); + scheduler.schedule( + () -> processNextMod(queue, index + 1, btnControl), + VersionChecker.getRateLimitMs(), + java.util.concurrent.TimeUnit.MILLISECONDS + ); + scheduler.shutdown(); }); } diff --git a/src/main/java/game/s4mc/cell/ModListCell.java b/src/main/java/game/s4mc/cell/ModListCell.java index a146749..2ab24b2 100644 --- a/src/main/java/game/s4mc/cell/ModListCell.java +++ b/src/main/java/game/s4mc/cell/ModListCell.java @@ -211,7 +211,7 @@ private void setupActions() { if (remoteVersion == null) { versionBadge.setStyle("-fx-background-color: #f39c12; -fx-background-radius: 20;"); } else { - boolean match = remoteVersion.equals(mod.getVersion()); + boolean match = VersionChecker.versionsMatch(mod.getVersion(), remoteVersion); mod.setUpToDate(match); if (!match) { mod.setNewVersion(remoteVersion); diff --git a/src/main/java/game/s4mc/manager/DataManager.java b/src/main/java/game/s4mc/manager/DataManager.java index 5f9313d..35bbb31 100644 --- a/src/main/java/game/s4mc/manager/DataManager.java +++ b/src/main/java/game/s4mc/manager/DataManager.java @@ -20,13 +20,17 @@ import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; public class DataManager { + private static final Logger LOG = Logger.getLogger(DataManager.class.getName()); private static DataManager instance; - private static final String MODS_FILE = "mods.json"; - private static final String RULES_FILE = "rules.json"; - private static final String COVERS_DIR = "covers"; + private static final Path DATA_DIR = Paths.get(System.getProperty("user.home"), ".sims4modchecker"); + private static final String MODS_FILE = DATA_DIR.resolve("mods.json").toString(); + private static final String RULES_FILE = DATA_DIR.resolve("rules.json").toString(); + private static final String COVERS_DIR = DATA_DIR.resolve("covers").toString(); private final ObjectMapper mapper = new ObjectMapper(); @Getter @@ -43,13 +47,33 @@ public static DataManager getInstance() { private DataManager() { createDirs(); + migrateLegacyFiles(); } private void createDirs() { try { + Files.createDirectories(DATA_DIR); Files.createDirectories(Paths.get(COVERS_DIR)); } catch (IOException e) { - e.printStackTrace(); + LOG.log(Level.SEVERE, "Failed to create data directories", e); + } + } + + private void migrateLegacyFiles() { + migrateSingleFile("mods.json", MODS_FILE); + migrateSingleFile("rules.json", RULES_FILE); + } + + private void migrateSingleFile(String legacyName, String newPath) { + File legacy = new File(legacyName); + File target = new File(newPath); + if (legacy.exists() && !target.exists()) { + try { + Files.move(legacy.toPath(), target.toPath()); + LOG.info("Migrated " + legacyName + " to " + newPath); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to migrate " + legacyName, e); + } } } @@ -57,7 +81,7 @@ public void save() { try { mapper.writerWithDefaultPrettyPrinter().writeValue(new File(MODS_FILE), modList); } catch (IOException e) { - e.printStackTrace(); + LOG.log(Level.SEVERE, "Failed to save mods.json", e); } } @@ -68,50 +92,40 @@ public void load() { public void reloadRules() { rulesList.clear(); + rulesList.addAll( - // Schema.org -> ... - new Rule( - "019b4232-629f-7994-9bfa-ab02357b5005", - "Synthira", - "Itemprop->Version", - Strategy.CSS_TEXT, - "[itemprop=version]" - ), - - // Версия файла: ... - new Rule( - "019b4232-7e56-7f5d-9c74-e13c51a42a5d", - "Synthira", - "Версия файла", - Strategy.REGEX_HTML, - "Версия файла:\\s*<[^>]+>([^<]+)" - ), - - // Обновлено: ... - new Rule( - "019b4232-8e16-797e-9fb9-f05a9e2a4811", - "Synthira", - "Дата обновления", Strategy.REGEX_TEXT, - "Обновлено:\\s*(\\d{2}\\.\\d{2}\\.\\d{4}(?:,\\s*\\d{2}:\\d{2})?)" - ), - - // Обновлена версия X - new Rule( - "019b4232-b0ee-7cb6-9bc6-27f938ba8c55", - "Synthira", "Обновлена версия", - Strategy.REGEX_TEXT, - "Обновлена версия\\s+([0-9a-zA-Z\\./]+)" - ), - - // v1.0.0 - new Rule( - "019b4232-c111-75b5-8b44-54943df3509f", - "Synthira", - "vX.X.X", - Strategy.REGEX_TEXT, - "v?(\\d+\\.\\d+(?:\\.\\d+)?)" - ) + // --- Synthira (original) --- + new Rule("019b4232-629f-7994-9bfa-ab02357b5005", "Synthira", "Itemprop->Version", Strategy.CSS_TEXT, "[itemprop=version]"), + new Rule("019b4232-7e56-7f5d-9c74-e13c51a42a5d", "Synthira", "Версия файла", Strategy.REGEX_HTML, "Версия файла:\\s*<[^>]+>([^<]+)"), + new Rule("019b4232-8e16-797e-9fb9-f05a9e2a4811", "Synthira", "Дата обновления", Strategy.REGEX_TEXT, "Обновлено:\\s*(\\d{2}\\.\\d{2}\\.\\d{4}(?:,\\s*\\d{2}:\\d{2})?)"), + new Rule("019b4232-b0ee-7cb6-9bc6-27f938ba8c55", "Synthira", "Обновлена версия", Strategy.REGEX_TEXT, "Обновлена версия\\s+([0-9a-zA-Z\\./]+)"), + + // --- MCCC (deaderpool-mccc.com) --- + new Rule("a1000001-mccc-0001-0001-000000000001", "MCCC", "MCCC Download Page Version", Strategy.REGEX_TEXT, "MC Command Center\\s+(\\d{4}\\.\\d+\\.\\d+)"), + + // --- WickedWhims (wickedwhimsmod.com / itch.io) --- + new Rule("a1000002-wwms-0001-0001-000000000001", "WickedWhims", "WickedWhims Version", Strategy.REGEX_TEXT, "WickedWhims\\s+(v\\d+[a-z]*)"), + new Rule("a1000002-wwms-0001-0002-000000000001", "WickedWhims", "WickedWhims itch.io", Strategy.REGEX_TEXT, "WickedWhims\\s+(v\\d+[a-z]*)\\s*-\\s*PUBLIC"), + + // --- Basemental (basementalcc.com) --- + new Rule("a1000003-bsmn-0001-0001-000000000001", "Basemental", "Basemental Version", Strategy.REGEX_TEXT, "(\\d+\\.\\d+\\.\\d+)\\s*\\(PUBLIC\\)"), + + // --- NisaK Wicked Perversions (wicked.cc) --- + new Rule("a1000004-nisa-0001-0001-000000000001", "NisaK", "Wicked Perversions Version", Strategy.REGEX_TEXT, "Current Version:\\s*([\\w.]+)"), + new Rule("a1000004-nisa-0001-0002-000000000001", "NisaK", "WP Download File", Strategy.REGEX_TEXT, "NisaWickedPerversionsvLL[_.]([\\d_]+(?:[a-z])?)\\.zip"), + + // --- Lumpinou (lumpinoumods.com / patreon) --- + new Rule("a1000005-lump-0001-0001-000000000001", "Lumpinou", "RPO Collection Version", Strategy.REGEX_TEXT, "RPO\\s+Collection\\s+v([\\d.]+)"), + new Rule("a1000005-lump-0001-0002-000000000001", "Lumpinou", "Lumpinou Patreon Version", Strategy.REGEX_TEXT, "(?:v|version\\s+)([\\d.]+)\\s*(?:\\(|\\[|-)"), + + // --- SimRealist (itch.io) --- + new Rule("a1000006-simr-0001-0001-000000000001", "SimRealist", "SimRealist itch.io", Strategy.REGEX_TEXT, "SimRealist[_\\s]+[-_\\w]+[_\\s]+(\\d+\\.\\d+\\.\\d+\\.\\d+)\\.zip"), + new Rule("a1000006-simr-0001-0002-000000000001", "SimRealist", "SimRealist Page Version", Strategy.REGEX_TEXT, "Version\\s+(\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?)"), + + // --- Generic (last resort) --- + new Rule("019b4232-c111-75b5-8b44-54943df3509f", "Generic", "vX.X.X", Strategy.REGEX_TEXT, "v?(\\d+\\.\\d+(?:\\.\\d+)?)") ); + rulesList.addAll(loadJson(RULES_FILE, mapper.getTypeFactory().constructCollectionType(List.class, Rule.class))); } @@ -123,7 +137,7 @@ private List loadJson(String fileName, JavaType javaType) { try { return mapper.readValue(file, javaType); } catch (IOException e) { - e.printStackTrace(); + LOG.log(Level.WARNING, "Failed to load " + fileName, e); } return new ArrayList<>(); } @@ -148,14 +162,21 @@ public String saveImage(File originalImage) { Files.copy(originalImage.toPath(), dest); return dest.toString(); } catch (IOException e) { - e.printStackTrace(); + LOG.log(Level.WARNING, "Failed to save image", e); return null; } } public String downloadAndSaveImage(String imageUrl) { try { - URL url = new URI(imageUrl.trim()).toURL(); + URI uri = new URI(imageUrl.trim()); + String scheme = uri.getScheme(); + if (scheme == null || (!scheme.equals("http") && !scheme.equals("https"))) { + LOG.warning("Rejected non-HTTP image URL: " + imageUrl); + return null; + } + + URL url = uri.toURL(); String fileName = "web_" + System.currentTimeMillis() + ".png"; Path dest = Paths.get(COVERS_DIR, fileName); @@ -164,8 +185,12 @@ public String downloadAndSaveImage(String imageUrl) { } return dest.toString(); } catch (Exception e) { - e.printStackTrace(); + LOG.log(Level.WARNING, "Failed to download image: " + imageUrl, e); return null; } } + + public static Path getDataDir() { + return DATA_DIR; + } } \ No newline at end of file diff --git a/src/main/java/game/s4mc/service/VersionChecker.java b/src/main/java/game/s4mc/service/VersionChecker.java index f7cc67d..2aa7197 100644 --- a/src/main/java/game/s4mc/service/VersionChecker.java +++ b/src/main/java/game/s4mc/service/VersionChecker.java @@ -9,75 +9,118 @@ import java.io.IOException; import java.util.*; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.*; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; public class VersionChecker { + private static final Logger LOG = Logger.getLogger(VersionChecker.class.getName()); private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; + private static final int TIMEOUT_MS = 10_000; + private static final int RATE_LIMIT_MS = 1500; - private static final Map scanForVersionsCache = new HashMap<>(); + private static final ExecutorService HTTP_POOL = Executors.newFixedThreadPool(3, r -> { + Thread t = new Thread(r, "s4mc-http"); + t.setDaemon(true); + return t; + }); + + private static final Map> pageCache = new ConcurrentHashMap<>(); + + public static void clearCache() { + pageCache.clear(); + } + + public static void clearCache(String url) { + pageCache.remove(url); + } public static CompletableFuture> scanForVersions(String url) { return CompletableFuture.supplyAsync(() -> { List results = new ArrayList<>(); - try { - Document document = scanForVersionsCache.computeIfAbsent( - url, - _ -> { - try { - return Jsoup.connect(url) - .userAgent(USER_AGENT) - .timeout(5000) - .get(); - } catch (IOException e) { - e.printStackTrace(); - return null; - } - } - ); - - String html = document.html(); - String text = document.text(); - - DataManager.getInstance().reloadRules(); - for (Rule rule : DataManager.getInstance().getRulesList()) { - applyRule(rule, document, html, text).ifPresent(version -> results.add(new ScanResult(rule.uuid(), rule.service(), rule.name(), version.trim()))); - } + Optional cached = pageCache.get(url); + Document document; + + if (cached != null) { + document = cached.orElse(null); + } else { + document = fetchDocument(url); + pageCache.put(url, Optional.ofNullable(document)); + } + + if (document == null) { + LOG.warning("Failed to fetch URL for version scan: " + url); + return results; + } + + String html = document.html(); + String text = document.text(); - } catch (Exception e) { - e.printStackTrace(); + for (Rule rule : DataManager.getInstance().getRulesList()) { + applyRule(rule, document, html, text) + .ifPresent(version -> results.add( + new ScanResult(rule.uuid(), rule.service(), rule.name(), version.trim()))); } return results; - }); + }, HTTP_POOL); } public static CompletableFuture checkVersion(String url, String ruleUUID) { return CompletableFuture.supplyAsync(() -> { - try { - Rule rule = DataManager.getInstance() - .getRulesList() - .stream() - .filter(r -> r.uuid().equals(ruleUUID)) - .findFirst() - .orElse(null); - - if (rule == null) { - return null; - } + Rule rule = DataManager.getInstance() + .getRulesList() + .stream() + .filter(r -> r.uuid().equals(ruleUUID)) + .findFirst() + .orElse(null); - Document document = Jsoup.connect(url) - .userAgent(USER_AGENT) - .timeout(5000) - .get(); + if (rule == null) { + LOG.warning("Rule not found: " + ruleUUID); + return null; + } - return applyRule(rule, document, document.html(), document.text()) - .map(String::trim) - .orElse(null); - } catch (IOException e) { + pageCache.remove(url); + Document document = fetchDocument(url); + if (document == null) { + LOG.warning("Failed to fetch URL for version check: " + url); return null; } - }); + + return applyRule(rule, document, document.html(), document.text()) + .map(String::trim) + .orElse(null); + }, HTTP_POOL); + } + + public static boolean versionsMatch(String local, String remote) { + if (local == null || remote == null) return false; + String a = normalizeVersion(local); + String b = normalizeVersion(remote); + return a.equals(b); + } + + static String normalizeVersion(String v) { + if (v == null) return ""; + String s = v.trim().toLowerCase(); + s = s.replaceFirst("^v\\.?", ""); + s = s.replaceAll("[_\\s]+", "."); + s = s.replaceAll("\\.+$", ""); + return s; + } + + private static Document fetchDocument(String url) { + try { + return Jsoup.connect(url) + .userAgent(USER_AGENT) + .timeout(TIMEOUT_MS) + .followRedirects(true) + .get(); + } catch (IOException e) { + LOG.log(Level.WARNING, "HTTP fetch failed for " + url, e); + return null; + } } private static Optional applyRule(Rule rule, Document document, String html, String text) { @@ -91,19 +134,28 @@ private static Optional applyRule(Rule rule, Document document, String h } case REGEX_HTML -> { Matcher matcher = Pattern.compile(rule.pattern(), Pattern.CASE_INSENSITIVE).matcher(html); - if (matcher.find()) { + if (matcher.find() && matcher.groupCount() >= 1) { return Optional.of(matcher.group(1)); } } case REGEX_TEXT -> { Matcher matcher = Pattern.compile(rule.pattern(), Pattern.CASE_INSENSITIVE).matcher(text); - if (matcher.find()) { + if (matcher.find() && matcher.groupCount() >= 1) { return Optional.of(matcher.group(1)); } } } - } catch (Exception ignored) { + } catch (Exception e) { + LOG.log(Level.FINE, "Rule '" + rule.name() + "' failed on pattern: " + rule.pattern(), e); } return Optional.empty(); } + + public static int getRateLimitMs() { + return RATE_LIMIT_MS; + } + + public static void shutdown() { + HTTP_POOL.shutdownNow(); + } } \ No newline at end of file diff --git a/src/main/java/game/s4mc/utils/AppUtils.java b/src/main/java/game/s4mc/utils/AppUtils.java index 77fda92..bfd5765 100644 --- a/src/main/java/game/s4mc/utils/AppUtils.java +++ b/src/main/java/game/s4mc/utils/AppUtils.java @@ -8,6 +8,7 @@ public static String getSystemLanguageName() { return switch (Locale.getDefault().getLanguage()) { case "ru" -> "Русский"; case "uk" -> "Українська"; + case "ko" -> "한국어"; default -> "English"; }; } @@ -16,6 +17,7 @@ public static String getLanguageCodeFromName(String languageName) { return switch (languageName) { case "Русский" -> "ru"; case "Українська" -> "uk"; + case "한국어" -> "ko"; default -> "en"; }; } diff --git a/src/main/java/game/s4mc/utils/FXUtils.java b/src/main/java/game/s4mc/utils/FXUtils.java index d5b816a..728610f 100644 --- a/src/main/java/game/s4mc/utils/FXUtils.java +++ b/src/main/java/game/s4mc/utils/FXUtils.java @@ -2,18 +2,18 @@ import javafx.application.Platform; -import java.util.Timer; -import java.util.TimerTask; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; public class FXUtils { + private static final ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "s4mc-fx-delay"); + t.setDaemon(true); + return t; + }); - public static void runLaterDelayed(Runnable runnable, long delay) { - TimerTask task = new TimerTask() { - @Override - public void run() { - Platform.runLater(runnable); - } - }; - new Timer().schedule(task, delay); + public static void runLaterDelayed(Runnable runnable, long delayMs) { + SCHEDULER.schedule(() -> Platform.runLater(runnable), delayMs, TimeUnit.MILLISECONDS); } } diff --git a/src/main/resources/i18n_ko.properties b/src/main/resources/i18n_ko.properties new file mode 100644 index 0000000..2b96106 --- /dev/null +++ b/src/main/resources/i18n_ko.properties @@ -0,0 +1,34 @@ +i18n.app_theme=\uC571 \uD14C\uB9C8 +i18n.app_language=\uC571 \uC5B8\uC5B4 +i18n.check_updates=\uC5C5\uB370\uC774\uD2B8 \uD655\uC778 +i18n.check_all_updates=\uC804\uCCB4 \uBAA8\uB4DC \uC5C5\uB370\uC774\uD2B8 \uD655\uC778 +i18n.mark_up_to_date=\uCD5C\uC2E0\uC73C\uB85C \uD45C\uC2DC +i18n.edit=\uD3B8\uC9D1 +i18n.open_folder=\uD3F4\uB354 \uC5F4\uAE30 +i18n.open_in_browser=\uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uC5F4\uAE30 +i18n.delete=\uC0AD\uC81C +i18n.search_name_or_desc=\uC774\uB984 \uB610\uB294 \uC124\uBA85\uC73C\uB85C \uAC80\uC0C9... +i18n.add_mod=\uBAA8\uB4DC \uCD94\uAC00 +i18n.edit_mod=\uBAA8\uB4DC \uD3B8\uC9D1 +i18n.save=\uC800\uC7A5 +i18n.add=\uCD94\uAC00 +i18n.cancel=\uCDE8\uC18C +i18n.mod_folder=\uBAA8\uB4DC \uD3F4\uB354 \uACBD\uB85C +i18n.folder=\uD3F4\uB354 +i18n.name=\uC774\uB984 +i18n.site_url=\uC0AC\uC774\uD2B8 URL +i18n.url=URL +i18n.current_rule=\uD604\uC7AC \uADDC\uCE59 +i18n.loading=\uB85C\uB529 \uC911... +i18n.loading_error=\uB85C\uB529 \uC624\uB958 +i18n.error=\uC624\uB958 +i18n.version=\uBC84\uC804 +i18n.obtain_version=\uBC84\uC804 \uAC00\uC838\uC624\uAE30 +i18n.versions_not_found=\uBC84\uC804\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 +i18n.searching=\uAC80\uC0C9 \uC911... +i18n.check_version_first=<- \uBA3C\uC800 \uBC84\uC804\uC744 \uAC00\uC838\uC640\uC57C \uD569\uB2C8\uB2E4 +i18n.description=\uC124\uBA85 +i18n.cover=\uCEE4\uBC84 +i18n.drag_n_drop_cover=\uCEE4\uBC84 \uC774\uBBF8\uC9C0\uB97C \uC5EC\uAE30\uC5D0 \uB4DC\uB798\uADF8\uD558\uC138\uC694\n(\uB85C\uCEEC \uD30C\uC77C \uB610\uB294 \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C) +i18n.deleting=\uC0AD\uC81C +i18n.deleting_info=%s \uBAA8\uB4DC\uB97C \uBAA9\uB85D\uC5D0\uC11C \uC81C\uAC70\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C?\n\n\uB514\uC2A4\uD06C\uC758 \uD30C\uC77C\uC740 \uC0AD\uC81C\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4!