From 1b67ab8f6965d26e13a561a06f949d58cc75040c Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:17:58 -0700 Subject: [PATCH 01/15] =?UTF-8?q?chore(deps):=20=E5=8D=87=E7=BA=A7=20Prism?= =?UTF-8?q?=200.2.2=20/=20Cadence=200.1.2=20=E5=B9=B6=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文本框 Ctrl/Cmd+A/C/X/V 映射为编辑键,不再把字母输入进去;补 Home/End 和 Shift 修饰键 - 剪贴板读写接到系统剪贴板(GuiScreen.get/setClipboardString) - ConfigProfilesBridge 改为返回结构化 Result,界面按 ok 着色 - 字体重载后清空 TextFit 测量缓存 - 补齐 Prism 新增的音乐/账号 i18n key(中英) - CI 从源码安装 Cadence 0.1.2;Prism/Cadence 的 ref 先留 TODO,合并后更新 --- .github/workflows/ci-release.yml | 21 ++++++- build.gradle.kts | 7 +-- .../java/top/fpsmaster/font/FontManager.java | 2 + .../ui/click/ConfigProfilesScreen.java | 36 ++++++------ .../java/top/fpsmaster/ui/kit/EdgeInput.java | 6 +- .../java/top/fpsmaster/ui/kit/EdgeUi.java | 36 +++++++++++- .../assets/minecraft/client/lang/en_us.lang | 57 +++++++++++++++++++ .../assets/minecraft/client/lang/zh_cn.lang | 57 +++++++++++++++++++ 8 files changed, 196 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 371a0fcf..f4270ed9 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -152,7 +152,9 @@ jobs: uses: actions/checkout@v4 with: repository: FPSMasterTeam/fpsmaster-prism - ref: 1a3b61b2d75b9be6bed583ad08b52fb0016e8b83 + # TODO: Edge 依赖 prism 0.2.2,这里先指向 fix/prism-review 的提交; + # Prism 合并到主分支后改成合并后的提交(或 0.2.2 的 tag)。 + ref: e3b6e244f912828bd11d777683c47efd94c79121 path: fpsmaster-prism - name: Install Prism UI @@ -162,6 +164,23 @@ jobs: chmod +x ./gradlew ./gradlew publishToMavenLocal + # Cadence (top.fpsmaster:music-api) 0.1.2 没有发布到 JitPack,和 Prism 一样从源码装进 mavenLocal。 + - name: Check out Cadence + if: steps.decision.outputs.should_build == 'true' + uses: actions/checkout@v4 + with: + repository: FPSMasterTeam/Cadence + # TODO: 先指向 fix/cadence-review 的提交;Cadence 合并并打 0.1.2 tag 后改成合并后的提交。 + ref: 9e60bab7834a73ed4ceead49ecec2d07af708b80 + path: cadence + + - name: Install Cadence + if: steps.decision.outputs.should_build == 'true' + working-directory: cadence + run: | + chmod +x ./gradlew + ./gradlew publishToMavenLocal + - name: Inject SMTC native libraries if: steps.decision.outputs.should_build == 'true' uses: actions/download-artifact@v4 diff --git a/build.gradle.kts b/build.gradle.kts index f0dfe417..e1a06f0e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -110,13 +110,12 @@ dependencies { isTransitive = false } - shadowImpl("top.fpsmaster:prism:0.2.1") + shadowImpl("top.fpsmaster:prism:0.2.2") // 音乐能力:Cadence 数据客户端(网易云/QQ 搜索/直链/歌词/歌单/登录)。 - // 由 JitPack 托管 (FPSMasterTeam/Cadence),坐标即 com.github.::; - // 源码里的包名仍是 top.fpsmaster.music.*。 + // 0.1.2 还没有发到 JitPack(没有 tag),先从 mavenLocal 取(Cadence 仓库 publishToMavenLocal)。 // gson 由 MC classpath 提供,故不传递依赖;Kotlin 运行时单独引入。 - shadowImpl("com.github.FPSMasterTeam:Cadence:v0.1.1") { + shadowImpl("top.fpsmaster:music-api:0.1.2") { isTransitive = false } shadowImpl("org.jetbrains.kotlin:kotlin-stdlib:2.4.0") { diff --git a/src/main/java/top/fpsmaster/font/FontManager.java b/src/main/java/top/fpsmaster/font/FontManager.java index a43b3cf8..040621d3 100644 --- a/src/main/java/top/fpsmaster/font/FontManager.java +++ b/src/main/java/top/fpsmaster/font/FontManager.java @@ -60,6 +60,8 @@ public void load() { s28 = createBaseRenderer(28); s36 = createBaseRenderer(36); s40 = createBaseRenderer(40); + // Prism 按字号缓存文本宽度和截断结果;换字体后旧宽度全部作废。 + top.fpsmaster.prism.widget.TextFit.clearCache(); } public UFontRenderer getFont(int size) { diff --git a/src/main/java/top/fpsmaster/ui/click/ConfigProfilesScreen.java b/src/main/java/top/fpsmaster/ui/click/ConfigProfilesScreen.java index bbab06f0..d5e4bd26 100644 --- a/src/main/java/top/fpsmaster/ui/click/ConfigProfilesScreen.java +++ b/src/main/java/top/fpsmaster/ui/click/ConfigProfilesScreen.java @@ -71,87 +71,87 @@ public int hudModules() { @Override public boolean isDefault(String name) { return ConfigProfileUtils.CURRENT_CONFIG.equals(name); } @Override - public String load(String name) { + public Result load(String name) { try { ConfigProfileUtils.loadProfile(name); - return String.format(i18n("configprofiles.status.loaded"), name); + return Result.ok(String.format(i18n("configprofiles.status.loaded"), name)); } catch (Exception e) { return failure("load", "configprofiles.status.load_failed", e); } } @Override - public String delete(String name) { + public Result delete(String name) { try { ConfigProfileUtils.deleteProfile(name); if (name.equals(activeName())) ConfigProfileUtils.loadProfileWithoutSavingCurrent(ConfigProfileUtils.CURRENT_CONFIG); - return String.format(i18n("configprofiles.status.deleted"), name); + return Result.ok(String.format(i18n("configprofiles.status.deleted"), name)); } catch (Exception e) { return failure("delete", "configprofiles.status.delete_failed", e); } } @Override - public String rename(String from, String to) { + public Result rename(String from, String to) { try { String renamed = ConfigProfileUtils.renameProfile(from, to, ""); if (from.equals(activeName())) ConfigProfileUtils.setActiveProfileName(renamed); - return String.format(i18n("configprofiles.status.renamed"), renamed); + return Result.ok(String.format(i18n("configprofiles.status.renamed"), renamed)); } catch (FileException e) { return failure("rename", "configprofiles.status.rename_failed", e); } } @Override - public String create(String name) { + public Result create(String name) { try { String created = ConfigProfileUtils.saveCurrentAs(name); - return String.format(i18n("configprofiles.status.renamed"), created); + return Result.ok(String.format(i18n("configprofiles.status.renamed"), created)); } catch (FileException e) { return failure("create", "configprofiles.status.rename_failed", e); } } @Override - public String exportActive() { + public Result exportActive() { FileDialog dialog = new FileDialog((Frame) null, i18n("configprofiles.filedialog.export"), FileDialog.SAVE); dialog.setDirectory(ConfigProfileUtils.getProfileDir().getAbsolutePath()); dialog.setFile(activeName() + ".json"); dialog.setVisible(true); - if (dialog.getFile() == null) return ""; + if (dialog.getFile() == null) return Result.ok(""); File target = new File(dialog.getDirectory(), dialog.getFile()); if (!target.getName().toLowerCase(Locale.ROOT).endsWith(".json")) { target = new File(target.getParentFile(), target.getName() + ".json"); } try { ConfigProfileUtils.exportActiveProfile(target); - return String.format(i18n("configprofiles.status.exported"), target.getName()); + return Result.ok(String.format(i18n("configprofiles.status.exported"), target.getName())); } catch (FileException e) { return failure("export", "configprofiles.status.export_failed", e); } } @Override - public String importFile() { + public Result importFile() { FileDialog dialog = new FileDialog((Frame) null, i18n("configprofiles.filedialog.import"), FileDialog.LOAD); dialog.setDirectory(ConfigProfileUtils.getProfileDir().getAbsolutePath()); dialog.setFilenameFilter((dir, name) -> name.toLowerCase(Locale.ROOT).endsWith(".json")); dialog.setVisible(true); - if (dialog.getFile() == null) return ""; + if (dialog.getFile() == null) return Result.ok(""); try { String name = ConfigProfileUtils.importProfile(new File(dialog.getDirectory(), dialog.getFile())); ConfigProfileUtils.loadProfile(name); - return String.format(i18n("configprofiles.status.imported"), name); + return Result.ok(String.format(i18n("configprofiles.status.imported"), name)); } catch (Exception e) { return failure("import", "configprofiles.status.import_failed", e); } } @Override - public String resetAllOff() { + public Result resetAllOff() { try { ConfigProfileUtils.resetActiveProfileToDefaults(); - return String.format(i18n("configprofiles.status.alloff"), activeName()); + return Result.ok(String.format(i18n("configprofiles.status.alloff"), activeName())); } catch (FileException e) { return failure("reset", "configprofiles.status.alloff_failed", e); } @@ -165,9 +165,9 @@ private File activeFile() { } } - private String failure(String action, String key, Exception error) { + private Result failure(String action, String key, Exception error) { ClientLogger.error("Failed to " + action + " config profile: " + error.getMessage()); - return i18n(key); + return Result.failed(i18n(key)); } } } diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java b/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java index d88bdd70..2bbe352e 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java @@ -1,5 +1,6 @@ package top.fpsmaster.ui.kit; +import net.minecraft.client.gui.GuiScreen; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; import top.fpsmaster.prism.input.FrameInput; import top.fpsmaster.prism.input.Input; @@ -116,10 +117,11 @@ public String consumeTypedChars() { } public String clipboard() { - return fallback.clipboard(); + String text = GuiScreen.getClipboardString(); + return text == null ? "" : text; } public void setClipboard(String text) { - fallback.setClipboard(text); + GuiScreen.setClipboardString(text == null ? "" : text); } } diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java b/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java index 33666017..d1ea27cb 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java @@ -1,5 +1,6 @@ package top.fpsmaster.ui.kit; +import net.minecraft.client.gui.GuiScreen; import top.fpsmaster.features.impl.interfaces.ClientSettings; import top.fpsmaster.features.settings.impl.BindKeys; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; @@ -32,6 +33,8 @@ public static Theme theme() { public static void begin(ScaledGuiScreen screen) { float w = screen == null ? 0f : screen.guiWidth; float h = screen == null ? 0f : screen.guiHeight; + // Shift 是修饰键:Prism 的文本框用 isKeyDown(SHIFT) 决定方向键/Home/End 是否扩选。 + fallback.setKeyDown(Keys.SHIFT, GuiScreen.isShiftKeyDown()); current = new UiFrame(new EdgeHost(screen, fallback, w, h), theme()); screenFrame = true; } @@ -70,10 +73,17 @@ public static boolean hasFrame() { } public static void keyTyped(char typedChar, int keyCode) { + fallback.pressRawKey(keyCode); + // Ctrl+A/C/X/V(macOS 上 GuiScreen.isCtrlKeyDown 认的是 Cmd)是编辑快捷键: + // 只发虚拟键,不再把字母 type 进去——macOS 上 Cmd+V 的 typedChar 就是 'v'。 + int shortcut = editingShortcut(keyCode); + if (shortcut != 0) { + fallback.pressKey(shortcut); + return; + } if (!Character.isISOControl(typedChar)) { fallback.type(String.valueOf(typedChar)); } - fallback.pressRawKey(keyCode); switch (keyCode) { case org.lwjgl.input.Keyboard.KEY_BACK: fallback.pressKey(Keys.BACKSPACE); @@ -94,11 +104,35 @@ public static void keyTyped(char typedChar, int keyCode) { case org.lwjgl.input.Keyboard.KEY_DELETE: fallback.pressKey(Keys.DELETE); break; + case org.lwjgl.input.Keyboard.KEY_HOME: + fallback.pressKey(Keys.HOME); + break; + case org.lwjgl.input.Keyboard.KEY_END: + fallback.pressKey(Keys.END); + break; default: break; } } + private static int editingShortcut(int keyCode) { + if (!GuiScreen.isCtrlKeyDown() || GuiScreen.isShiftKeyDown() || GuiScreen.isAltKeyDown()) { + return 0; + } + switch (keyCode) { + case org.lwjgl.input.Keyboard.KEY_A: + return Keys.SELECT_ALL; + case org.lwjgl.input.Keyboard.KEY_C: + return Keys.COPY; + case org.lwjgl.input.Keyboard.KEY_X: + return Keys.CUT; + case org.lwjgl.input.Keyboard.KEY_V: + return Keys.PASTE; + default: + return 0; + } + } + /** * 鼠标中键/侧键当一次按键喂给绑定界面。左/右键不喂——「点一下开始捕获」本身就是左键, * 喂进去会同一帧把自己绑掉。 diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 42c1786c..9da1ad73 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -1300,3 +1300,60 @@ signin.failed.throttled=Too many attempts, please try again later signin.failed.server=The server is temporarily unavailable, please try again later mainmenu.account.fps=FPSMaster account mainmenu.account.fps.signin=Sign in to FPSMaster +music.settings.title=Music Settings +music.settings.subtitle=Playback and lyrics +music.settings.lyricsize=Lyric size +music.settings.lyriclines=Visible lines +music.settings.lines.unit=%d lines +music.settings.hud=Lyrics HUD +music.settings.translation=Show translation +music.settings.smooth=Smooth scrolling +music.settings.background=Lyric background +music.settings.mode=Playback mode +music.mode.sequential=In order +music.mode.shuffle=Shuffle +music.mode.repeatone=Repeat one +music.source.netease=NetEase +music.source.qq=QQ Music +music.tab.discover=Discover +music.tab.playlists=Playlists +music.tab.search=Search +music.tab.local=Local +music.search.placeholder=Search songs or artists… +music.search.hint=Type a keyword and press Enter +music.list.loading=Loading… +music.list.empty=No songs +music.list.needlogin=Log in to view +music.list.error=Failed to load +music.list.retry=Retry +music.playlists.empty=No playlists +music.local.import=Import music +music.local.title=Local music +music.local.empty=Import MP3, WAV or AIFF files +music.login=Log in +music.login.title=%s login +music.login.refreshqr=Refresh QR code +music.login.cookie=Cookie login +music.logout=Log out +music.logout.confirm=Log out of %s? +music.cancel=Cancel +music.greeting.night=Up late +music.greeting.morning=Good morning +music.greeting.afternoon=Good afternoon +music.greeting.evening=Good evening +music.greeting.qq=QQ Music listener +music.greeting.netease=NetEase listener +music.greeting.sep=, +music.sub.qq=Top chart updated +music.sub.daily=Daily picks updated +music.sub.login=Log in for daily picks +music.lyrics.loading=Loading lyrics… +music.lyrics.idle=Nothing playing +music.error.network=Network error, check your connection and retry +music.error.timeout=The request timed out, please retry later +music.error.notloggedin=Your login has expired, please log in again +music.error.risk=Too many requests or blocked by the platform, please retry later +music.error.badresponse=The music platform returned unreadable data +music.error.api=The music platform returned an error: %s +music.error.unknown=Failed to load: %s +mainmenu.account.remove.confirm=Click again to remove diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index 856c52ed..be4f680e 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -1300,3 +1300,60 @@ signin.failed.throttled=尝试过于频繁,请稍后再试 signin.failed.server=服务器暂时不可用,请稍后再试 mainmenu.account.fps=FPSMaster 账号 mainmenu.account.fps.signin=登录 FPSMaster 账号 +music.settings.title=音乐设置 +music.settings.subtitle=播放与歌词 +music.settings.lyricsize=歌词字号 +music.settings.lyriclines=显示行数 +music.settings.lines.unit=%d 行 +music.settings.hud=桌面歌词 +music.settings.translation=显示翻译 +music.settings.smooth=平滑滚动 +music.settings.background=歌词背景 +music.settings.mode=播放模式 +music.mode.sequential=顺序 +music.mode.shuffle=乱序 +music.mode.repeatone=单曲 +music.source.netease=网易云 +music.source.qq=QQ 音乐 +music.tab.discover=发现 +music.tab.playlists=歌单 +music.tab.search=搜索 +music.tab.local=本地 +music.search.placeholder=搜索歌曲、歌手… +music.search.hint=输入关键字并回车搜索 +music.list.loading=加载中… +music.list.empty=暂无歌曲 +music.list.needlogin=登录后查看 +music.list.error=加载失败 +music.list.retry=重试 +music.playlists.empty=暂无歌单 +music.local.import=导入音乐 +music.local.title=本地音乐 +music.local.empty=导入 MP3、WAV 或 AIFF 文件 +music.login=登录 +music.login.title=%s 登录 +music.login.refreshqr=刷新二维码 +music.login.cookie=Cookie 登录 +music.logout=退出登录 +music.logout.confirm=退出%s账号? +music.cancel=取消 +music.greeting.night=夜深了 +music.greeting.morning=早上好 +music.greeting.afternoon=下午好 +music.greeting.evening=晚上好 +music.greeting.qq=Q音听友 +music.greeting.netease=云村居民 +music.greeting.sep=, +music.sub.qq=热歌榜已更新 +music.sub.daily=每日推荐已更新 +music.sub.login=登录后查看每日推荐 +music.lyrics.loading=歌词加载中… +music.lyrics.idle=未在播放 +music.error.network=网络连接失败,请检查网络后重试 +music.error.timeout=请求超时,请稍后重试 +music.error.notloggedin=登录已失效,请重新登录 +music.error.risk=操作过于频繁或触发平台风控,请稍后再试 +music.error.badresponse=音乐平台返回了无法识别的数据 +music.error.api=音乐平台返回错误:%s +music.error.unknown=加载失败:%s +mainmenu.account.remove.confirm=再点一次删除 From ef9d85bab8ff7b9d19b2b182d64dd215dd42e9cb Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:25:37 -0700 Subject: [PATCH 02/15] =?UTF-8?q?fix(api):=20=E8=AF=B7=E6=B1=82=E5=B8=A6?= =?UTF-8?q?=20Accept-Language=EF=BC=8C=E5=A4=B1=E8=B4=A5=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=90=8E=E7=AB=AF=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非 2xx 的统一错误信封不再被丢成「HTTP error: 404」,ApiResponse 新增 errorCode, 逻辑判断改认错误码;封禁提示按 ACCOUNT_BANNED 判定。 --- .../client/api/FPSMasterApiClient.java | 27 ++++++++++++-- .../client/api/FPSMasterConstants.java | 5 +++ .../client/api/PromotedServersService.java | 1 + .../modules/client/api/model/ApiResponse.java | 37 ++++++++++++++----- .../ui/screens/signin/SignInScreen.java | 5 +++ 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/main/java/top/fpsmaster/modules/client/api/FPSMasterApiClient.java b/src/main/java/top/fpsmaster/modules/client/api/FPSMasterApiClient.java index 01a4387a..34b31a76 100644 --- a/src/main/java/top/fpsmaster/modules/client/api/FPSMasterApiClient.java +++ b/src/main/java/top/fpsmaster/modules/client/api/FPSMasterApiClient.java @@ -110,8 +110,7 @@ public CompletableFuture> login(String username, Stri ClientLogger.warn("Login failed: " + apiResponse.getMessage()); // 带上状态码,界面才能把「密码错」和「连不上」分流到不同文案; // serverMessage 也要跟着走,否则界面无法判断这句话是不是后端原话。 - return ApiResponse.error(response.getStatusCode(), - apiResponse.getMessage(), null, apiResponse.hasServerMessage()); + return ApiResponse.failure(apiResponse); } return apiResponse; @@ -702,12 +701,21 @@ private UserInfo toUserInfo(CurrentUserView view) { // ================== Utility Methods ================== // + /** + * 2xx 返回正文;非 2xx 时只要正文是后端的统一错误信封(带 {@code success})也照样返回, + * 这样调用方经 {@link ApiResponse#fromJson} 拿得到 code 和本地化 message。以前这里一律 + * 抛「HTTP error: 404」,后端给的错误码全丢了,调用方只能去匹配这句英文。 + */ private JsonObject parseResponse(HttpRequest.HttpResponseResult response) throws ApiException { + String body = response.getBody(); if (!response.isSuccess()) { + JsonObject envelope = parseQuietly(body); + if (envelope != null && envelope.has("success")) { + return envelope; + } throw new ApiException(response.getStatusCode(), "HTTP error: " + response.getStatusCode()); } - String body = response.getBody(); if (body == null || body.isEmpty()) { throw new ApiException(-1, "Empty response body"); } @@ -719,10 +727,23 @@ private JsonObject parseResponse(HttpRequest.HttpResponseResult response) throws } } + /** Cloudflare 之类挡在前面时错误正文是 HTML,解析不了就当没有信封。 */ + private static JsonObject parseQuietly(String body) { + if (body == null || body.isEmpty()) { + return null; + } + try { + return FPSMasterGson.getInstance().fromJson(body, JsonObject.class); + } catch (RuntimeException notJson) { + return null; + } + } + private Map getDefaultHeaders() { Map headers = new HashMap<>(); headers.put("User-Agent", FPSMasterConstants.USER_AGENT); headers.put("Accept", "application/json"); + headers.put("Accept-Language", FPSMasterConstants.acceptLanguage()); headers.put("Content-Type", "application/json"); return headers; } diff --git a/src/main/java/top/fpsmaster/modules/client/api/FPSMasterConstants.java b/src/main/java/top/fpsmaster/modules/client/api/FPSMasterConstants.java index c27c55a4..f93e192a 100644 --- a/src/main/java/top/fpsmaster/modules/client/api/FPSMasterConstants.java +++ b/src/main/java/top/fpsmaster/modules/client/api/FPSMasterConstants.java @@ -32,6 +32,11 @@ private static String trimTrailingSlash(String url) { return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; } + /** 按游戏语言给后端的 Accept-Language:后端据此本地化失败响应的 message。 */ + public static String acceptLanguage() { + return top.fpsmaster.features.impl.interfaces.ClientSettings.language.getValue() == 1 ? "zh-CN" : "en"; + } + /** Resolves a backend-relative path (an asset key, for example) against the configured base. */ public static String resolve(String path) { if (path == null || path.isEmpty()) { diff --git a/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java b/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java index 26632804..267012bd 100644 --- a/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java +++ b/src/main/java/top/fpsmaster/modules/client/api/PromotedServersService.java @@ -101,6 +101,7 @@ private void fetch() { Map headers = new HashMap<>(); headers.put("User-Agent", FPSMasterConstants.USER_AGENT); headers.put("Accept", "application/json"); + headers.put("Accept-Language", FPSMasterConstants.acceptLanguage()); HttpRequest.HttpResponseResult response = HttpRequest.get(SERVERS_ENDPOINT, headers); if (!response.isSuccess() || response.getBody() == null || response.getBody().isEmpty()) { ClientLogger.warn("Promoted servers request failed: HTTP " + response.getStatusCode()); diff --git a/src/main/java/top/fpsmaster/modules/client/api/model/ApiResponse.java b/src/main/java/top/fpsmaster/modules/client/api/model/ApiResponse.java index b6ab09a7..91a8ac47 100644 --- a/src/main/java/top/fpsmaster/modules/client/api/model/ApiResponse.java +++ b/src/main/java/top/fpsmaster/modules/client/api/model/ApiResponse.java @@ -28,20 +28,23 @@ public class ApiResponse { */ private final boolean serverMessage; - private ApiResponse(boolean success, String message, T data) { - this(success, message, data, NO_CODE, false); - } + /** + * 后端失败响应里稳定的 UPPER_SNAKE 错误码(如 {@code ITEM_ALREADY_OWNED}),没有时为 null。 + * 逻辑判断只认它:message 会按 Accept-Language 本地化,匹配原文迟早对不上。 + */ + private final String errorCode; - private ApiResponse(boolean success, String message, T data, int code) { - this(success, message, data, code, false); + private ApiResponse(boolean success, String message, T data) { + this(success, message, data, NO_CODE, false, null); } - private ApiResponse(boolean success, String message, T data, int code, boolean serverMessage) { + private ApiResponse(boolean success, String message, T data, int code, boolean serverMessage, String errorCode) { this.success = success; this.message = message; this.data = data; this.code = code; this.serverMessage = serverMessage; + this.errorCode = errorCode; } public static ApiResponse success(String message, T data) { @@ -49,12 +52,17 @@ public static ApiResponse success(String message, T data) { } public static ApiResponse error(int code, String message, String error) { - return new ApiResponse<>(false, message, null, code, false); + return new ApiResponse<>(false, message, null, code, false, null); } /** 转发一个后端原话时用这个重载,别让 {@link #hasServerMessage()} 在转手时丢掉。 */ public static ApiResponse error(int code, String message, String error, boolean serverMessage) { - return new ApiResponse<>(false, message, null, code, serverMessage); + return new ApiResponse<>(false, message, null, code, serverMessage, null); + } + + /** 转手一个失败响应:状态码、后端原话和错误码都原样带上。 */ + public static ApiResponse failure(ApiResponse source) { + return new ApiResponse<>(false, source.message, null, source.code, source.serverMessage, source.errorCode); } public static ApiResponse fromJson(JsonObject json, Class dataClass) { @@ -80,6 +88,9 @@ public static ApiResponse fromJson(JsonObject json, Class dataClass, i // 之类挡在前面时正文可能是一段带 message 的 JSON,只看 message 非空就会把它当成 // 封禁原因贴到界面上。 boolean serverMessage = !message.isEmpty() && json.has("success"); + String errorCode = json.has("code") && json.get("code").isJsonPrimitive() + && json.get("code").getAsJsonPrimitive().isString() + ? json.get("code").getAsString() : null; T data = null; if (json.has("data") && !json.get("data").isJsonNull()) { @@ -90,11 +101,11 @@ public static ApiResponse fromJson(JsonObject json, Class dataClass, i } catch (Exception e) { // 这是「响应到了但 data 结构不对」,不是网络失败——以前它返回 NETWORK_ERROR, // 界面会显示「无法连接服务器」,把排障方向指到网络上去。 - return new ApiResponse(false, message, null, statusCode, serverMessage); + return new ApiResponse(false, message, null, statusCode, serverMessage, errorCode); } } - return new ApiResponse<>(success, message, data, statusCode, serverMessage); + return new ApiResponse<>(success, message, data, statusCode, serverMessage, errorCode); } public static ApiResponse fromJson(JsonObject json) { @@ -121,6 +132,11 @@ public int getCode() { return code; } + /** 后端给的错误码,没有(成功、网络失败、旧后端)时为 null。 */ + public String getErrorCode() { + return errorCode; + } + /** {@link #getMessage()} 是不是后端按契约给的原话;兜底路径造的技术文案不算。 */ public boolean hasServerMessage() { return serverMessage; @@ -132,6 +148,7 @@ public String toString() { "success=" + success + ", message='" + message + '\'' + ", code=" + code + + ", errorCode=" + errorCode + ", data=" + data + '}'; } diff --git a/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java b/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java index 04bd875b..f5a93fb9 100644 --- a/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java @@ -132,6 +132,11 @@ private String localizeLoginError(ApiResponse response) { */ private String banMessage(ApiResponse response) { String reason = response.getMessage(); + // 新后端带错误码,message 已按 Accept-Language 本地化(含封禁原因),直接显示。 + if (response.hasServerMessage() && "ACCOUNT_BANNED".equals(response.getErrorCode()) + && reason != null && !reason.isEmpty()) { + return reason; + } if (!response.hasServerMessage() || reason == null || reason.isEmpty() || BACKEND_DEFAULT_BAN_REASON.equals(reason)) { return FPSMaster.i18n.get("signin.failed.banned"); From 7ae6b0aa2b27676e4c142855cebfbf02b07f0787 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:25:37 -0700 Subject: [PATCH 03/15] =?UTF-8?q?fix(cosmetics):=20=E8=BF=9C=E7=AB=AF?= =?UTF-8?q?=E9=A5=B0=E5=93=81=E8=A7=A3=E6=9E=90=E6=94=B9=E4=B8=BA=E8=B4=9F?= =?UTF-8?q?=E7=BC=93=E5=AD=98+=E9=99=90=E9=A2=91=E6=89=B9=E9=87=8F+?= =?UTF-8?q?=E6=8C=87=E6=95=B0=E9=80=80=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 无饰品玩家也写入 5 分钟负缓存,不再每个 tick 重发 resolve - 每秒最多一轮、单请求在途、一批最多 200 个;失败按 5s 起步翻倍退避到 5 分钟 - 换世界后丢弃旧请求的回包;修正「once a second」注释 - 饰品贴图下载失败负缓存 5 分钟,不再每帧重下 - 正版绑定按 Minecraft 会话区分:切换账号自动重新绑定;网络失败退避重试, 令牌无效等永久失败才停止;推送失败按状态码区分不可用/失败 --- .../fpsmaster/cosmetic/CosmeticManager.java | 24 ++- .../cosmetic/MinecraftLinkService.java | 120 +++++++++++-- .../cosmetic/RemoteCosmeticService.java | 166 +++++++----------- .../fpsmaster/cosmetic/ResolveScheduler.java | 145 +++++++++++++++ .../cosmetic/ResolveSchedulerTest.java | 104 +++++++++++ 5 files changed, 437 insertions(+), 122 deletions(-) create mode 100644 src/main/java/top/fpsmaster/cosmetic/ResolveScheduler.java create mode 100644 src/test/java/top/fpsmaster/cosmetic/ResolveSchedulerTest.java diff --git a/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java b/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java index 7feb8d42..966f74e1 100644 --- a/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java +++ b/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java @@ -64,6 +64,13 @@ private static boolean isRenderableCategory(String category) { private final ElytraRenderer elytraRenderer = new ElytraRenderer(); private final Map textures = new HashMap<>(); private final Set loading = new HashSet<>(); + /** + * Option id -> when its texture last failed to load. The renderers ask for a texture every frame, + * so without this a 404 or a malformed atlas was downloaded again on the next frame, forever. + * Guarded by {@link #textures}. + */ + private final Map failedAt = new HashMap<>(); + private static final long TEXTURE_RETRY_MS = 5L * 60_000L; /** Other players' items, resolved on demand. Deliberately outside {@link #allOptions()}: someone * else's cosmetic is something to draw, never something in this account's wardrobe. */ private final Map remoteOptions = new ConcurrentHashMap<>(); @@ -108,7 +115,11 @@ public void reloadCustom() { // The reloaded file may hold a different image under the same id, so the entry has to go // — and with it the GL texture, which dropping the map entry alone would strand. synchronized (textures) { - for (CosmeticOption option : customOptions) deleteTexture(textures.remove(option.id)); + for (CosmeticOption option : customOptions) { + deleteTexture(textures.remove(option.id)); + // The user may just have fixed the file: give it a fresh attempt. + failedAt.remove(option.id); + } } customOptions = Collections.unmodifiableList(options); validateSelections(); @@ -413,6 +424,7 @@ public void releaseTextures() { for (ResourceLocation location : textures.values()) deleteTexture(location); textures.clear(); loading.clear(); + failedAt.clear(); } } @@ -427,7 +439,10 @@ private static void deleteTexture(ResourceLocation location) { private void loadTexture(CosmeticOption option) { if (option == null || option.assetKey == null) return; synchronized (textures) { - if (textures.containsKey(option.id) || !loading.add(option.id)) return; + if (textures.containsKey(option.id)) return; + Long failed = failedAt.get(option.id); + if (failed != null && System.currentTimeMillis() - failed < TEXTURE_RETRY_MS) return; + if (!loading.add(option.id)) return; } FPSMaster.async.runnable(() -> { try { @@ -443,6 +458,7 @@ private void loadTexture(CosmeticOption option) { synchronized (textures) { textures.put(option.id, location); loading.remove(option.id); + failedAt.remove(option.id); } } catch (Exception exception) { fail(option, exception); @@ -502,8 +518,10 @@ private void validateDimensions(BufferedImage image, String category) { private void fail(CosmeticOption option, Exception exception) { synchronized (textures) { loading.remove(option.id); + failedAt.put(option.id, System.currentTimeMillis()); } - ClientLogger.warn("Failed to load cosmetic " + option.id + ": " + exception.getMessage()); + ClientLogger.warn("Failed to load cosmetic " + option.id + " (next attempt in " + + (TEXTURE_RETRY_MS / 60_000L) + " min): " + exception.getMessage()); } private CosmeticOption option(CosmeticItem item, String category) { diff --git a/src/main/java/top/fpsmaster/cosmetic/MinecraftLinkService.java b/src/main/java/top/fpsmaster/cosmetic/MinecraftLinkService.java index 46fc80ed..5eb3401f 100644 --- a/src/main/java/top/fpsmaster/cosmetic/MinecraftLinkService.java +++ b/src/main/java/top/fpsmaster/cosmetic/MinecraftLinkService.java @@ -1,10 +1,12 @@ package top.fpsmaster.cosmetic; import com.mojang.authlib.GameProfile; +import com.mojang.authlib.exceptions.AuthenticationUnavailableException; import net.minecraft.client.Minecraft; import top.fpsmaster.FPSMaster; import top.fpsmaster.modules.client.api.AuthService; import top.fpsmaster.modules.client.api.FPSMasterApiClient; +import top.fpsmaster.modules.client.api.model.ApiResponse; import top.fpsmaster.modules.client.api.model.MinecraftLinkChallenge; import top.fpsmaster.modules.logger.ClientLogger; @@ -35,9 +37,17 @@ public enum State { FAILED } + private static final long RETRY_BASE_MS = 5_000L; + private static final long RETRY_MAX_MS = 5L * 60_000L; + private volatile State state = State.IDLE; private volatile String message = ""; - private volatile boolean attempted; + /** Session the current state belongs to ({@code uuid:name}); a different session starts over. */ + private volatile String sessionKey; + private volatile boolean inFlight; + /** 0 = no further attempt for this session (linked, offline, or rejected for good). */ + private volatile long retryAt; + private int failures; private MinecraftLinkService() { } @@ -69,38 +79,61 @@ public static boolean isOnlineSession() { return id != null && id.version() == 4 && token != null && token.length() > 8; } - /** Runs the handshake once per session; repeated calls after a success are no-ops. */ - public void linkIfNeeded() { - if (attempted || !AuthService.getInstance().isLoggedIn()) { + /** + * Runs the handshake for the current Minecraft session. Cheap enough for every tick: it only + * does work when the session changed (account switch), or when a retryable failure's backoff + * has elapsed. A linked, offline or permanently rejected session is never retried. + */ + public synchronized void linkIfNeeded() { + if (!AuthService.getInstance().isLoggedIn() || inFlight) { + return; + } + String key = currentSessionKey(); + if (key == null) { + return; + } + if (!key.equals(sessionKey)) { + // Switching Minecraft accounts makes the previous link (or failure) someone else's. + sessionKey = key; + state = State.IDLE; + message = ""; + failures = 0; + retryAt = System.currentTimeMillis(); + } + if (retryAt == 0L || System.currentTimeMillis() < retryAt) { return; } - attempted = true; if (!isOnlineSession()) { state = State.SKIPPED_OFFLINE; message = FPSMaster.i18n.get("cosmetics.link.offline"); + retryAt = 0L; return; } + inFlight = true; state = State.LINKING; message = FPSMaster.i18n.get("cosmetics.link.pending"); + final String attemptKey = key; FPSMasterApiClient.getInstance().requestMinecraftLinkChallenge().whenComplete((response, error) -> { if (error != null || response == null || !response.isSuccess() || response.getData() == null) { - fail(error != null ? error.getMessage() : response == null ? "" : response.getMessage()); + failBackend(attemptKey, response, error); return; } - joinAndConfirm(response.getData()); + joinAndConfirm(attemptKey, response.getData()); }); } - /** Allows a retry after a failure — a new sign-in, for example. */ - public void reset() { - attempted = false; + /** Forgets this session's outcome so the next {@link #linkIfNeeded()} starts over (sign-in/out). */ + public synchronized void reset() { + sessionKey = null; state = State.IDLE; message = ""; + failures = 0; + retryAt = 0L; } - private void joinAndConfirm(MinecraftLinkChallenge challenge) { + private void joinAndConfirm(String attemptKey, MinecraftLinkChallenge challenge) { if (!challenge.isWellFormed()) { - fail("malformed challenge"); + fail(attemptKey, false, "malformed challenge"); return; } FPSMaster.async.runnable(() -> { @@ -109,27 +142,76 @@ private void joinAndConfirm(MinecraftLinkChallenge challenge) { try { minecraft.getSessionService().joinServer( profile, minecraft.getSession().getToken(), challenge.getServerId()); + } catch (AuthenticationUnavailableException unavailable) { + // Mojang's session server could not be reached: a network problem, worth retrying. + fail(attemptKey, true, unavailable.getClass().getSimpleName()); + return; } catch (Exception exception) { - // Deliberately reports the exception type only: the message can echo request details. - fail(exception.getClass().getSimpleName()); + // Invalid or expired session token: retrying the same token cannot succeed. A new + // session (re-login, account switch) gets a fresh attempt. Deliberately reports the + // exception type only: the message can echo request details. + fail(attemptKey, false, exception.getClass().getSimpleName()); return; } FPSMasterApiClient.getInstance() .confirmMinecraftLink(challenge.getChallengeId(), profile.getName()) .whenComplete((response, error) -> { if (error == null && response != null && response.isSuccess()) { - state = State.LINKED; - message = FPSMaster.i18n.get("cosmetics.link.ok"); + succeed(attemptKey); return; } - fail(error != null ? error.getMessage() : response == null ? "" : response.getMessage()); + failBackend(attemptKey, response, error); }); }); } - private void fail(String reason) { + private synchronized void succeed(String attemptKey) { + inFlight = false; + if (!attemptKey.equals(sessionKey)) { + return; + } + state = State.LINKED; + message = FPSMaster.i18n.get("cosmetics.link.ok"); + failures = 0; + retryAt = 0L; + } + + /** + * Network failures, rate limiting and server errors are transient; any other 4xx is the backend + * rejecting this request and will not change by asking again. + */ + private void failBackend(String attemptKey, ApiResponse response, Throwable error) { + int code = error != null || response == null ? ApiResponse.NETWORK_ERROR : response.getCode(); + boolean retryable = code == ApiResponse.NETWORK_ERROR || code == 429 || code >= 500; + String reason = error != null ? error.getClass().getSimpleName() + : response == null ? "no response" + : "HTTP " + code + (response.getErrorCode() == null ? "" : " " + response.getErrorCode()); + fail(attemptKey, retryable, reason); + } + + private synchronized void fail(String attemptKey, boolean retryable, String reason) { + inFlight = false; + if (!attemptKey.equals(sessionKey)) { + return; + } state = State.FAILED; message = FPSMaster.i18n.get("cosmetics.link.failed"); - ClientLogger.warn("Minecraft account link failed: " + (reason == null ? "unknown" : reason)); + if (retryable) { + failures++; + long delay = Math.min(RETRY_BASE_MS << Math.min(failures - 1, 20), RETRY_MAX_MS); + retryAt = System.currentTimeMillis() + delay; + ClientLogger.warn("Minecraft account link failed: " + reason + ", retrying in " + (delay / 1000L) + "s"); + } else { + retryAt = 0L; + ClientLogger.warn("Minecraft account link failed: " + reason); + } + } + + private static String currentSessionKey() { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getSession() == null) { + return null; + } + return minecraft.getSession().getPlayerID() + ":" + minecraft.getSession().getUsername(); } } diff --git a/src/main/java/top/fpsmaster/cosmetic/RemoteCosmeticService.java b/src/main/java/top/fpsmaster/cosmetic/RemoteCosmeticService.java index f55860df..45a75842 100644 --- a/src/main/java/top/fpsmaster/cosmetic/RemoteCosmeticService.java +++ b/src/main/java/top/fpsmaster/cosmetic/RemoteCosmeticService.java @@ -5,6 +5,7 @@ import top.fpsmaster.FPSMaster; import top.fpsmaster.modules.client.api.AuthService; import top.fpsmaster.modules.client.api.FPSMasterApiClient; +import top.fpsmaster.modules.client.api.model.ApiResponse; import top.fpsmaster.modules.client.api.model.CosmeticItem; import top.fpsmaster.modules.client.api.model.CosmeticLoadoutView; import top.fpsmaster.modules.client.api.model.ResolvedLoadoutView; @@ -12,7 +13,7 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.HashSet; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -20,7 +21,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -28,8 +28,9 @@ * Publishes this account's cosmetic loadout and resolves everyone else's, so two FPSMaster players on * the same server see each other's cosmetics. * - *

Reads are a batch resolve keyed by Minecraft UUID, refreshed when the world is entered, when the - * tab list changes and at most once a minute otherwise; an entry is trusted for five minutes and the + *

Reads are a batch resolve keyed by Minecraft UUID, scheduled by {@link ResolveScheduler}: every + * answer (including "this player has nothing") is trusted for five minutes, a pass runs at most once a + * second and asks only for players without a fresh answer, and failures back off exponentially. The * whole cache is dropped on world leave or logout, so a stale loadout never outlives the session that * produced it. * @@ -39,10 +40,6 @@ * was and moves {@link #status()} to {@link Status#FAILED}, which the cosmetics screen shows. */ public final class RemoteCosmeticService { - /** Batch ceiling the backend enforces; a larger tab list resolves over consecutive passes. */ - private static final int MAX_BATCH = 200; - private static final long CACHE_TTL_MS = 5L * 60_000L; - private static final long REFRESH_INTERVAL_MS = 60_000L; private static final long PUSH_DEBOUNCE_MS = 500L; private static final RemoteCosmeticService INSTANCE = new RemoteCosmeticService(); @@ -58,12 +55,9 @@ public enum Status { UNAVAILABLE } - private final Map cache = new ConcurrentHashMap<>(); - private final AtomicBoolean resolveInFlight = new AtomicBoolean(false); + private final ResolveScheduler resolver = new ResolveScheduler<>(); private final AtomicBoolean pushInFlight = new AtomicBoolean(false); - private final Set lastOnline = new HashSet<>(); - private volatile long lastFullRefreshAt; private volatile boolean hadWorld; private volatile long pushDueAt; @@ -110,30 +104,21 @@ public String syncStatusKey() { // ================== Reading other players ================== // - /** The cached loadout for a Minecraft UUID, or null when unknown or older than the TTL. */ + /** The cached loadout for a Minecraft UUID, or null when unknown, expired or resolved to nothing. */ public RemoteLoadout loadoutFor(UUID uuid) { - if (uuid == null) { - return null; - } - Entry entry = cache.get(uuid); - if (entry == null) { - return null; - } - if (System.currentTimeMillis() - entry.fetchedAt > CACHE_TTL_MS) { - cache.remove(uuid); - return null; - } - return entry.loadout; + return resolver.get(uuid, System.currentTimeMillis()); } - /** Called once a second from the client tick. */ + /** + * Called every client tick (20 Hz). Only the debounced push and the world-leave check run at that + * rate; resolving is throttled and batched by {@link ResolveScheduler}. + */ public void onClientTick() { Minecraft minecraft = Minecraft.getMinecraft(); boolean hasWorld = minecraft.theWorld != null && minecraft.getNetHandler() != null; if (hadWorld && !hasWorld) { clearRemote(); } - boolean entered = hasWorld && !hadWorld; hadWorld = hasWorld; flushDuePush(); @@ -141,34 +126,17 @@ public void onClientTick() { if (!hasWorld || !AuthService.getInstance().isLoggedIn()) { return; } + // Linking is what makes this player visible to others; retry it here so a failed attempt + // (network down at startup) recovers without a restart. linkIfNeeded paces itself. + MinecraftLinkService.getInstance().linkIfNeeded(); - Set online = onlinePlayers(minecraft); long now = System.currentTimeMillis(); - boolean periodic = now - lastFullRefreshAt >= REFRESH_INTERVAL_MS; - boolean tabChanged = !online.equals(lastOnline); - - Set wanted; - if (entered || periodic) { - wanted = new LinkedHashSet<>(online); - lastFullRefreshAt = now; - } else if (tabChanged) { - wanted = new LinkedHashSet<>(online); - wanted.removeAll(lastOnline); - } else { - // Steady state still has to pick up entries that aged out, and the remainder of a tab - // list too large for one batch. - wanted = new LinkedHashSet<>(); - for (UUID uuid : online) { - if (loadoutFor(uuid) == null) { - wanted.add(uuid); - } - } + if (!resolver.passDue(now)) { + return; } - - lastOnline.clear(); - lastOnline.addAll(online); - if (!wanted.isEmpty()) { - resolve(wanted); + ResolveScheduler.Batch batch = resolver.nextBatch(onlinePlayers(minecraft), now); + if (batch != null) { + resolve(batch); } } @@ -187,55 +155,65 @@ private Set onlinePlayers(Minecraft minecraft) { return online; } - private void resolve(Collection uuids) { - if (!resolveInFlight.compareAndSet(false, true)) { - return; - } - List batch = new ArrayList(Math.min(uuids.size(), MAX_BATCH)); - for (UUID uuid : uuids) { - if (batch.size() >= MAX_BATCH) { - break; - } + private void resolve(ResolveScheduler.Batch batch) { + List uuids = new ArrayList(batch.uuids.size()); + for (UUID uuid : batch.uuids) { // UUID.toString() is already the canonical dashed lowercase form the endpoint requires; // it rejects anything else. - batch.add(uuid.toString()); + uuids.add(uuid.toString()); } - FPSMasterApiClient.getInstance().resolveLoadouts(batch).whenComplete((response, error) -> { + FPSMasterApiClient.getInstance().resolveLoadouts(uuids).whenComplete((response, error) -> { + long now = System.currentTimeMillis(); + if (error != null || response == null || !response.isSuccess() || response.getData() == null) { + // A resolve failure degrades to "nobody else has cosmetics", never to a wrong loadout, + // so it does not touch this account's own sync status. It does back off: a backend + // without the endpoint (404) or one that is down must not be asked again next second. + long delay = resolver.onFailure(now); + ClientLogger.warn("Cosmetic resolve failed (" + describe(response, error) + "), retrying in " + + (delay / 1000L) + "s"); + return; + } + Map found = new HashMap<>(); try { - if (error != null || response == null || !response.isSuccess() || response.getData() == null) { - // A resolve failure degrades to "nobody else has cosmetics", never to a wrong - // loadout, so it does not touch this account's own sync status. The next pass - // retries, since nothing was cached for those players. - return; - } - long now = System.currentTimeMillis(); for (ResolvedLoadoutView entry : response.getData()) { if (entry == null || entry.getLoadout() == null) { continue; } UUID uuid = parseUuid(entry.getMinecraftUuid()); if (uuid != null) { - store(uuid, entry.getLoadout(), now); + found.put(uuid, toLoadout(entry.getLoadout())); } } - } catch (Exception exception) { + } catch (RuntimeException exception) { + // A malformed entry poisons this answer only; caching what parsed so far as "nothing" + // would hide real loadouts for five minutes, so treat it like a failed request. + resolver.onFailure(now); ClientLogger.warn("Failed to apply resolved cosmetic loadouts: " + exception.getMessage()); - } finally { - resolveInFlight.set(false); + return; } + resolver.onSuccess(batch, found, now); }); } - private void store(UUID uuid, CosmeticLoadoutView view, long now) { + private static String describe(ApiResponse response, Throwable error) { + if (error != null) { + return error.getClass().getSimpleName(); + } + if (response == null) { + return "no response"; + } + String code = response.getErrorCode(); + return "HTTP " + response.getCode() + (code == null ? "" : " " + code); + } + + private RemoteLoadout toLoadout(CosmeticLoadoutView view) { CosmeticManager cosmetics = CosmeticManager.getInstance(); String capeId = register(cosmetics, view.getCapeItem(), view.getCapeItemId()); String backId = register(cosmetics, view.getBackItem(), view.getBackItemId()); String backCategory = view.getBackItem() != null ? view.getBackItem().getCategory().toLowerCase(Locale.ROOT) : "wings"; - cache.put(uuid, new Entry(new RemoteLoadout( - capeId, backId, backCategory, - view.isBuiltinWingsEnabled(), view.getWingScale(), view.isCapeAnimationEnabled() - ), now)); + return new RemoteLoadout(capeId, backId, backCategory, + view.isBuiltinWingsEnabled(), view.getWingScale(), view.isCapeAnimationEnabled()); } private String register(CosmeticManager cosmetics, CosmeticItem item, String fallbackId) { @@ -350,14 +328,14 @@ private void flushDuePush() { } // A backend without the endpoint answers 404: that is "unavailable", not "failed". // Either way the local loadout keeps rendering exactly as it was chosen. - String message = error != null ? error.getMessage() - : response == null ? "" : response.getMessage(); - boolean missing = message != null && message.contains("404"); + boolean missing = error == null && response != null && response.getCode() == 404; status = missing ? Status.UNAVAILABLE : Status.FAILED; - // The backend's message is human-readable ("item 7 is not owned"), so it is worth - // surfacing rather than replacing with a generic failure line. - statusMessage = !missing && message != null && !message.isEmpty() - ? message + // The backend's message is localized via Accept-Language ("你还没有拥有该物品"), so + // it is worth surfacing; anything else (network, parse) gets the generic line. + String message = error != null ? error.getClass().getSimpleName() + : response == null ? "no response" : describe(response, null); + statusMessage = !missing && error == null && response != null && response.hasServerMessage() + ? response.getMessage() : FPSMaster.i18n.get(missing ? "cosmetics.sync.unavailable" : "cosmetics.sync.failed"); ClientLogger.warn("Cosmetic loadout sync failed: " + message); }); @@ -375,14 +353,12 @@ private static String remoteId(String id) { /** World leave: other players' loadouts stop being true the moment we stop sharing a server. */ public void clearRemote() { - cache.clear(); - lastOnline.clear(); - lastFullRefreshAt = 0L; + resolver.clearCache(); } /** Logout: everything remote goes, including the sync status of an account we no longer hold. */ public void onLogout() { - clearRemote(); + resolver.reset(); pushPending = false; pushDueAt = 0L; status = Status.IDLE; @@ -409,16 +385,6 @@ public void pullOwnLoadout() { }); } - private static final class Entry { - private final RemoteLoadout loadout; - private final long fetchedAt; - - private Entry(RemoteLoadout loadout, long fetchedAt) { - this.loadout = loadout; - this.fetchedAt = fetchedAt; - } - } - /** A resolved loadout in the terms the renderers need: cosmetic option ids and a real scale. */ public static final class RemoteLoadout { public final String capeItemId; diff --git a/src/main/java/top/fpsmaster/cosmetic/ResolveScheduler.java b/src/main/java/top/fpsmaster/cosmetic/ResolveScheduler.java new file mode 100644 index 00000000..60151e87 --- /dev/null +++ b/src/main/java/top/fpsmaster/cosmetic/ResolveScheduler.java @@ -0,0 +1,145 @@ +package top.fpsmaster.cosmetic; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Decides when other players' loadouts are asked for, so the resolve endpoint sees a handful of + * requests per session instead of one per tick. + * + *

    + *
  • A player the backend answered for is cached for {@link #ENTRY_TTL_MS} — including the + * answer "no loadout", which is what nearly every player on a public server gets. Without that + * negative entry they were re-requested as soon as the previous request finished.
  • + *
  • At most one request is in flight and a pass runs at most every {@link #PASS_INTERVAL_MS}; + * everything a pass finds missing goes into one batch of at most {@link #MAX_BATCH}.
  • + *
  • A failed request backs off exponentially from {@link #BACKOFF_BASE_MS} up to + * {@link #BACKOFF_MAX_MS}; one success resets it.
  • + *
+ * + *

Pure bookkeeping with an injected clock, so the schedule is unit-testable without a game. + * + * @param what a resolved player maps to; null means "resolved, has nothing" + */ +final class ResolveScheduler { + static final int MAX_BATCH = 200; + static final long ENTRY_TTL_MS = 5L * 60_000L; + static final long PASS_INTERVAL_MS = 1_000L; + static final long BACKOFF_BASE_MS = 5_000L; + static final long BACKOFF_MAX_MS = 5L * 60_000L; + + /** A request the caller must send; hand it back to {@link #onSuccess} or {@link #onFailure}. */ + static final class Batch { + final List uuids; + private final int generation; + + private Batch(List uuids, int generation) { + this.uuids = Collections.unmodifiableList(uuids); + this.generation = generation; + } + } + + private static final class Entry { + final V value; + final long fetchedAt; + + Entry(V value, long fetchedAt) { + this.value = value; + this.fetchedAt = fetchedAt; + } + } + + /** Read every frame by the renderers, written by the HTTP callback: hence concurrent. */ + private final Map> cache = new ConcurrentHashMap<>(); + private boolean inFlight; + private long lastPassAt = Long.MIN_VALUE / 2; + private int failures; + private long retryAt; + /** Bumped by {@link #clearCache()}; answers to requests sent before it are dropped. */ + private int generation; + + /** The cached value, or null when unknown, expired, or resolved to nothing. */ + V get(UUID uuid, long now) { + Entry entry = uuid == null ? null : cache.get(uuid); + if (entry == null || now - entry.fetchedAt > ENTRY_TTL_MS) { + return null; + } + return entry.value; + } + + /** Whether {@link #nextBatch} could send anything now; lets the caller skip walking the tab list. */ + synchronized boolean passDue(long now) { + return !inFlight && now >= retryAt && now - lastPassAt >= PASS_INTERVAL_MS; + } + + /** The batch to send now, or null when nothing is due (throttled, backing off, busy or all known). */ + synchronized Batch nextBatch(Collection online, long now) { + if (!passDue(now)) { + return null; + } + lastPassAt = now; + List wanted = new ArrayList<>(); + for (UUID uuid : online) { + if (wanted.size() >= MAX_BATCH) { + break; + } + Entry entry = cache.get(uuid); + if (entry == null || now - entry.fetchedAt > ENTRY_TTL_MS) { + wanted.add(uuid); + } + } + if (wanted.isEmpty()) { + return null; + } + inFlight = true; + return new Batch(wanted, generation); + } + + /** + * Records the answer. Every requested player gets an entry: the ones absent from {@code found} + * are cached as "nothing", which is what keeps them from being asked for again next pass. + */ + synchronized void onSuccess(Batch batch, Map found, long now) { + inFlight = false; + failures = 0; + retryAt = 0L; + if (batch.generation != generation) { + return; + } + for (UUID uuid : batch.uuids) { + cache.put(uuid, new Entry<>(found.get(uuid), now)); + } + } + + /** Backs off: 5s, 10s, 20s ... capped at five minutes. Returns the delay chosen. */ + synchronized long onFailure(long now) { + inFlight = false; + failures++; + long delay = BACKOFF_BASE_MS << Math.min(failures - 1, 20); + delay = Math.min(delay, BACKOFF_MAX_MS); + retryAt = now + delay; + return delay; + } + + /** + * World leave: the cache stops being true, but the backoff stays — a backend that was failing a + * second ago is not healthier because we changed servers. + */ + synchronized void clearCache() { + cache.clear(); + generation++; + lastPassAt = Long.MIN_VALUE / 2; + } + + /** Logout: everything goes. */ + synchronized void reset() { + clearCache(); + failures = 0; + retryAt = 0L; + } +} diff --git a/src/test/java/top/fpsmaster/cosmetic/ResolveSchedulerTest.java b/src/test/java/top/fpsmaster/cosmetic/ResolveSchedulerTest.java new file mode 100644 index 00000000..0ac8e460 --- /dev/null +++ b/src/test/java/top/fpsmaster/cosmetic/ResolveSchedulerTest.java @@ -0,0 +1,104 @@ +package top.fpsmaster.cosmetic; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ResolveSchedulerTest { + private final UUID a = UUID.randomUUID(); + private final UUID b = UUID.randomUUID(); + private final List online = Arrays.asList(a, b); + + @Test + void playersWithoutLoadoutAreNotRequestedAgainUntilTtl() { + ResolveScheduler scheduler = new ResolveScheduler<>(); + long now = 1_000_000L; + ResolveScheduler.Batch first = scheduler.nextBatch(online, now); + assertNotNull(first); + assertEquals(online, first.uuids); + + Map found = new HashMap<>(); + found.put(a, "cape"); + scheduler.onSuccess(first, found, now); + + assertEquals("cape", scheduler.get(a, now)); + assertNull(scheduler.get(b, now)); + // Every tick for the next five minutes: nothing to ask for, b is negatively cached. + for (long t = now + 50L; t < now + ResolveScheduler.ENTRY_TTL_MS; t += 50L * 97L) { + assertNull(scheduler.nextBatch(online, t)); + } + ResolveScheduler.Batch expired = scheduler.nextBatch(online, now + ResolveScheduler.ENTRY_TTL_MS + 1L); + assertNotNull(expired); + } + + @Test + void passesAreThrottledAndSingleFlight() { + ResolveScheduler scheduler = new ResolveScheduler<>(); + long now = 5_000_000L; + ResolveScheduler.Batch batch = scheduler.nextBatch(Collections.singletonList(a), now); + assertNotNull(batch); + // In flight: nothing else goes out, even for a new player and long after the interval. + assertNull(scheduler.nextBatch(online, now + 10_000L)); + scheduler.onSuccess(batch, Collections.emptyMap(), now + 10_000L); + // Throttle: a newcomer seen within the same second waits for the next pass. + ResolveScheduler.Batch next = scheduler.nextBatch(online, now + 10_000L); + assertNotNull(next); + assertEquals(Collections.singletonList(b), next.uuids); + scheduler.onSuccess(next, Collections.emptyMap(), now + 10_000L); + UUID c = UUID.randomUUID(); + assertNull(scheduler.nextBatch(Arrays.asList(a, b, c), now + 10_500L)); + assertNotNull(scheduler.nextBatch(Arrays.asList(a, b, c), now + 11_000L)); + } + + @Test + void failuresBackOffExponentiallyUpToFiveMinutes() { + ResolveScheduler scheduler = new ResolveScheduler<>(); + long now = 9_000_000L; + long[] expected = {5_000L, 10_000L, 20_000L, 40_000L, 80_000L, 160_000L, 300_000L, 300_000L}; + for (long delay : expected) { + assertNotNull(scheduler.nextBatch(online, now)); + assertEquals(delay, scheduler.onFailure(now)); + assertNull(scheduler.nextBatch(online, now + delay - 1L)); + now += delay; + } + ResolveScheduler.Batch batch = scheduler.nextBatch(online, now); + assertNotNull(batch); + scheduler.onSuccess(batch, Collections.emptyMap(), now); + // Success resets the backoff. + assertNotNull(scheduler.nextBatch(Collections.singletonList(UUID.randomUUID()), now + 1_000L)); + assertEquals(5_000L, scheduler.onFailure(now + 1_000L)); + } + + @Test + void batchesAreCappedAndAnswersFromBeforeAWorldChangeAreDropped() { + ResolveScheduler scheduler = new ResolveScheduler<>(); + List crowd = new ArrayList<>(); + for (int i = 0; i < 450; i++) { + crowd.add(UUID.randomUUID()); + } + long now = 20_000_000L; + ResolveScheduler.Batch batch = scheduler.nextBatch(crowd, now); + assertNotNull(batch); + assertEquals(ResolveScheduler.MAX_BATCH, batch.uuids.size()); + + scheduler.clearCache(); + Map found = new HashMap<>(); + found.put(crowd.get(0), "wings"); + scheduler.onSuccess(batch, found, now); + assertNull(scheduler.get(crowd.get(0), now)); + // The in-flight slot is free again and the whole crowd is unknown. + ResolveScheduler.Batch again = scheduler.nextBatch(crowd, now); + assertNotNull(again); + assertEquals(crowd.subList(0, ResolveScheduler.MAX_BATCH), again.uuids); + } +} From 210b6e30ce4d1616b9671b4afea7afeefca879d5 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:33:00 -0700 Subject: [PATCH 04/15] =?UTF-8?q?feat(config):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E5=86=99=E5=85=A5=E3=80=81=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E4=B8=8E=E4=B8=80=E9=94=AE=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=EF=BC=8C=E5=AF=BC=E5=85=A5=E5=90=8C=E5=90=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=85=88=E9=97=AE=E8=A6=86=E7=9B=96/=E5=8F=A6=E5=AD=98?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置文件改为临时文件+原子替换,崩溃或断电不再留下半截 JSON - 保存前把被替换的文件滚动备份到 config/backups/<配置>/(带时间戳,保留最近 10 份, 常规保存至少间隔 5 分钟一份;覆盖导入、恢复默认、回滚前强制备份;损坏文件不入备份) - 读取失败不再静默重置:损坏文件改名保留为 .corrupt-<时间>,自动从最近可用备份恢复, 没有备份才重置,并弹通知说明;主菜单等无世界界面也能显示通知 - 配置方案界面新增「备份历史」,可确认后回滚到任一备份 - 导入同名配置时提示覆盖(先备份)/另存为 name (n)/取消;非 JSON 文件拒绝导入 - 文件选择框移到后台线程,不再冻结渲染和网络(背景选择同改) - 拖动滑块时设置值不变就不派发事件;自动保存改为 500ms 去抖,写入的是新值 - 单条快捷键配置损坏只跳过该条,不再中断整个配置加载 --- .../fpsmaster/features/GlobalListener.java | 28 +++ .../fpsmaster/features/settings/Setting.java | 11 + .../features/settings/impl/NumberSetting.java | 7 +- .../modules/config/ConfigBackups.java | 186 ++++++++++++++++ .../modules/config/ConfigManager.java | 114 ++++++++-- .../modules/config/ConfigProfileUtils.java | 116 ++++++++-- .../ui/click/ConfigBackupsScreen.java | 173 +++++++++++++++ .../ui/click/ConfigProfilesScreen.java | 206 +++++++++++++++--- .../screens/mainmenu/BackgroundSelector.java | 25 +-- .../top/fpsmaster/utils/io/AtomicFiles.java | 52 +++++ .../utils/render/gui/ScaledGuiScreen.java | 5 + .../fpsmaster/utils/system/FileDialogs.java | 71 ++++++ .../assets/minecraft/client/lang/en_us.lang | 16 ++ .../assets/minecraft/client/lang/zh_cn.lang | 16 ++ .../modules/config/ConfigBackupsTest.java | 97 +++++++++ 15 files changed, 1031 insertions(+), 92 deletions(-) create mode 100644 src/main/java/top/fpsmaster/modules/config/ConfigBackups.java create mode 100644 src/main/java/top/fpsmaster/ui/click/ConfigBackupsScreen.java create mode 100644 src/main/java/top/fpsmaster/utils/io/AtomicFiles.java create mode 100644 src/main/java/top/fpsmaster/utils/system/FileDialogs.java create mode 100644 src/test/java/top/fpsmaster/modules/config/ConfigBackupsTest.java diff --git a/src/main/java/top/fpsmaster/features/GlobalListener.java b/src/main/java/top/fpsmaster/features/GlobalListener.java index 7798e9ff..c9ec2377 100644 --- a/src/main/java/top/fpsmaster/features/GlobalListener.java +++ b/src/main/java/top/fpsmaster/features/GlobalListener.java @@ -65,12 +65,38 @@ public void onChat(EventPacket e) { public void onChatSend(EventSendChatMessage e) { } + /** + * Autosave is debounced: EventValueChange fires before the new value is stored (so saving inside + * the handler wrote the old value) and once per frame while a slider is dragged (so it was a full + * serialize + disk write every frame). The save runs from the tick once changes settle. + */ + private static final long AUTOSAVE_DELAY_MS = 500L; + private long autosaveDueAt; + @Subscribe public void onValueChange(EventValueChange e) { if (FPSMaster.configManager.isConfigLoaded() && !FPSMaster.configManager.isLoadingConfig()) { + autosaveDueAt = System.currentTimeMillis() + AUTOSAVE_DELAY_MS; + } + } + + private void flushAutosave(long now) { + if (autosaveDueAt != 0L && now >= autosaveDueAt) { + autosaveDueAt = 0L; ConfigProfileUtils.saveActiveProfileQuietly(); } } + + private void showConfigRecoveryNotice() { + String[] notice = FPSMaster.configManager.consumeRecoveryNotice(); + if (notice == null) { + return; + } + Object[] args = java.util.Arrays.copyOfRange(notice, 1, notice.length, Object[].class); + NotificationManager.addNotification(FPSMaster.i18n.get("config.recovery.title"), + String.format(FPSMaster.i18n.get(notice[0]), args), + top.fpsmaster.prism.overlay.NotificationCenter.Type.WARNING, 15f); + } @Subscribe public void onTick(EventTick e) { Minecraft minecraft = Minecraft.getMinecraft(); @@ -101,6 +127,8 @@ public void onTick(EventTick e) { FPSMaster.INSTANCE.migrateGuiScaleForHiDpi(); PendingScreen.tick(); long now = System.currentTimeMillis(); + flushAutosave(now); + showConfigRecoveryNotice(); if (now - lastFlushAt < 1000L) { return; } diff --git a/src/main/java/top/fpsmaster/features/settings/Setting.java b/src/main/java/top/fpsmaster/features/settings/Setting.java index 3cd0e17c..62cb1561 100644 --- a/src/main/java/top/fpsmaster/features/settings/Setting.java +++ b/src/main/java/top/fpsmaster/features/settings/Setting.java @@ -75,6 +75,12 @@ public T getValue() { public void setValue(T value) { T oldValue = this.value; + // Sliders call setValue every frame while held; an unchanged immutable value must not fire a + // change event (which schedules a config save). Mutable values (colors, lists) are often + // edited in place and re-set to notify, so they always go through. + if (isImmutableValue(oldValue) && oldValue.equals(value)) { + return; + } if (!fireValueChangeEvent(oldValue, value)) { return; } @@ -82,6 +88,11 @@ public void setValue(T value) { notifyChangeListeners(oldValue, value); } + private static boolean isImmutableValue(Object value) { + return value instanceof Boolean || value instanceof String || value instanceof Integer + || value instanceof Long || value instanceof Double || value instanceof Float; + } + protected boolean fireValueChangeEvent(T oldValue, T newValue) { EventValueChange event = new EventValueChange(this, oldValue, newValue); EventDispatcher.dispatchEvent(event); diff --git a/src/main/java/top/fpsmaster/features/settings/impl/NumberSetting.java b/src/main/java/top/fpsmaster/features/settings/impl/NumberSetting.java index 8281fa5b..468c7b5b 100644 --- a/src/main/java/top/fpsmaster/features/settings/impl/NumberSetting.java +++ b/src/main/java/top/fpsmaster/features/settings/impl/NumberSetting.java @@ -26,7 +26,12 @@ public NumberSetting(String name, Number value, Number min, Number max, Number i public void setValue(Number newValue) { double closestMultipleOfInc = Math.round(newValue.doubleValue() / inc.doubleValue()) * inc.doubleValue(); closestMultipleOfInc = Math.round(closestMultipleOfInc * 100) / 100.0; - super.setValue(Math.max(min.doubleValue(), Math.min(max.doubleValue(), closestMultipleOfInc))); + double clamped = Math.max(min.doubleValue(), Math.min(max.doubleValue(), closestMultipleOfInc)); + // The stored value may be an Integer default while the new one is a Double: compare numerically. + if (getValue() != null && getValue().doubleValue() == clamped) { + return; + } + super.setValue(clamped); } } diff --git a/src/main/java/top/fpsmaster/modules/config/ConfigBackups.java b/src/main/java/top/fpsmaster/modules/config/ConfigBackups.java new file mode 100644 index 00000000..32974103 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/config/ConfigBackups.java @@ -0,0 +1,186 @@ +package top.fpsmaster.modules.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import top.fpsmaster.modules.logger.ClientLogger; +import top.fpsmaster.utils.io.AtomicFiles; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * Rolling, timestamped copies of each config profile under {@code config/backups//}. + * + *

Saves are frequent (every settings change autosaves), so a backup per save would leave the last + * {@link #KEEP} slider steps and nothing older. Instead a save first snapshots the file it is about to + * replace only when the newest backup is at least {@link #MIN_INTERVAL_MS} old and differs from it; + * destructive operations (import over a profile, reset, restore) snapshot unconditionally. Only files + * that parse as a JSON object are ever kept, so a corrupt file can never push out the good backups + * that a recovery needs. + */ +public final class ConfigBackups { + public static final int KEEP = 10; + public static final long MIN_INTERVAL_MS = 5L * 60_000L; + private static final String SUFFIX = ".json"; + private static final String PATTERN = "yyyyMMdd-HHmmss-SSS"; + + private final File root; + + public ConfigBackups(File root) { + this.root = root; + } + + /** The backups of the regular profile directory. */ + public static ConfigBackups forProfiles() { + return new ConfigBackups(new File(ConfigProfileUtils.getProfileDir(), "backups")); + } + + public static final class Backup { + private final File file; + private final long createdAt; + + private Backup(File file, long createdAt) { + this.file = file; + this.createdAt = createdAt; + } + + public File getFile() { + return file; + } + + public long getCreatedAt() { + return createdAt; + } + } + + /** Newest first. */ + public List list(String profile) { + File[] files = dir(profile).listFiles((d, name) -> name.endsWith(SUFFIX)); + if (files == null) { + return Collections.emptyList(); + } + List backups = new ArrayList<>(); + for (File file : files) { + long created = parseTimestamp(file.getName()); + if (created > 0L) { + backups.add(new Backup(file, created)); + } + } + backups.sort(Comparator.comparingLong(Backup::getCreatedAt).reversed()); + return backups; + } + + /** + * Copies {@code current} into the profile's backups if it is worth keeping. + * + * @param force skip the interval check (but never the validity / duplicate checks) + * @return the new backup, or null when nothing was written + */ + public Backup snapshot(String profile, File current, boolean force, long now) throws IOException { + if (current == null || !current.isFile() || current.length() == 0L) { + return null; + } + byte[] content = Files.readAllBytes(current.toPath()); + if (!isJsonObject(content)) { + return null; + } + List existing = list(profile); + if (!existing.isEmpty()) { + Backup newest = existing.get(0); + if (!force && now - newest.createdAt < MIN_INTERVAL_MS) { + return null; + } + if (Arrays.equals(content, Files.readAllBytes(newest.file.toPath()))) { + return null; + } + } + File target = uniqueFile(profile, now); + AtomicFiles.write(target.toPath(), content); + prune(profile); + return new Backup(target, now); + } + + /** The newest backup whose content is a JSON object, or null. */ + public Backup newestValid(String profile) { + for (Backup backup : list(profile)) { + try { + if (isJsonObject(Files.readAllBytes(backup.file.toPath()))) { + return backup; + } + } catch (IOException exception) { + ClientLogger.warn("Unreadable config backup " + backup.file.getName() + ": " + exception.getMessage()); + } + } + return null; + } + + /** Moves a profile's backups along with a rename. */ + public void rename(String from, String to) { + File source = dir(from); + if (!source.isDirectory()) { + return; + } + File target = dir(to); + if (target.exists() || !source.renameTo(target)) { + ClientLogger.warn("Config backups of " + from + " were not moved to " + to); + } + } + + static boolean isJsonObject(byte[] content) { + try { + JsonElement element = new JsonParser().parse(new String(content, StandardCharsets.UTF_8)); + return element != null && element.isJsonObject(); + } catch (RuntimeException invalid) { + return false; + } + } + + private void prune(String profile) { + List backups = list(profile); + for (int i = KEEP; i < backups.size(); i++) { + File file = backups.get(i).file; + if (!file.delete()) { + ClientLogger.warn("Failed to prune config backup: " + file.getAbsolutePath()); + } + } + } + + private File dir(String profile) { + return new File(root, profile); + } + + private File uniqueFile(String profile, long now) { + String stamp = new SimpleDateFormat(PATTERN, Locale.ROOT).format(new Date(now)); + File file = new File(dir(profile), stamp + SUFFIX); + // Two snapshots in the same millisecond (a forced one right after a regular one): step forward + // so neither overwrites the other and the order stays right. + while (file.exists()) { + now++; + stamp = new SimpleDateFormat(PATTERN, Locale.ROOT).format(new Date(now)); + file = new File(dir(profile), stamp + SUFFIX); + } + return file; + } + + private static long parseTimestamp(String fileName) { + String stamp = fileName.substring(0, fileName.length() - SUFFIX.length()); + try { + SimpleDateFormat format = new SimpleDateFormat(PATTERN, Locale.ROOT); + format.setLenient(false); + return format.parse(stamp).getTime(); + } catch (ParseException notOurs) { + return 0L; + } + } +} diff --git a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java index 1cea3ef6..9dc45776 100644 --- a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java +++ b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java @@ -37,6 +37,7 @@ public class ConfigManager { private boolean configLoaded; public Configure configure = new Configure(); + private volatile String[] recoveryNotice; public void saveConfigQuietly(String name) { if (loadingConfig) { @@ -189,23 +190,26 @@ public void loadConfig(String name) throws Exception { try { File configFile = ConfigProfileUtils.getProfileFile(name); ClientLogger.info("Loading config: " + configFile.getAbsolutePath()); + boolean existed = configFile.isFile() && configFile.length() > 0L; String jsonStr = ConfigProfileUtils.readConfigFile(name); if (jsonStr.trim().isEmpty()) { - ClientLogger.warn("Config file is empty, creating default config: " + configFile.getAbsolutePath()); - resetConfigToDefaults(name); + if (existed) { + recoverCorruptConfig(name, configFile, "empty"); + } else { + ClientLogger.info("No config yet, creating default config: " + configFile.getAbsolutePath()); + resetConfigToDefaults(name); + } return; } JsonObject json; try { json = gson.fromJson(jsonStr, JsonObject.class); } catch (JsonSyntaxException exception) { - ClientLogger.warn("Config file is invalid JSON, resetting: " + name + ".json"); - resetConfigToDefaults(name); + recoverCorruptConfig(name, configFile, "invalid JSON: " + exception.getMessage()); return; } if (json == null) { - ClientLogger.warn("Config file is empty or invalid JSON, resetting: " + name + ".json"); - resetConfigToDefaults(name); + recoverCorruptConfig(name, configFile, "not a JSON object"); return; } @@ -418,25 +422,19 @@ public void loadConfig(String name) throws Exception { } Shortcut.shortcuts.clear(); - JsonArray shortcutsJson = Optional.ofNullable(json.getAsJsonArray("shortcuts")).orElse(new JsonArray()); + JsonElement shortcutsElement = json.get("shortcuts"); + JsonArray shortcutsJson = shortcutsElement != null && shortcutsElement.isJsonArray() + ? shortcutsElement.getAsJsonArray() : new JsonArray(); for (JsonElement element : shortcutsJson) { - if (element.isJsonObject()) { - JsonObject shortcutJson = element.getAsJsonObject(); - ArrayList actions = new ArrayList<>(); - JsonArray actionsJson = shortcutJson.getAsJsonArray("actions"); - for (JsonElement actionElement : actionsJson) { - if (actionElement.isJsonObject()) { - JsonObject actionJson = actionElement.getAsJsonObject(); - String context = actionJson.has("context") - ? actionJson.get("context").getAsString() - : actionJson.has("Context") ? actionJson.get("Context").getAsString() : ""; - actions.add(new Shortcut.Action(Shortcut.Action.Type.valueOf(actionJson.get("type").getAsString()), context)); - } + // One malformed or downgraded entry (unknown action type, missing name/key) used to + // throw out of loadConfig and skip the rest of client initialization with it. + try { + Shortcut shortcut = parseShortcut(element); + if (shortcut != null) { + Shortcut.shortcuts.add(shortcut); } - Shortcut.shortcuts.add(new Shortcut(shortcutJson.get("name").getAsString(), - shortcutJson.get("key").getAsInt(), - actions - )); + } catch (RuntimeException exception) { + ClientLogger.warn("Skipping malformed shortcut in config " + name + ": " + exception); } } } finally { @@ -445,6 +443,76 @@ public void loadConfig(String name) throws Exception { } } + private static Shortcut parseShortcut(JsonElement element) { + if (element == null || !element.isJsonObject()) { + return null; + } + JsonObject shortcutJson = element.getAsJsonObject(); + ArrayList actions = new ArrayList<>(); + JsonElement actionsElement = shortcutJson.get("actions"); + if (actionsElement != null && actionsElement.isJsonArray()) { + for (JsonElement actionElement : actionsElement.getAsJsonArray()) { + if (!actionElement.isJsonObject()) { + continue; + } + JsonObject actionJson = actionElement.getAsJsonObject(); + String context = actionJson.has("context") + ? actionJson.get("context").getAsString() + : actionJson.has("Context") ? actionJson.get("Context").getAsString() : ""; + try { + actions.add(new Shortcut.Action(Shortcut.Action.Type.valueOf(actionJson.get("type").getAsString()), context)); + } catch (IllegalArgumentException unknownType) { + ClientLogger.warn("Skipping shortcut action of unknown type: " + actionJson.get("type")); + } + } + } + return new Shortcut(shortcutJson.get("name").getAsString(), shortcutJson.get("key").getAsInt(), actions); + } + + /** + * A config that exists but cannot be read is never silently replaced by defaults: the damaged file + * is kept as {@code .json.corrupt-

Truncating the target and then writing it means a crash, power loss or a killed process in + * between leaves an empty or half JSON file, which used to cost players their whole config. Here the + * bytes go to a uniquely named sibling temp file first (unique so two clients sharing a directory + * never write the same temp), are forced to disk, and only then replace the target in one rename. + */ +public final class AtomicFiles { + private AtomicFiles() { + } + + public static void write(Path target, byte[] content) throws IOException { + Path parent = target.toAbsolutePath().getParent(); + Files.createDirectories(parent); + Path temp = Files.createTempFile(parent, target.getFileName().toString() + ".", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(content); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + // Some network / FAT volumes cannot rename atomically; a plain replace is still far + // better than truncating in place, since the complete file already exists. + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException failure) { + try { + Files.deleteIfExists(temp); + } catch (IOException suppressed) { + failure.addSuppressed(suppressed); + } + throw failure; + } + } +} diff --git a/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java b/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java index f3d0f683..538baaaa 100644 --- a/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java +++ b/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java @@ -82,6 +82,11 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { GL11.glPopMatrix(); UiScale.end(); } + // In a world the HUD pass draws notifications; out of one (main menu, config screens) nothing + // did, so results such as "config restored from backup" were never seen. + if (mc.theWorld == null) { + top.fpsmaster.ui.notification.NotificationManager.drawNotifications(); + } } /** diff --git a/src/main/java/top/fpsmaster/utils/system/FileDialogs.java b/src/main/java/top/fpsmaster/utils/system/FileDialogs.java new file mode 100644 index 00000000..0b751d20 --- /dev/null +++ b/src/main/java/top/fpsmaster/utils/system/FileDialogs.java @@ -0,0 +1,71 @@ +package top.fpsmaster.utils.system; + +import net.minecraft.client.Minecraft; +import top.fpsmaster.modules.logger.ClientLogger; + +import java.awt.FileDialog; +import java.awt.Frame; +import java.io.File; +import java.io.FilenameFilter; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Native file pickers that do not freeze the game. + * + *

An AWT {@link FileDialog} is modal: {@code setVisible(true)} blocks its caller until the dialog + * closes. Called from a GUI callback that is the render thread, so the game stopped rendering and + * ticking (Windows showed "not responding", a server kicked the player on keepalive timeout after + * ~30 s). The dialog now blocks a daemon thread instead and the choice is handed back on the client + * thread via {@link Minecraft#addScheduledTask}. + */ +public final class FileDialogs { + /** One picker at a time: a second click while one is open would stack modal dialogs. */ + private static final AtomicBoolean OPEN = new AtomicBoolean(false); + + private FileDialogs() { + } + + /** + * @param onChosen runs on the client thread with the chosen file, or is not called on cancel + * @return false when another picker is already open + */ + public static boolean open(String title, boolean save, File directory, String fileName, + FilenameFilter filter, Consumer onChosen) { + if (!OPEN.compareAndSet(false, true)) { + return false; + } + Thread thread = new Thread(() -> { + File chosen = null; + try { + FileDialog dialog = new FileDialog((Frame) null, title, save ? FileDialog.SAVE : FileDialog.LOAD); + if (directory != null) { + dialog.setDirectory(directory.getAbsolutePath()); + } + if (fileName != null) { + dialog.setFile(fileName); + } + if (filter != null) { + dialog.setFilenameFilter(filter); + } + dialog.setVisible(true); + if (dialog.getFile() != null) { + chosen = new File(dialog.getDirectory(), dialog.getFile()); + } + dialog.dispose(); + } catch (RuntimeException | LinkageError exception) { + // Headless JREs and broken AWT installs throw here; the caller simply gets no file. + ClientLogger.error("File dialog failed: " + exception); + } finally { + OPEN.set(false); + } + if (chosen != null) { + File result = chosen; + Minecraft.getMinecraft().addScheduledTask(() -> onChosen.accept(result)); + } + }, "FPSMaster-FileDialog"); + thread.setDaemon(true); + thread.start(); + return true; + } +} diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 9da1ad73..5b8e085a 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -1357,3 +1357,19 @@ music.error.badresponse=The music platform returned unreadable data music.error.api=The music platform returned an error: %s music.error.unknown=Failed to load: %s mainmenu.account.remove.confirm=Click again to remove +configprofiles.history=Backups +configprofiles.history.title=Backup history +configprofiles.history.subtitle=Profile %s · keeps the latest %d automatically +configprofiles.history.empty=No backups yet. They are made automatically every few minutes as the config saves. +configprofiles.history.restore=Restore +configprofiles.history.confirm=Restore the backup from %s? The current config is backed up first. +configprofiles.history.restored=Restored the backup from %s +configprofiles.history.restore_failed=Failed to restore the backup +configprofiles.import.conflict=A profile named "%s" already exists +configprofiles.import.conflict.desc=Overwrite replaces it (backed up first), or save as "%s" +configprofiles.import.overwrite=Overwrite +configprofiles.import.saveas=Save as +configprofiles.filedialog.busy=A file dialog is already open +config.recovery.title=Config recovered +config.recovery.restored=Config "%s" could not be read and was restored from the backup made at %s +config.recovery.reset=Config "%s" could not be read and had no usable backup, so it was reset to defaults; the original was kept as %s diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index be4f680e..4ef406e4 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -1357,3 +1357,19 @@ music.error.badresponse=音乐平台返回了无法识别的数据 music.error.api=音乐平台返回错误:%s music.error.unknown=加载失败:%s mainmenu.account.remove.confirm=再点一次删除 +configprofiles.history=备份历史 +configprofiles.history.title=备份历史 +configprofiles.history.subtitle=配置 %s · 自动保留最近 %d 份 +configprofiles.history.empty=还没有备份。配置保存时会每隔几分钟自动备份一次。 +configprofiles.history.restore=恢复 +configprofiles.history.confirm=恢复到 %s 的备份?当前配置会先备份。 +configprofiles.history.restored=已恢复到 %s 的备份 +configprofiles.history.restore_failed=恢复备份失败 +configprofiles.import.conflict=已存在名为“%s”的配置 +configprofiles.import.conflict.desc=覆盖会替换它(原内容先备份),或另存为“%s” +configprofiles.import.overwrite=覆盖 +configprofiles.import.saveas=另存为 +configprofiles.filedialog.busy=文件选择窗口已经打开 +config.recovery.title=配置已恢复 +config.recovery.restored=配置“%s”无法读取,已从 %s 的备份恢复 +config.recovery.reset=配置“%s”无法读取且没有可用备份,已重置为默认;原文件保留为 %s diff --git a/src/test/java/top/fpsmaster/modules/config/ConfigBackupsTest.java b/src/test/java/top/fpsmaster/modules/config/ConfigBackupsTest.java new file mode 100644 index 00000000..145b05fb --- /dev/null +++ b/src/test/java/top/fpsmaster/modules/config/ConfigBackupsTest.java @@ -0,0 +1,97 @@ +package top.fpsmaster.modules.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import top.fpsmaster.utils.io.AtomicFiles; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigBackupsTest { + @TempDir + Path temp; + + private File write(String content) throws IOException { + File file = temp.resolve("default.json").toFile(); + AtomicFiles.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return file; + } + + @Test + void regularSavesSnapshotAtMostEveryInterval() throws IOException { + ConfigBackups backups = new ConfigBackups(temp.resolve("backups").toFile()); + long now = 1_700_000_000_000L; + File config = write("{\"v\":1}"); + assertNotNull(backups.snapshot("default", config, false, now)); + + write("{\"v\":2}"); + assertNull(backups.snapshot("default", config, false, now + 1_000L), "inside the interval"); + assertNotNull(backups.snapshot("default", config, false, now + ConfigBackups.MIN_INTERVAL_MS)); + // Same content as the newest backup is never stored twice, even when forced. + assertNull(backups.snapshot("default", config, true, now + 3 * ConfigBackups.MIN_INTERVAL_MS)); + + write("{\"v\":3}"); + assertNotNull(backups.snapshot("default", config, true, now + ConfigBackups.MIN_INTERVAL_MS + 5L), + "forced snapshots ignore the interval"); + assertEquals(3, backups.list("default").size()); + } + + @Test + void corruptFilesAreNeverBackedUpAndRecoveryPicksNewestValid() throws IOException { + ConfigBackups backups = new ConfigBackups(temp.resolve("backups").toFile()); + long now = 1_700_000_000_000L; + File config = write("{\"good\":true}"); + backups.snapshot("default", config, true, now); + + write("{\"half\": "); + assertNull(backups.snapshot("default", config, true, now + 10L)); + write(""); + assertNull(backups.snapshot("default", config, true, now + 20L)); + + ConfigBackups.Backup newest = backups.newestValid("default"); + assertNotNull(newest); + assertArrayEquals("{\"good\":true}".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(newest.getFile().toPath())); + } + + @Test + void keepsOnlyTheNewestBackupsNewestFirst() throws IOException { + ConfigBackups backups = new ConfigBackups(temp.resolve("backups").toFile()); + long now = 1_700_000_000_000L; + File config = temp.resolve("default.json").toFile(); + for (int i = 0; i < ConfigBackups.KEEP + 5; i++) { + write("{\"v\":" + i + "}"); + assertNotNull(backups.snapshot("default", config, true, now + i * 1_000L)); + } + List list = backups.list("default"); + assertEquals(ConfigBackups.KEEP, list.size()); + assertEquals(now + (ConfigBackups.KEEP + 4) * 1_000L, list.get(0).getCreatedAt()); + for (int i = 1; i < list.size(); i++) { + assertTrue(list.get(i - 1).getCreatedAt() > list.get(i).getCreatedAt()); + } + assertArrayEquals(("{\"v\":" + (ConfigBackups.KEEP + 4) + "}").getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(list.get(0).getFile().toPath())); + } + + @Test + void atomicWriteReplacesWithoutLeavingTempFiles() throws IOException { + File config = write("{\"v\":1}"); + AtomicFiles.write(config.toPath(), "{\"v\":2}".getBytes(StandardCharsets.UTF_8)); + assertArrayEquals("{\"v\":2}".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(config.toPath())); + String[] names = temp.toFile().list(); + assertNotNull(names); + for (String name : names) { + assertTrue(!name.endsWith(".tmp"), "leftover temp file " + name); + } + } +} From ada13de5b8a8a338c20153c800d26f8da0e941a7 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:33:00 -0700 Subject: [PATCH 05/15] =?UTF-8?q?fix(account):=20accounts.json=20=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E6=B8=B8=E6=88=8F=E7=9B=AE=E5=BD=95=E5=B9=B6=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存有微软 refreshToken 的账号文件改放到用户级 FPSMaster 目录(与 auth.json 同处, 文件名 edge_accounts.json),首次启动自动迁移并删除游戏目录里的旧文件, 避免玩家打包分享配置目录时泄露令牌。 --- .../modules/account/AccountManager.java | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/top/fpsmaster/modules/account/AccountManager.java b/src/main/java/top/fpsmaster/modules/account/AccountManager.java index f3f32f02..e67719d7 100644 --- a/src/main/java/top/fpsmaster/modules/account/AccountManager.java +++ b/src/main/java/top/fpsmaster/modules/account/AccountManager.java @@ -11,7 +11,9 @@ import top.fpsmaster.FPSMaster; import top.fpsmaster.exception.AccountException; import top.fpsmaster.forge.api.IMinecraft; +import top.fpsmaster.modules.client.api.AuthService; import top.fpsmaster.modules.logger.ClientLogger; +import top.fpsmaster.utils.io.AtomicFiles; import top.fpsmaster.utils.io.FileUtils; import java.io.File; @@ -343,11 +345,39 @@ private static String offlineUuid(String name) { return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)).toString(); } + /** + * Microsoft refresh tokens live here, so the file sits in the per-user FPSMaster directory next to + * auth.json, not in the game's "FPSMaster Edge" folder that players zip up and share with their + * configs. Named for Edge so it never collides with another FPSMaster client's file. + */ private File file() { + return new File(AuthService.getInstance().getAuthFile().getParentFile(), "edge_accounts.json"); + } + + private File legacyFile() { return new File(FileUtils.dir, "accounts.json"); } + /** Moves accounts.json out of the shareable game folder the first time this version runs. */ + private void migrateLegacyFile() { + File legacy = legacyFile(); + File target = file(); + if (!legacy.isFile()) { + return; + } + try { + if (!target.exists()) { + AtomicFiles.write(target.toPath(), Files.readAllBytes(legacy.toPath())); + } + Files.delete(legacy.toPath()); + ClientLogger.info("Moved accounts.json out of the game folder to " + target.getAbsolutePath()); + } catch (IOException exception) { + ClientLogger.warn("Failed to move accounts.json out of the game folder: " + exception); + } + } + private void load() { + migrateLegacyFile(); File file = file(); if (!file.isFile()) { return; @@ -407,7 +437,7 @@ private void save() { root.addProperty("selected", selectedName); } root.add("accounts", GSON.toJsonTree(accounts)); - Files.write(file().toPath(), GSON.toJson(root).getBytes(StandardCharsets.UTF_8)); + AtomicFiles.write(file().toPath(), GSON.toJson(root).getBytes(StandardCharsets.UTF_8)); } catch (IOException exception) { ClientLogger.warn("Failed to save accounts.json: " + exception); } From 7c1b393447edfdc8deaf5ca685d2d768c50fcb44 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:34:33 -0700 Subject: [PATCH 06/15] =?UTF-8?q?fix(auth):=20=E5=90=AF=E5=8A=A8=20token?= =?UTF-8?q?=20=E6=94=B9=E8=AF=BB=20FPSMASTER=5FLAUNCH=5FTOKEN=EF=BC=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=BB=9F=E4=B8=80=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 先读环境变量,读不到再读旧的 -Dfpsmaster.auth.token;读完立即清除相关系统属性 - 启动子进程(打开文件夹、ffmpeg)时从环境里去掉该变量,不再传给子进程 - ClientLogger 所有输出经 Redactor 过滤:已读到的 token 精确替换, 另按 JWT、Bearer、-Dfpsmaster.auth.*、*token=、cookie 等形态兜底 --- .../modules/client/api/AuthService.java | 35 +++++++-- .../modules/logger/ClientLogger.java | 27 ++++--- .../fpsmaster/modules/logger/Redactor.java | 73 +++++++++++++++++++ .../replay/director/DirectorExporter.java | 4 +- .../utils/system/ChildProcesses.java | 30 ++++++++ .../fpsmaster/utils/system/FolderOpen.java | 6 +- .../modules/logger/RedactorTest.java | 30 ++++++++ 7 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 src/main/java/top/fpsmaster/modules/logger/Redactor.java create mode 100644 src/main/java/top/fpsmaster/utils/system/ChildProcesses.java create mode 100644 src/test/java/top/fpsmaster/modules/logger/RedactorTest.java diff --git a/src/main/java/top/fpsmaster/modules/client/api/AuthService.java b/src/main/java/top/fpsmaster/modules/client/api/AuthService.java index d52847b4..c816f840 100644 --- a/src/main/java/top/fpsmaster/modules/client/api/AuthService.java +++ b/src/main/java/top/fpsmaster/modules/client/api/AuthService.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import top.fpsmaster.cosmetic.RemoteCosmeticService; import top.fpsmaster.modules.logger.ClientLogger; +import top.fpsmaster.modules.logger.Redactor; import java.io.BufferedReader; import java.io.BufferedWriter; @@ -20,10 +21,13 @@ /** * Service for managing authentication tokens at system user level. * Tokens are stored in AppData/Roaming/FPSMaster/auth.json - * Launcher can pass tokens via system properties: fpsmaster.auth.token + * The launcher passes the platform token in the environment variable {@value #LAUNCH_TOKEN_ENV}; older + * launchers use the system property {@value #SYSTEM_PROPERTY_TOKEN}, which is read as a fallback and + * then cleared so it does not linger in System properties for the rest of the session. */ public class AuthService { private static final String AUTH_FILE_NAME = "auth.json"; + private static final String LAUNCH_TOKEN_ENV = "FPSMASTER_LAUNCH_TOKEN"; private static final String SYSTEM_PROPERTY_TOKEN = "fpsmaster.auth.token"; private static final String SYSTEM_PROPERTY_REFRESH = "fpsmaster.auth.refreshToken"; private static final String SYSTEM_PROPERTY_EXPIRES = "fpsmaster.auth.tokenExpiresAt"; @@ -81,13 +85,19 @@ public static AuthService getInstance() { * Initialize the service and check for launcher-provided tokens */ public void initialize() { - // Check if launcher provided tokens via system properties - String launcherToken = System.getProperty(SYSTEM_PROPERTY_TOKEN); + String launcherToken = readLaunchToken(); + String launcherRefresh = System.getProperty(SYSTEM_PROPERTY_REFRESH); + String expiresStr = System.getProperty(SYSTEM_PROPERTY_EXPIRES); + // Read once, then gone: anything that dumps System properties later (crash reports, mods, + // debug commands) must not find the tokens there. + System.clearProperty(SYSTEM_PROPERTY_TOKEN); + System.clearProperty(SYSTEM_PROPERTY_REFRESH); + System.clearProperty(SYSTEM_PROPERTY_EXPIRES); if (launcherToken != null && !launcherToken.isEmpty()) { ClientLogger.info("Found auth token from launcher"); this.accessToken = launcherToken; - this.refreshToken = System.getProperty(SYSTEM_PROPERTY_REFRESH); - String expiresStr = System.getProperty(SYSTEM_PROPERTY_EXPIRES); + this.refreshToken = launcherRefresh; + Redactor.registerSecret(launcherRefresh); if (expiresStr != null && !expiresStr.isEmpty()) { try { this.tokenExpiresAt = Long.parseLong(expiresStr); @@ -105,6 +115,17 @@ public void initialize() { } } + /** Environment variable first (current launcher), then the legacy {@code -D} property. */ + private static String readLaunchToken() { + String token = System.getenv(LAUNCH_TOKEN_ENV); + if (token == null || token.trim().isEmpty()) { + token = System.getProperty(SYSTEM_PROPERTY_TOKEN); + } + token = token == null ? null : token.trim(); + Redactor.registerSecret(token); + return token; + } + public boolean isLoggedIn() { return accessToken != null && !accessToken.isEmpty() && !isTokenExpired(); } @@ -133,6 +154,8 @@ public void saveTokens(String access, String refresh, long expiresAt) { this.accessToken = access; this.refreshToken = refresh; this.tokenExpiresAt = expiresAt; + Redactor.registerSecret(access); + Redactor.registerSecret(refresh); saveToFile(); } } @@ -231,6 +254,8 @@ private void loadFromFileLocked() { refreshToken = json.has("refreshToken") && !json.get("refreshToken").isJsonNull() ? json.get("refreshToken").getAsString() : null; + Redactor.registerSecret(accessToken); + Redactor.registerSecret(refreshToken); tokenExpiresAt = json.has("tokenExpiresAt") && !json.get("tokenExpiresAt").isJsonNull() ? json.get("tokenExpiresAt").getAsLong() : 0L; diff --git a/src/main/java/top/fpsmaster/modules/logger/ClientLogger.java b/src/main/java/top/fpsmaster/modules/logger/ClientLogger.java index 460fa9c8..d23549e1 100644 --- a/src/main/java/top/fpsmaster/modules/logger/ClientLogger.java +++ b/src/main/java/top/fpsmaster/modules/logger/ClientLogger.java @@ -3,51 +3,50 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +/** Every line goes through {@link Redactor}: logs are what players paste into bug reports. */ public class ClientLogger { private static final Logger logger = LogManager.getLogger("FPSMaster"); public static void info(String s) { - logger.info(s); + logger.info(Redactor.redact(s)); } public static void error(String s) { - logger.error(s); + logger.error(Redactor.redact(s)); } public static void warn(String s) { - logger.warn(s); + logger.warn(Redactor.redact(s)); } public static void debug(String s) { - logger.debug(s); + logger.debug(Redactor.redact(s)); } public static void fatal(String s) { - logger.fatal(s); + logger.fatal(Redactor.redact(s)); } public static void trace(String s) { - logger.trace(s); + logger.trace(Redactor.redact(s)); } public static void info(String from, String s) { - logger.info("{} -> {}", from, s); + logger.info("{} -> {}", from, Redactor.redact(s)); } public static void error(String from, String s) { - logger.error("{} -> {}", from, s); + logger.error("{} -> {}", from, Redactor.redact(s)); } // Throwable overloads: without these, callers that catch an exception have no way to record it - // and the stack trace is silently dropped. + // and the stack trace is silently dropped. The trace is rendered and redacted here because an + // exception message can carry a URL or header with a token in it. public static void error(String s, Throwable throwable) { - logger.error(s, throwable); + logger.error(Redactor.redact(s) + System.lineSeparator() + Redactor.redact(throwable)); } public static void warn(String s, Throwable throwable) { - logger.warn(s, throwable); + logger.warn(Redactor.redact(s) + System.lineSeparator() + Redactor.redact(throwable)); } } - - - diff --git a/src/main/java/top/fpsmaster/modules/logger/Redactor.java b/src/main/java/top/fpsmaster/modules/logger/Redactor.java new file mode 100644 index 00000000..846af912 --- /dev/null +++ b/src/main/java/top/fpsmaster/modules/logger/Redactor.java @@ -0,0 +1,73 @@ +package top.fpsmaster.modules.logger; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Scrubs credentials from text before it reaches a log line or a report. + * + *

Two layers: exact values registered at runtime ({@link #registerSecret}, e.g. the launch token + * once it has been read), and patterns for credential shapes we did not see coming — JWTs, bearer + * headers, {@code -Dfpsmaster.auth.token=...} and {@code key: value} pairs named like tokens. + */ +public final class Redactor { + public static final String MASK = ""; + /** Shorter values are not worth masking and would mangle ordinary words. */ + private static final int MIN_SECRET_LENGTH = 8; + + private static final Set SECRETS = new CopyOnWriteArraySet<>(); + + private static final Pattern[] PATTERNS = { + // JSON Web Tokens: three base64url segments, the first one always starts with eyJ. + Pattern.compile("eyJ[A-Za-z0-9_-]{4,}\\.[A-Za-z0-9_-]{4,}\\.[A-Za-z0-9_-]*"), + Pattern.compile("(?i)(bearer\\s+)[A-Za-z0-9._~+/=-]{8,}"), + Pattern.compile("(?i)(-Dfpsmaster\\.auth\\.[A-Za-z]+=)\\S+"), + Pattern.compile("(?i)(FPSMASTER_LAUNCH_TOKEN=)\\S+"), + Pattern.compile("(?i)((?:access|refresh|auth|launch)[_-]?token\"?\\s*[:=]\\s*\"?)[^\"\\s,;}&]+"), + Pattern.compile("(?i)((?:password|passwd|cookie|MUSIC_U|qm_keyst)\"?\\s*[:=]\\s*\"?)[^\"\\s,;}&]+"), + }; + + private Redactor() { + } + + public static void registerSecret(String secret) { + if (secret != null && secret.length() >= MIN_SECRET_LENGTH) { + SECRETS.add(secret); + } + } + + public static String redact(String text) { + if (text == null || text.isEmpty()) { + return text; + } + String result = text; + for (String secret : SECRETS) { + if (result.contains(secret)) { + result = result.replace(secret, MASK); + } + } + for (Pattern pattern : PATTERNS) { + Matcher matcher = pattern.matcher(result); + if (!matcher.find()) { + continue; + } + StringBuffer out = new StringBuffer(); + do { + String prefix = matcher.groupCount() >= 1 && matcher.group(1) != null ? matcher.group(1) : ""; + matcher.appendReplacement(out, Matcher.quoteReplacement(prefix + MASK)); + } while (matcher.find()); + matcher.appendTail(out); + result = out.toString(); + } + return result; + } + + /** The full stack trace of {@code throwable}, redacted. */ + public static String redact(Throwable throwable) { + java.io.StringWriter writer = new java.io.StringWriter(); + throwable.printStackTrace(new java.io.PrintWriter(writer)); + return redact(writer.toString()); + } +} diff --git a/src/main/java/top/fpsmaster/replay/director/DirectorExporter.java b/src/main/java/top/fpsmaster/replay/director/DirectorExporter.java index a4868ba8..6d55c2ab 100644 --- a/src/main/java/top/fpsmaster/replay/director/DirectorExporter.java +++ b/src/main/java/top/fpsmaster/replay/director/DirectorExporter.java @@ -169,7 +169,7 @@ public static boolean start(int framesPerSecond, int exportWidth, int exportHeig command.add("yuv420p"); command.add(outputFile.getAbsolutePath()); try { - ProcessBuilder builder = new ProcessBuilder(command); + ProcessBuilder builder = top.fpsmaster.utils.system.ChildProcesses.builder(command); builder.redirectErrorStream(false); ffmpeg = builder.start(); } catch (IOException exception) { @@ -581,7 +581,7 @@ private static String locateFfmpeg() { try { // Read -version output to EOF before waiting: closing the pipe instead makes // ffmpeg die on EPIPE and look "missing" even when it is right there. - ProcessBuilder builder = new ProcessBuilder(candidate, "-version"); + ProcessBuilder builder = top.fpsmaster.utils.system.ChildProcesses.builder(candidate, "-version"); builder.redirectErrorStream(true); Process probe = builder.start(); byte[] scratch = new byte[4096]; diff --git a/src/main/java/top/fpsmaster/utils/system/ChildProcesses.java b/src/main/java/top/fpsmaster/utils/system/ChildProcesses.java new file mode 100644 index 00000000..ff43e1ae --- /dev/null +++ b/src/main/java/top/fpsmaster/utils/system/ChildProcesses.java @@ -0,0 +1,30 @@ +package top.fpsmaster.utils.system; + +import java.util.List; + +/** + * Process builders for anything the client spawns (explorer, ffmpeg, ...). + * + *

The launcher hands the platform token over in {@code FPSMASTER_LAUNCH_TOKEN}. Java cannot unset + * its own environment, but it can keep the variable out of every child: those inherit the whole + * environment by default and have no business holding the player's token. + */ +public final class ChildProcesses { + private static final String LAUNCH_TOKEN_ENV = "FPSMASTER_LAUNCH_TOKEN"; + + private ChildProcesses() { + } + + public static ProcessBuilder builder(String... command) { + return scrub(new ProcessBuilder(command)); + } + + public static ProcessBuilder builder(List command) { + return scrub(new ProcessBuilder(command)); + } + + private static ProcessBuilder scrub(ProcessBuilder builder) { + builder.environment().remove(LAUNCH_TOKEN_ENV); + return builder; + } +} diff --git a/src/main/java/top/fpsmaster/utils/system/FolderOpen.java b/src/main/java/top/fpsmaster/utils/system/FolderOpen.java index 2f5d83e1..5a72507a 100644 --- a/src/main/java/top/fpsmaster/utils/system/FolderOpen.java +++ b/src/main/java/top/fpsmaster/utils/system/FolderOpen.java @@ -80,7 +80,7 @@ static String[] windowsExplorerArgs(File folder) { private static boolean openWindows(File folder) { String path = folder.getAbsolutePath(); try { - new ProcessBuilder(windowsExplorerArgs(folder)).start(); + ChildProcesses.builder(windowsExplorerArgs(folder)).start(); requestExplorerForeground(path); return true; } catch (IOException exception) { @@ -88,7 +88,7 @@ private static boolean openWindows(File folder) { } try { // start gives the child its own process group so it can take foreground. - new ProcessBuilder("cmd", "/c", "start", "", "explorer.exe", path).start(); + ChildProcesses.builder("cmd", "/c", "start", "", "explorer.exe", path).start(); requestExplorerForeground(path); return true; } catch (IOException exception) { @@ -188,7 +188,7 @@ private static boolean desktopOpen(File folder) { private static boolean run(String[] command) { try { - new ProcessBuilder(command).start(); + ChildProcesses.builder(command).start(); return true; } catch (IOException exception) { ClientLogger.warn("Failed to run " + command[0] + ": " + exception.getMessage()); diff --git a/src/test/java/top/fpsmaster/modules/logger/RedactorTest.java b/src/test/java/top/fpsmaster/modules/logger/RedactorTest.java new file mode 100644 index 00000000..351c2ee0 --- /dev/null +++ b/src/test/java/top/fpsmaster/modules/logger/RedactorTest.java @@ -0,0 +1,30 @@ +package top.fpsmaster.modules.logger; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RedactorTest { + @Test + void masksTokenShapes() { + String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcDEF_123-xyz"; + assertEquals("token " + Redactor.MASK + " end", Redactor.redact("token " + jwt + " end")); + assertEquals("Authorization: Bearer " + Redactor.MASK, Redactor.redact("Authorization: Bearer abcdef0123456789")); + assertEquals("-Dfpsmaster.auth.token=" + Redactor.MASK + " -Xmx2G", + Redactor.redact("-Dfpsmaster.auth.token=plainsecret -Xmx2G")); + assertEquals("FPSMASTER_LAUNCH_TOKEN=" + Redactor.MASK, Redactor.redact("FPSMASTER_LAUNCH_TOKEN=plainsecret")); + assertEquals("{\"accessToken\":\"" + Redactor.MASK + "\"}", Redactor.redact("{\"accessToken\":\"abc.def\"}")); + assertEquals("refresh_token=" + Redactor.MASK + "&x=1", Redactor.redact("refresh_token=M.R3_abc&x=1")); + } + + @Test + void masksRegisteredSecretsAndLeavesOrdinaryTextAlone() { + Redactor.registerSecret("opaque-launch-secret-42"); + assertFalse(Redactor.redact("failed with opaque-launch-secret-42 in url").contains("opaque-launch-secret-42")); + String plain = "Loaded config profile: default (12 modules)"; + assertEquals(plain, Redactor.redact(plain)); + assertTrue(Redactor.redact(new IllegalStateException("bad opaque-launch-secret-42")).contains(Redactor.MASK)); + } +} From 04cffa1f03cd1ea5e656117ae10f447a490aefde Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:37:36 -0700 Subject: [PATCH 07/15] =?UTF-8?q?fix(music):=20=E6=92=AD=E6=94=BE=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E9=9A=94=E7=A6=BB=E3=80=81=E5=88=97=E8=A1=A8=E5=9B=9B?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=A4=B1=E8=B4=A5=E8=B4=9F=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AudioEngine 每次播放一个独立会话:旧线程只认自己的会话,切歌不再两首叠放; 停止时断开旧连接而不是在主线程 join 500ms;下载期间显示「缓冲中」 - MusicBridge 上报 tracksState/playlistsState:请求中 LOADING,成功(含空)READY, 未登录 NEED_LOGIN,其他按 Cadence 的错误类型给出本地化原因;过期回包丢弃 - 平台判定未登录或 getLoginUid 返回 null 时本地切回未登录并清除失效凭证 - 音乐请求改用专用的 2 线程池,不再占共享 async 池 - 封面下载/解码失败负缓存 5 分钟,不再每帧重下;网易云封面按 300px 取图 - 本地音乐目录在后台扫描,只列一次目录建索引找封面 - 界面状态文案改走 i18n(中英) --- .../fpsmaster/modules/music/AudioEngine.java | 243 ++++++++++++------ .../fpsmaster/modules/music/MusicManager.java | 104 ++++++-- .../modules/music/MusicTextures.java | 51 +++- .../ui/screens/music/MusicScreen.java | 168 +++++++++--- .../assets/minecraft/client/lang/en_us.lang | 11 + .../assets/minecraft/client/lang/zh_cn.lang | 11 + 6 files changed, 435 insertions(+), 153 deletions(-) diff --git a/src/main/java/top/fpsmaster/modules/music/AudioEngine.java b/src/main/java/top/fpsmaster/modules/music/AudioEngine.java index 277bedc5..f2f95708 100644 --- a/src/main/java/top/fpsmaster/modules/music/AudioEngine.java +++ b/src/main/java/top/fpsmaster/modules/music/AudioEngine.java @@ -25,6 +25,10 @@ * *

解码与播放在后台线程进行,绝不阻塞渲染线程;曲目自然播完时通过 * {@link Minecraft#addScheduledTask(Runnable)} 在主线程回调 {@code onEnded}。 + * + *

每次播放是一个独立的 {@link Session}:线程只认自己的会话,只有当前会话能改进度、时长和音量控件。 + * 以前所有播放共用一个 {@code stopped} 标志,旧线程卡在慢速下载里时,新曲目把标志改回 false, + * 旧线程醒来就接着播,两首歌叠在一起;停止也不再在主线程 join,而是断开旧连接让阻塞的读立即失败。 */ public class AudioEngine { @@ -32,22 +36,41 @@ public class AudioEngine { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36"; - private volatile Thread playThread; - private volatile boolean stopped = true; + /** One playback: its thread, connection and controls. Cancelled sessions only clean up after themselves. */ + private static final class Session { + volatile boolean cancelled; + /** Still downloading: nothing audible yet. */ + volatile boolean buffering; + volatile HttpURLConnection connection; + volatile long pendingSeekMs = -1; + volatile FloatControl gainControl; + Thread thread; + + void cancel() { + cancelled = true; + HttpURLConnection conn = connection; + if (conn != null) { + // Socket reads ignore interrupts; disconnecting makes a read blocked on a slow CDN + // fail now instead of after the 20 s read timeout. + conn.disconnect(); + } + Thread t = thread; + if (t != null) { + t.interrupt(); + } + } + } + + private volatile Session current; private volatile boolean paused = false; private volatile long positionMs = 0; private volatile long durationMs = 0; - private volatile long pendingSeekMs = -1; private volatile float volume = 0.7f; // 0..1 - private volatile FloatControl gainControl; - - private volatile File currentTemp; - private final Object lock = new Object(); - /** 是否正在播放(含暂停态时也算“有曲目”)。 */ + /** 是否有曲目(含暂停与缓冲中)。 */ public boolean isActive() { - return !stopped; + return current != null; } public boolean isPaused() { @@ -55,7 +78,14 @@ public boolean isPaused() { } public boolean isPlaying() { - return !stopped && !paused; + Session session = current; + return session != null && !session.buffering && !paused; + } + + /** 正在下载、还没开始出声。 */ + public boolean isBuffering() { + Session session = current; + return session != null && session.buffering; } public long getPositionMs() { @@ -79,27 +109,26 @@ public float getVolume() { * @param onEnded 曲目自然播完时在主线程回调;可为 null。 */ public void play(final String url, final String referer, long knownDurationMs, final Runnable onEnded) { - stop(); - synchronized (lock) { - stopped = false; - paused = false; - positionMs = 0; - durationMs = knownDurationMs; - pendingSeekMs = -1; - } - final Thread t = new Thread(new Runnable() { + final Session session = begin(knownDurationMs); + session.buffering = true; + start(session, new Runnable() { @Override public void run() { File temp = null; try { - temp = downloadToTemp(url, referer); - synchronized (lock) { - currentTemp = temp; + temp = downloadToTemp(session, url, referer); + if (temp == null) { + return; } - playLoop(temp, onEnded); + session.buffering = false; + playLoop(session, temp, onEnded); } catch (Throwable e) { - ClientLogger.error("Music playback failed: " + e.getMessage()); + if (!session.cancelled) { + ClientLogger.error("Music playback failed: " + e.getMessage()); + finish(session); + } } finally { + session.buffering = false; if (temp != null) { //noinspection ResultOfMethodCallIgnored temp.delete(); @@ -107,44 +136,59 @@ public void run() { } } }, "FPSMaster-Music-Audio"); - t.setDaemon(true); - synchronized (lock) { - playThread = t; - } - t.start(); } public void playFile(final File file, long knownDurationMs, final Runnable onEnded) { - stop(); - synchronized (lock) { - stopped = false; - paused = false; - positionMs = 0; - durationMs = knownDurationMs; - pendingSeekMs = -1; - } - final Thread t = new Thread(new Runnable() { + final Session session = begin(knownDurationMs); + start(session, new Runnable() { @Override public void run() { try { - playLoop(file, onEnded); + playLoop(session, file, onEnded); } catch (Throwable e) { - ClientLogger.error("Local music playback failed: " + e.getMessage()); + if (!session.cancelled) { + ClientLogger.error("Local music playback failed: " + e.getMessage()); + finish(session); + } } } }, "FPSMaster-Music-Local"); + } + + private Session begin(long knownDurationMs) { + stop(); + Session session = new Session(); + paused = false; + positionMs = 0; + durationMs = knownDurationMs; + current = session; + return session; + } + + private void start(Session session, Runnable body, String name) { + Thread t = new Thread(body, name); t.setDaemon(true); - synchronized (lock) { - playThread = t; - } + session.thread = t; t.start(); } - private void playLoop(File file, Runnable onEnded) throws Exception { + /** The session ended on its own (finished or failed): it is no longer the current one. */ + private void finish(Session session) { + if (current == session) { + current = null; + paused = false; + } + } + + private boolean live(Session session) { + return !session.cancelled && current == session; + } + + private void playLoop(Session session, File file, Runnable onEnded) throws Exception { long startMs = 0; boolean endedNaturally = false; - while (!stopped) { + while (live(session)) { AudioInputStream fileIn = null; AudioInputStream din = null; SourceDataLine line = null; @@ -165,7 +209,7 @@ private void playLoop(File file, Runnable onEnded) throws Exception { line = (SourceDataLine) AudioSystem.getLine(info); line.open(decoded); line.start(); - applyGain(line); + applyGain(session, line); double bytesPerMs = decoded.getFrameRate() * decoded.getFrameSize() / 1000.0; int frameSize = Math.max(1, decoded.getFrameSize()); @@ -179,17 +223,17 @@ private void playLoop(File file, Runnable onEnded) throws Exception { // not others, and why seeking again "fixed" it. long skipBytes = (long) (startMs * bytesPerMs); skipBytes -= skipBytes % frameSize; - skipDecoded(din, skipBytes); + skipDecoded(session, din, skipBytes); } long posBytes = 0; byte[] buf = new byte[4096]; // Bytes left over from the previous read that do not yet complete a frame. int carry = 0; - while (!stopped) { - long seek = pendingSeekMs; + while (live(session)) { + long seek = session.pendingSeekMs; if (seek >= 0) { - pendingSeekMs = -1; + session.pendingSeekMs = -1; startMs = seek; seekBreak = true; // Discard whatever is still queued in the line before tearing it down. The @@ -217,18 +261,22 @@ private void playLoop(File file, Runnable onEnded) throws Exception { if (writable > 0) { line.write(buf, 0, writable); posBytes += writable; - positionMs = startMs + (long) (posBytes / bytesPerMs); + if (current == session) { + positionMs = startMs + (long) (posBytes / bytesPerMs); + } } carry = available - writable; if (carry > 0) { System.arraycopy(buf, writable, buf, 0, carry); } } + } catch (InterruptedException cancelledWhilePaused) { + return; } finally { closeQuietly(line, din, fileIn); } - if (stopped) { + if (!live(session)) { return; } if (seekBreak) { @@ -239,20 +287,20 @@ private void playLoop(File file, Runnable onEnded) throws Exception { } } - if (endedNaturally && !stopped && onEnded != null) { - stopped = true; + if (endedNaturally && live(session)) { + finish(session); Minecraft mc = Minecraft.getMinecraft(); - if (mc != null) { + if (onEnded != null && mc != null) { mc.addScheduledTask(onEnded); } } } /** 逐块读丢弃到目标字节数(mp3spi 的 skip 不可靠,故用读丢弃)。 */ - private void skipDecoded(AudioInputStream din, long bytes) throws Exception { + private void skipDecoded(Session session, AudioInputStream din, long bytes) throws Exception { byte[] junk = new byte[4096]; long remaining = bytes; - while (remaining > 0 && !stopped) { + while (remaining > 0 && live(session)) { int toRead = (int) Math.min(junk.length, remaining); int n = din.read(junk, 0, toRead); if (n < 0) break; @@ -260,17 +308,18 @@ private void skipDecoded(AudioInputStream din, long bytes) throws Exception { } } - private void applyGain(SourceDataLine line) { + private void applyGain(Session session, SourceDataLine line) { try { if (line.isControlSupported(FloatControl.Type.MASTER_GAIN)) { FloatControl ctl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); - gainControl = ctl; + session.gainControl = ctl; setGainDb(ctl, volume); } else { - gainControl = null; + session.gainControl = null; } - } catch (Exception ignored) { - gainControl = null; + } catch (Exception unsupported) { + // Some mixers advertise MASTER_GAIN and then refuse it; playback still works at line level. + session.gainControl = null; } } @@ -293,11 +342,13 @@ public void setVolume(float vol) { if (vol < 0) vol = 0; if (vol > 1) vol = 1; this.volume = vol; - FloatControl ctl = gainControl; + Session session = current; + FloatControl ctl = session == null ? null : session.gainControl; if (ctl != null) { try { setGainDb(ctl, vol); - } catch (Exception ignored) { + } catch (Exception lineClosed) { + // The line closed between the read and the call; the next line picks the volume up. } } } @@ -316,36 +367,39 @@ public void togglePause() { /** 跳转到指定毫秒。 */ public void seek(long ms) { + Session session = current; + if (session == null) { + return; + } if (ms < 0) ms = 0; if (durationMs > 0 && ms > durationMs) ms = durationMs; positionMs = ms; - pendingSeekMs = ms; + session.pendingSeekMs = ms; paused = false; } - /** 停止并释放当前曲目。 */ + /** + * 停止当前曲目。不等待旧线程:它在断开的连接或下一次循环检查时自己退出并清理, + * 主线程不会再因此卡住。 + */ public void stop() { - Thread t; - synchronized (lock) { - stopped = true; - paused = false; - t = playThread; - playThread = null; - } - if (t != null) { - t.interrupt(); - try { - t.join(500); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } + Session session = current; + current = null; + paused = false; + if (session != null) { + session.cancel(); } positionMs = 0; - gainControl = null; } - private File downloadToTemp(String url, String referer) throws Exception { + /** 下载到临时文件;会话被取消时返回 null。 */ + private File downloadToTemp(Session session, String url, String referer) throws Exception { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + session.connection = conn; + if (session.cancelled) { + conn.disconnect(); + return null; + } conn.setInstanceFollowRedirects(true); conn.setConnectTimeout(10_000); conn.setReadTimeout(20_000); @@ -363,13 +417,30 @@ private File downloadToTemp(String url, String referer) throws Exception { byte[] buf = new byte[16384]; int n; while ((n = in.read(buf)) != -1) { - if (stopped) break; + if (session.cancelled) { + break; + } out.write(buf, 0, n); } + } catch (java.io.IOException failure) { + closeQuietly(out); + out = null; + //noinspection ResultOfMethodCallIgnored + temp.delete(); + if (session.cancelled) { + return null; + } + throw failure; } finally { closeQuietly(in); closeQuietly(out); conn.disconnect(); + session.connection = null; + } + if (session.cancelled) { + //noinspection ResultOfMethodCallIgnored + temp.delete(); + return null; } return temp; } @@ -380,7 +451,8 @@ private static void closeQuietly(SourceDataLine line, AudioInputStream din, Audi line.stop(); line.flush(); line.close(); - } catch (Exception ignored) { + } catch (Exception alreadyClosed) { + // Teardown of a line the mixer already dropped; nothing left to release. } } closeQuietly(din); @@ -391,7 +463,8 @@ private static void closeQuietly(AutoCloseable c) { if (c != null) { try { c.close(); - } catch (Exception ignored) { + } catch (Exception alreadyClosed) { + // Closing is best effort during teardown. } } } diff --git a/src/main/java/top/fpsmaster/modules/music/MusicManager.java b/src/main/java/top/fpsmaster/modules/music/MusicManager.java index 127e69d5..037140d7 100644 --- a/src/main/java/top/fpsmaster/modules/music/MusicManager.java +++ b/src/main/java/top/fpsmaster/modules/music/MusicManager.java @@ -6,6 +6,8 @@ import top.fpsmaster.modules.logger.ClientLogger; import top.fpsmaster.music.AudioQuality; import top.fpsmaster.music.Lyric; +import top.fpsmaster.music.MusicApiException; +import top.fpsmaster.music.MusicErrorKind; import top.fpsmaster.music.MusicLog; import top.fpsmaster.music.MusicLogger; import top.fpsmaster.music.MusicPlaylist; @@ -29,8 +31,9 @@ /** * 音乐功能总控:桥接 Cadence(top.fpsmaster.music)数据库与 Edge 的 UI / 播放引擎。 * - *

所有联网操作(搜索/取直链/歌词/登录)都在 {@link FPSMaster#async} 线程池执行,结果通过 - * {@link Minecraft#addScheduledTask(Runnable)} 回到主线程,绝不阻塞渲染。 + *

所有联网操作(搜索/取直链/歌词/登录)都在音乐专用的有界线程池 {@link #NET} 执行,结果通过 + * {@link Minecraft#addScheduledTask(Runnable)} 回到主线程,绝不阻塞渲染。不用共享的 + * {@link FPSMaster#async}:Cadence 单次请求最长约 55 秒,网络差时会把其他功能的任务一起堵住。 */ public class MusicManager { @@ -58,6 +61,24 @@ public PlaylistItem(MusicSource source, String id, String name, String cover, in private static MusicManager INSTANCE; + /** 音乐请求专用:两个线程足够(列表 + 直链/歌词),守护线程不拖住退出。 */ + private static final java.util.concurrent.ExecutorService NET = java.util.concurrent.Executors.newFixedThreadPool(2, + new java.util.concurrent.ThreadFactory() { + private final java.util.concurrent.atomic.AtomicInteger n = new java.util.concurrent.atomic.AtomicInteger(); + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "FPSMaster-Music-Net-" + n.incrementAndGet()); + t.setDaemon(true); + return t; + } + }); + + /** 是否已经创建过:关机时没用过音乐就不必为了关掉它而现场初始化 SMTC 和凭证。 */ + public static boolean isInitialized() { + return INSTANCE != null; + } + /** 全局唯一实例(懒加载)。界面与入口按钮通过它访问音乐能力。 */ public static MusicManager get() { if (INSTANCE == null) { @@ -235,6 +256,9 @@ public Lyric getCurrentLyric() { } public String getStatus() { + if (status.isEmpty() && engine.isBuffering()) { + return FPSMaster.i18n.get("music.status.buffering"); + } return status; } @@ -265,8 +289,8 @@ public void search(final String keyword, Cb> cb) { final MusicSource s = source; runAsync(new Callable>() { @Override - public List call() { - return service.search(s, keyword, 30); + public List call() throws Exception { + return guardLogin(s, () -> service.search(s, keyword, 30)); } }, cb); } @@ -276,14 +300,12 @@ public void loadDiscover(Cb> cb) { final MusicSource s = source; runAsync(new Callable>() { @Override - public List call() { + public List call() throws Exception { if (s == MusicSource.QQ) { return service.getQq().getToplist(26, 50); } - if (neteaseLoggedIn) { - return service.getNetease().getDailyRecommendSongs(); - } - return new ArrayList(); + requireNeteaseLogin(); + return guardLogin(s, () -> service.getNetease().getDailyRecommendSongs()); } }, cb); } @@ -293,18 +315,22 @@ public void loadPlaylists(Cb> cb) { final MusicSource s = source; runAsync(new Callable>() { @Override - public List call() { + public List call() throws Exception { List out = new ArrayList(); if (s == MusicSource.QQ) { for (MusicPlaylist p : service.getQq().getRecommendPlaylists(12)) { out.add(new PlaylistItem(MusicSource.QQ, p.getId(), p.getName(), p.getCoverUrl(), p.getTrackCount())); } - } else if (neteaseLoggedIn) { + } else { + requireNeteaseLogin(); Long uid = service.getNetease().getLoginUid(); - if (uid != null) { - for (PlaylistBrief p : service.getNetease().getUserPlaylists(uid, 30, 0)) { - out.add(new PlaylistItem(MusicSource.NETEASE, p.getId(), p.getName(), p.getCoverUrl(), p.getTrackCount())); - } + if (uid == null) { + // Cadence 0.1.2:cookie 失效时 getLoginUid 返回 null。 + expireLogin(MusicSource.NETEASE); + throw new MusicApiException("netease cookie expired", null, MusicErrorKind.NOT_LOGGED_IN); + } + for (PlaylistBrief p : guardLogin(MusicSource.NETEASE, () -> service.getNetease().getUserPlaylists(uid, 30, 0))) { + out.add(new PlaylistItem(MusicSource.NETEASE, p.getId(), p.getName(), p.getCoverUrl(), p.getTrackCount())); } } return out; @@ -316,11 +342,11 @@ public List call() { public void loadPlaylistTracks(final PlaylistItem item, Cb> cb) { runAsync(new Callable>() { @Override - public List call() { + public List call() throws Exception { if (item.source == MusicSource.QQ) { - return service.getQq().getPlaylistTracks(item.id, 100); + return guardLogin(MusicSource.QQ, () -> service.getQq().getPlaylistTracks(item.id, 100)); } - return service.getNetease().getPlaylistTracks(item.id, 1000); + return guardLogin(MusicSource.NETEASE, () -> service.getNetease().getPlaylistTracks(item.id, 1000)); } }, cb); } @@ -361,7 +387,7 @@ private void playInternal(final Track track) { return; } // 请求 STANDARD(mp3)以保证 mp3spi 可解码 - FPSMaster.async.execute(new Callable() { + NET.submit(new Callable() { @Override public Object call() { try { @@ -401,7 +427,7 @@ public void run() { } private void loadLyric(final Track track) { - FPSMaster.async.execute(new Callable() { + NET.submit(new Callable() { @Override public Object call() { try { @@ -484,7 +510,7 @@ public void startQrLogin(final Cb onUpdate) { final MusicSource s = source; qrState = null; qrCode = null; - FPSMaster.async.execute(new Callable() { + NET.submit(new Callable() { @Override public Object call() { try { @@ -619,8 +645,42 @@ public void logout() { // ---- 内部 ---- + private void requireNeteaseLogin() { + if (!neteaseLoggedIn) { + throw new MusicApiException("netease not logged in", null, MusicErrorKind.NOT_LOGGED_IN); + } + } + + /** 平台说"未登录"时本地也切回未登录(并清掉存着的失效凭证),界面才会给出登录入口。 */ + private T guardLogin(MusicSource s, Callable call) throws Exception { + try { + return call.call(); + } catch (MusicApiException e) { + if (e.getKind() == MusicErrorKind.NOT_LOGGED_IN) { + expireLogin(s); + } + throw e; + } + } + + private void expireLogin(MusicSource s) { + try { + if (s == MusicSource.NETEASE && neteaseLoggedIn) { + neteaseLoggedIn = false; + service.getNetease().clearLogin(); + store.clearNetease(); + } else if (s == MusicSource.QQ && qqLoggedIn) { + qqLoggedIn = false; + service.getQq().clearLogin(); + store.clearQq(); + } + } catch (RuntimeException e) { + ClientLogger.error("Music login reset failed: " + e.getMessage()); + } + } + private void runAsync(final Callable task, final Cb cb) { - FPSMaster.async.execute(new Callable() { + NET.submit(new Callable() { @Override public Object call() { try { diff --git a/src/main/java/top/fpsmaster/modules/music/MusicTextures.java b/src/main/java/top/fpsmaster/modules/music/MusicTextures.java index e9aa0681..3135526d 100644 --- a/src/main/java/top/fpsmaster/modules/music/MusicTextures.java +++ b/src/main/java/top/fpsmaster/modules/music/MusicTextures.java @@ -59,6 +59,12 @@ protected boolean removeEldestEntry(Map.Entry eldest) } }; private static final Set LOADING = new HashSet<>(); + /** + * key -> 失败时间。界面每帧都会查封面,失败后不记一笔的话下一帧就重新排队下载同一个 + * 404 / 被防盗链拦下的 URL,并且每次都打一条 error。 + */ + private static final Map FAILED = new HashMap<>(); + private static final long RETRY_FAILED_MS = 5L * 60_000L; // 所有 AWT/ImageIO 图片解码放到单一线程串行执行:macOS(尤其 Rosetta) 下并发调用 // AWT/CoreGraphics 原生代码会崩(objc_release)。单线程可最大限度规避。 @@ -86,21 +92,22 @@ public static synchronized ResourceLocation cover(final String url) { final String key = "cover:" + url; ResourceLocation loc = READY.get(key); if (loc != null) return loc; - if (LOADING.contains(key) || LOADING.size() >= MAX_LOADING) return null; + if (recentlyFailed(key) || LOADING.contains(key) || LOADING.size() >= MAX_LOADING) return null; LOADING.add(key); + final String sized = sizedCoverUrl(url); NET_EXEC.execute(new Runnable() { @Override public void run() { final byte[] bytes; try { - bytes = downloadBytes(url); + bytes = downloadBytes(sized); } catch (Throwable e) { ClientLogger.error("Music cover download failed: " + e.getMessage()); - unmark(key); + fail(key); return; } if (bytes == null) { - unmark(key); + fail(key); return; } // 拿到字节之后才回到串行线程解码 @@ -111,7 +118,7 @@ public void run() { upload(key, ImageIO.read(new ByteArrayInputStream(bytes))); } catch (Throwable e) { ClientLogger.error("Music cover decode failed: " + e.getMessage()); - unmark(key); + fail(key); } } }); @@ -179,6 +186,7 @@ public static synchronized void invalidate(String rawKey) { ResourceLocation loc = READY.remove(k); deleteTexture(loc); LOADING.remove(k); + FAILED.remove(k); } } @@ -194,6 +202,7 @@ public static synchronized void shutdown() { } READY.clear(); LOADING.clear(); + FAILED.clear(); IMG_EXEC.shutdownNow(); NET_EXEC.shutdownNow(); } @@ -229,6 +238,35 @@ private static synchronized void unmark(String key) { LOADING.remove(key); } + private static synchronized void fail(String key) { + LOADING.remove(key); + long now = System.currentTimeMillis(); + if (FAILED.size() >= 256) { + FAILED.values().removeIf(at -> now - at >= RETRY_FAILED_MS); + } + FAILED.put(key, now); + } + + private static boolean recentlyFailed(String key) { + Long at = FAILED.get(key); + if (at == null) { + return false; + } + if (System.currentTimeMillis() - at >= RETRY_FAILED_MS) { + FAILED.remove(key); + return false; + } + return true; + } + + /** 网易云图床支持按尺寸出图;封面最大也就显示一两百像素,没必要下载原图(常见 1000px+)。 */ + private static String sizedCoverUrl(String url) { + if (url.contains(".music.126.net/") && url.indexOf('?') < 0) { + return url + "?param=300y300"; + } + return url; + } + private static void deleteTexture(ResourceLocation location) { if (location == null) { return; @@ -241,7 +279,8 @@ private static void deleteTexture(ResourceLocation location) { private static void upload(final String key, final BufferedImage raw) { if (raw == null) { - unmark(key); + // ImageIO 认不出格式:同一份字节再试也一样。 + fail(key); return; } final BufferedImage argb = toArgb(raw); diff --git a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java index f5a8309c..74565197 100644 --- a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java @@ -11,6 +11,8 @@ import top.fpsmaster.modules.music.MusicTextures; import top.fpsmaster.music.Lyric; import top.fpsmaster.music.LyricLine; +import top.fpsmaster.music.MusicApiException; +import top.fpsmaster.music.MusicErrorKind; import top.fpsmaster.music.MusicSource; import top.fpsmaster.music.QrCode; import top.fpsmaster.music.QrLoginState; @@ -41,6 +43,17 @@ public class MusicScreen extends ScaledGuiScreen { private volatile List playlists = new ArrayList(); private volatile String listTitle = ""; private volatile String loginStatus = ""; + /* + * List states for Prism: an empty list alone cannot say "loading", "nothing found", "log in first" + * or "request failed". The request counters drop answers that arrive after a newer request (search + * typed while discover was still loading, for example). Written on the client thread only. + */ + private MusicBridge.ListState tracksState = MusicBridge.ListState.READY; + private String tracksError = ""; + private int tracksRequest; + private MusicBridge.ListState playlistsState = MusicBridge.ListState.READY; + private String playlistsError = ""; + private int playlistsRequest; public MusicScreen() { this(null); @@ -57,8 +70,80 @@ public MusicScreen(GuiScreen parent) { } try { refreshLocalTracks(FileUtils.musicDirectory()); - } catch (RuntimeException exception) { - ClientLogger.warn("Failed to scan local music folder: " + exception.getMessage()); + } catch (IllegalStateException exception) { + ClientLogger.warn("Music folder is unavailable: " + exception.getMessage()); + } + } + + private MusicManager.Cb> tracksCallback() { + final int request = ++tracksRequest; + tracksState = MusicBridge.ListState.LOADING; + tracksError = ""; + return (result, error) -> { + if (request != tracksRequest) return; + if (error != null) { + tracks = new ArrayList(); + settle(error, true); + return; + } + tracks = result == null ? new ArrayList() : result; + tracksState = MusicBridge.ListState.READY; + }; + } + + private MusicManager.Cb> playlistsCallback() { + final int request = ++playlistsRequest; + playlistsState = MusicBridge.ListState.LOADING; + playlistsError = ""; + return (result, error) -> { + if (request != playlistsRequest) return; + if (error != null) { + playlists = new ArrayList(); + settle(error, false); + return; + } + playlists = result == null ? new ArrayList() : result; + playlistsState = MusicBridge.ListState.READY; + }; + } + + private void settle(Throwable error, boolean trackList) { + boolean needLogin = error instanceof MusicApiException + && ((MusicApiException) error).getKind() == MusicErrorKind.NOT_LOGGED_IN; + MusicBridge.ListState state = needLogin ? MusicBridge.ListState.NEED_LOGIN : MusicBridge.ListState.ERROR; + String reason = needLogin ? "" : describe(error); + if (!needLogin) { + ClientLogger.warn("Music list request failed: " + error); + } + if (trackList) { + tracksState = state; + tracksError = reason; + } else { + playlistsState = state; + playlistsError = reason; + } + } + + /** User-facing reason for a failed request, by Cadence's error kind rather than its English message. */ + private static String describe(Throwable error) { + if (!(error instanceof MusicApiException)) { + return String.format(FPSMaster.i18n.get("music.error.unknown"), error.getClass().getSimpleName()); + } + MusicApiException api = (MusicApiException) error; + switch (api.getKind()) { + case NETWORK: + return FPSMaster.i18n.get("music.error.network"); + case TIMEOUT: + return FPSMaster.i18n.get("music.error.timeout"); + case NOT_LOGGED_IN: + return FPSMaster.i18n.get("music.error.notloggedin"); + case RISK_CONTROL: + return FPSMaster.i18n.get("music.error.risk"); + case BAD_RESPONSE: + return FPSMaster.i18n.get("music.error.badresponse"); + default: + Integer code = api.getPlatformCode(); + return String.format(FPSMaster.i18n.get("music.error.api"), code == null ? "?" : String.valueOf(code)); } } @@ -115,8 +200,8 @@ private final class EdgeMusicBridge implements MusicBridge { @Override public void setQq(boolean qq) { music.setSource(qq ? MusicSource.QQ : MusicSource.NETEASE); } @Override public boolean loggedIn() { return music.isLoggedIn(); } @Override public String status() { return music.getStatus(); } - @Override public String nowTitle() { Track track = music.getCurrent(); return track == null ? "未在播放" : track.getName(); } - @Override public String nowArtist() { Track track = music.getCurrent(); return track == null ? (qq() ? "QQ 音乐" : "网易云音乐") : track.getArtists(); } + @Override public String nowTitle() { Track track = music.getCurrent(); return track == null ? i18n("music.lyrics.idle") : track.getName(); } + @Override public String nowArtist() { Track track = music.getCurrent(); return track == null ? i18n(qq() ? "music.source.qq" : "music.source.netease") : track.getArtists(); } @Override public boolean playing() { return music.engine().isPlaying(); } @Override public boolean paused() { return music.engine().isPaused(); } @Override public long positionMs() { return music.engine().getPositionMs(); } @@ -170,19 +255,23 @@ public List tracks() { } @Override public String listTitle() { return listTitle; } + @Override public ListState tracksState() { return tracksState; } + @Override public String tracksError() { return tracksError; } + @Override public ListState playlistsState() { return playlistsState; } + @Override public String playlistsError() { return playlistsError; } + @Override public void search(final String query) { - listTitle = "搜索: " + query; - music.search(query, (result, error) -> tracks = result == null ? new ArrayList() : result); + listTitle = String.format(i18n("music.list.search"), query); + music.search(query, tracksCallback()); } @Override public void loadDiscover() { - listTitle = qq() ? "QQ 热歌榜" : "每日推荐"; - music.loadDiscover((result, error) -> tracks = result == null ? new ArrayList() : result); + listTitle = i18n(qq() ? "music.list.toplist" : "music.list.daily"); + music.loadDiscover(tracksCallback()); } @Override public void loadPlaylists() { - music.loadPlaylists((result, error) -> playlists = result == null - ? new ArrayList() : result); + music.loadPlaylists(playlistsCallback()); } @Override public boolean playlists() { return !playlists.isEmpty(); } @@ -193,7 +282,7 @@ public void openPlaylist(int index) { if (index < 0 || index >= snapshot.size()) return; MusicManager.PlaylistItem playlist = snapshot.get(index); listTitle = playlist.name; - music.loadPlaylistTracks(playlist, (result, error) -> tracks = result == null ? new ArrayList() : result); + music.loadPlaylistTracks(playlist, tracksCallback()); } @Override @@ -207,15 +296,15 @@ public List playlistRows() { @Override public void startLogin() { - loginStatus = "二维码生成中…"; + loginStatus = i18n("music.login.status.generating"); music.startQrLogin(new MusicManager.Cb() { @Override public void done(QrLoginState state, Throwable error) { - if (error != null || state == QrLoginState.ERROR) loginStatus = "登录失败,请刷新重试"; - else if (state == QrLoginState.WAITING) loginStatus = "等待扫码…"; - else if (state == QrLoginState.SCANNED) loginStatus = "已扫码,请在手机确认"; - else if (state == QrLoginState.CONFIRMED) loginStatus = "登录成功"; - else if (state == QrLoginState.EXPIRED) loginStatus = "二维码已过期,请刷新"; + if (error != null || state == QrLoginState.ERROR) loginStatus = i18n("music.login.status.failed"); + else if (state == QrLoginState.WAITING) loginStatus = i18n("music.login.status.waiting"); + else if (state == QrLoginState.SCANNED) loginStatus = i18n("music.login.status.scanned"); + else if (state == QrLoginState.CONFIRMED) loginStatus = i18n("music.login.status.ok"); + else if (state == QrLoginState.EXPIRED) loginStatus = i18n("music.login.status.expired"); } }); } @@ -237,7 +326,7 @@ public void paintLoginQr(UiFrame ui, float x, float y, float size) { @Override public void submitQqCookie(String musicId, String musicKey) { music.qqCookieLogin(musicId, musicKey, (result, error) -> - loginStatus = Boolean.TRUE.equals(result) ? "登录成功" : "Cookie 登录失败"); + loginStatus = i18n(Boolean.TRUE.equals(result) ? "music.login.status.ok" : "music.login.status.cookiefailed")); } @Override @@ -290,13 +379,17 @@ private void openLocalMusicFolder() { refreshLocalTracks(folder); } - private void refreshLocalTracks(File folder) { - File[] files = folder.listFiles(); - if (files == null) { - localTracks = new ArrayList(); - return; - } - localTracks = toLocalTracks(files); + /** + * Scans the music folder off the client thread. One listing serves both the audio files and the + * cover lookup: the old version listed the folder again for every song (O(n²) on the render + * thread, a visible hitch with a few hundred files). + */ + private void refreshLocalTracks(final File folder) { + FPSMaster.async.runnable(() -> { + File[] files = folder.listFiles(); + final List scanned = files == null ? new ArrayList() : toLocalTracks(files); + mc.addScheduledTask(() -> localTracks = scanned); + }); } private static String format(long millis) { @@ -305,36 +398,31 @@ private static String format(long millis) { } private static List toLocalTracks(File[] files) { + java.util.Map byLowerName = new java.util.HashMap(); + for (File file : files) { + if (file.isFile()) byLowerName.put(file.getName().toLowerCase(java.util.Locale.ROOT), file); + } + String localArtist = FPSMaster.i18n.get("music.local.title"); List result = new ArrayList(); for (File file : files) { if (!file.isFile() || !isLocalAudio(file.getName())) continue; String name = file.getName(); int dot = name.lastIndexOf('.'); if (dot > 0) name = name.substring(0, dot); - File cover = localCover(file); + File cover = localCover(name.toLowerCase(java.util.Locale.ROOT), byLowerName); result.add(new Track(MusicSource.NETEASE, file.toURI().toString(), null, name, - "本地音乐", "", 0L, cover == null ? null : cover.toURI().toString(), false)); + localArtist, "", 0L, cover == null ? null : cover.toURI().toString(), false)); } return result; } - private static File localCover(File audio) { - File directory = audio.getParentFile(); - File[] files = directory == null ? null : directory.listFiles(); - if (files == null) return null; - String audioName = audio.getName(); - int dot = audioName.lastIndexOf('.'); - String base = (dot > 0 ? audioName.substring(0, dot) : audioName).toLowerCase(java.util.Locale.ROOT); + private static File localCover(String base, java.util.Map byLowerName) { String[] names = {base, "cover", "folder"}; String[] extensions = {"png", "jpg", "jpeg"}; for (String name : names) { for (String extension : extensions) { - String expected = name + "." + extension; - for (File file : files) { - if (file.isFile() && file.getName().toLowerCase(java.util.Locale.ROOT).equals(expected)) { - return file; - } - } + File file = byLowerName.get(name + "." + extension); + if (file != null) return file; } } return null; diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 5b8e085a..762faccf 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -1373,3 +1373,14 @@ configprofiles.filedialog.busy=A file dialog is already open config.recovery.title=Config recovered config.recovery.restored=Config "%s" could not be read and was restored from the backup made at %s config.recovery.reset=Config "%s" could not be read and had no usable backup, so it was reset to defaults; the original was kept as %s +music.list.search=Search: %s +music.list.toplist=QQ Top Chart +music.list.daily=Daily picks +music.status.buffering=Buffering… +music.login.status.generating=Generating QR code… +music.login.status.waiting=Waiting for scan… +music.login.status.scanned=Scanned, confirm on your phone +music.login.status.ok=Logged in +music.login.status.expired=QR code expired, please refresh +music.login.status.failed=Login failed, refresh and try again +music.login.status.cookiefailed=Cookie login failed diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index 4ef406e4..982144a1 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -1373,3 +1373,14 @@ configprofiles.filedialog.busy=文件选择窗口已经打开 config.recovery.title=配置已恢复 config.recovery.restored=配置“%s”无法读取,已从 %s 的备份恢复 config.recovery.reset=配置“%s”无法读取且没有可用备份,已重置为默认;原文件保留为 %s +music.list.search=搜索:%s +music.list.toplist=QQ 热歌榜 +music.list.daily=每日推荐 +music.status.buffering=缓冲中… +music.login.status.generating=二维码生成中… +music.login.status.waiting=等待扫码… +music.login.status.scanned=已扫码,请在手机确认 +music.login.status.ok=登录成功 +music.login.status.expired=二维码已过期,请刷新 +music.login.status.failed=登录失败,请刷新重试 +music.login.status.cookiefailed=Cookie 登录失败 From 2f3a6b2c6bc4ad8af12c8bc76e7bc5e32c9321f1 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:38:35 -0700 Subject: [PATCH 08/15] =?UTF-8?q?fix(lifecycle):=20=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=97=B6=E5=85=88=E5=AD=98=E9=85=8D=E7=BD=AE=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E7=BD=91=E7=BB=9C=E5=8D=A1=E4=BD=8F=EF=BC=9B?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E6=AD=A5=E9=AA=A4=E4=BA=92=E4=B8=8D?= =?UTF-8?q?=E8=BF=9E=E5=B8=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shutdown 第一时间保存配置;离线上报改到守护线程最多等 1 秒; 共享线程池关闭只等 2 秒;没用过音乐就不为关闭而初始化 SMTC - 配置/饰品/命令/i18n 初始化各自独立 try,一步失败不再跳过后面几步 - 开发环境探测只做一次,主菜单不再每帧抛 ClassNotFoundException - 删掉 MoreParticles 每次攻击打印的空行 --- src/main/java/top/fpsmaster/FPSMaster.java | 80 ++++++++++++------- .../features/impl/render/MoreParticles.java | 1 - .../telemetry/EdgeTelemetryReporter.java | 11 ++- .../client/thread/ClientThreadPool.java | 6 +- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/main/java/top/fpsmaster/FPSMaster.java b/src/main/java/top/fpsmaster/FPSMaster.java index 5c170dc5..f48afdd1 100644 --- a/src/main/java/top/fpsmaster/FPSMaster.java +++ b/src/main/java/top/fpsmaster/FPSMaster.java @@ -46,24 +46,27 @@ public class FPSMaster { public static Language i18n = new Language(); public static ClientThreadPool async = new ClientThreadPool(Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); public static EdgeTelemetryReporter telemetryReporter = new EdgeTelemetryReporter(); - public static boolean development = false; + /** + * Probed once: the main menu asks every frame, and in a release build each probe threw and caught + * a ClassNotFoundException (hundreds of stack-trace allocations a second). + */ + public static final boolean development = detectDevelopment(); public static boolean defaultConfigExistedBeforeLoad = false; - private static void checkDevelopment() { + private static boolean detectDevelopment() { try { Class.forName("net.fabricmc.devlaunchinjector.Main"); - development = true; - } catch (Throwable ignored) { + return true; + } catch (ClassNotFoundException | LinkageError notDev) { + return false; } } public static boolean isDevelopment() { - checkDevelopment(); return development; } public static String getClientTitle() { - checkDevelopment(); return CLIENT_NAME + " " + EDITION + " (" + GitInfo.getBranch() + " - " + GitInfo.getCommitIdAbbrev() + ")" + (development ? " - dev" : ""); } @@ -172,13 +175,29 @@ public void initialize() { initializeFonts(); initializeModules(); initializeComponents(); - initializeConfigures(); - CosmeticManager.getInstance().initialize(); - initializeCommands(); - initializeLang(); - checkOptifine(); } catch (Exception e) { + // Everything after this needs the data directory and the module list. ExceptionHandler.handle(e); + return; + } + // Independent steps, each in its own try: one bad config entry used to throw out of the + // shared try and skip cosmetics, commands and i18n for the whole session. + initStep("config", this::initializeConfigures); + initStep("cosmetics", () -> CosmeticManager.getInstance().initialize()); + initStep("commands", this::initializeCommands); + initStep("i18n", this::initializeLang); + checkOptifine(); + } + + private interface InitStep { + void run() throws Exception; + } + + private static void initStep(String name, InitStep step) { + try { + step.run(); + } catch (Exception e) { + ExceptionHandler.handle(e, "Initialization step '" + name + "' failed"); } } @@ -186,32 +205,35 @@ public void initialize() { public void shutdown() { // Before anything else: an unfinished stream is an unreadable file. top.fpsmaster.replay.ReplayRecorder.instance().stop(); + // Then the config: everything below may block on the network, and a player who gives up + // waiting and kills the process must not lose this session's settings. + try { + ClientLogger.info("Saving configs"); + configManager.saveConfig(ConfigProfileUtils.getActiveProfileName()); + } catch (FileException e) { + ExceptionHandler.handleFileException(e, "Failed to save default config during shutdown"); + } PlayTimeStatistics.flush(); // A debounced scale change is still pending if the game is closed straight after dragging // the slider; give it a bounded moment rather than losing it. top.fpsmaster.cosmetic.RemoteCosmeticService.getInstance().flushBlocking(1500L); - // Release SMTC native session before tearing down async/audio - try { - top.fpsmaster.modules.music.MusicManager.get().shutdownSmtc(); - } catch (Throwable t) { - ClientLogger.warn("SMTC shutdown skipped: " + t.getMessage()); - } - // The playback thread holds a javax.sound line and a temp file, and stop() joins it. It has - // to happen before async.close(), which is where the rest of the client's workers go. - try { - top.fpsmaster.modules.music.MusicManager.get().engine().stop(); - } catch (Throwable t) { - ClientLogger.warn("Audio engine stop skipped: " + t.getMessage()); + // Music was never opened: nothing to stop, and get() would create the SMTC session and + // load credentials just to tear them down again. + if (top.fpsmaster.modules.music.MusicManager.isInitialized()) { + try { + top.fpsmaster.modules.music.MusicManager.get().shutdownSmtc(); + } catch (Throwable t) { + ClientLogger.warn("SMTC shutdown skipped: " + t.getMessage()); + } + try { + top.fpsmaster.modules.music.MusicManager.get().engine().stop(); + } catch (Throwable t) { + ClientLogger.warn("Audio engine stop skipped: " + t.getMessage()); + } } releaseGraphics(); telemetryReporter.shutdown(); async.close(); - try { - ClientLogger.info("Saving configs"); - configManager.saveConfig(ConfigProfileUtils.getActiveProfileName()); - } catch (FileException e) { - ExceptionHandler.handleFileException(e, "Failed to save default config during shutdown"); - } } /** diff --git a/src/main/java/top/fpsmaster/features/impl/render/MoreParticles.java b/src/main/java/top/fpsmaster/features/impl/render/MoreParticles.java index 616395f7..d7f86c74 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/MoreParticles.java +++ b/src/main/java/top/fpsmaster/features/impl/render/MoreParticles.java @@ -104,7 +104,6 @@ public void onAttack(EventAttack event) { Minecraft.getMinecraft().effectRenderer.emitParticleAtEntity(event.target, EnumParticleTypes.FLAME); } else if (special.getValue() == 3) { if (mc.objectMouseOver.hitVec != null && event.target.hurtResistantTime <= 10) { - System.out.println(); if (mc.theWorld != null) { mc.theWorld.playSound( mc.objectMouseOver.hitVec.xCoord, diff --git a/src/main/java/top/fpsmaster/modules/client/telemetry/EdgeTelemetryReporter.java b/src/main/java/top/fpsmaster/modules/client/telemetry/EdgeTelemetryReporter.java index 07b96637..acc182fe 100644 --- a/src/main/java/top/fpsmaster/modules/client/telemetry/EdgeTelemetryReporter.java +++ b/src/main/java/top/fpsmaster/modules/client/telemetry/EdgeTelemetryReporter.java @@ -156,7 +156,16 @@ private void submitOffline(boolean blocking) { }; if (blocking) { - request.run(); + // Shutdown: the request has 15 s connect/read timeouts, and waiting for them on the + // client thread froze the closing window. Give it one second on a daemon thread. + Thread thread = new Thread(request, "FPSMaster-Telemetry-Offline"); + thread.setDaemon(true); + thread.start(); + try { + thread.join(1000L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } return; } FPSMaster.async.runnable(request); diff --git a/src/main/java/top/fpsmaster/modules/client/thread/ClientThreadPool.java b/src/main/java/top/fpsmaster/modules/client/thread/ClientThreadPool.java index 3269a436..0b434eae 100644 --- a/src/main/java/top/fpsmaster/modules/client/thread/ClientThreadPool.java +++ b/src/main/java/top/fpsmaster/modules/client/thread/ClientThreadPool.java @@ -29,10 +29,14 @@ public Future runnable(Runnable task) { return executorService.submit(task); } + /** + * Gives running tasks a short moment at shutdown. The workers are daemons, so anything still + * waiting on a 15 s HTTP timeout is abandoned rather than holding the window open. + */ public void close() { executorService.shutdown(); try { - if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + if (!executorService.awaitTermination(2, TimeUnit.SECONDS)) { executorService.shutdownNow(); } } catch (InterruptedException ex) { From de0c552620afe193877e9d80d3c434513da931d4 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:42:52 -0700 Subject: [PATCH 09/15] =?UTF-8?q?fix(i18n):=20=E9=80=97=E5=8F=B7=E5=B0=8F?= =?UTF-8?q?=E6=95=B0=E7=82=B9=E5=9C=B0=E5=8C=BA=20Reach=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=A4=B1=E6=95=88=EF=BC=9Bi18n=20key=20=E4=B8=8E?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=88=A4=E6=96=AD=E6=94=B9=E7=94=A8=20Locale?= =?UTF-8?q?.ROOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReachDisplay 改为数值四舍五入,不再 format 后 parseDouble(德/法/俄/土等系统抛异常) - 模块/设置/模式的 i18n key、通知、HUD 列表、命令提示、os.name 判断统一 Locale.ROOT, 土耳其语系统下 ItemPhysics 等不再变成 ıtemphysics 而丢翻译 --- .../top/fpsmaster/features/command/CommandManager.java | 2 +- .../features/impl/interfaces/ReachDisplay.java | 4 +++- .../java/top/fpsmaster/features/manager/Module.java | 4 ++-- src/main/java/top/fpsmaster/ui/click/MainPanel.java | 8 ++++---- .../fpsmaster/ui/custom/impl/ModsListComponent.java | 4 ++-- .../java/top/fpsmaster/ui/hud/HudEditorScreen.java | 4 ++-- .../top/fpsmaster/ui/screens/replay/DirectorPanel.java | 2 +- src/main/java/top/fpsmaster/utils/system/OSUtil.java | 10 +++++----- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/main/java/top/fpsmaster/features/command/CommandManager.java b/src/main/java/top/fpsmaster/features/command/CommandManager.java index fe90c750..8f7b012b 100644 --- a/src/main/java/top/fpsmaster/features/command/CommandManager.java +++ b/src/main/java/top/fpsmaster/features/command/CommandManager.java @@ -411,7 +411,7 @@ private void setModuleState(Class moduleClass, String[] args) } private void sendModuleState(Module module) { - Utility.sendClientNotify(FPSMaster.i18n.get(module.name.toLowerCase()) + ": " + Utility.sendClientNotify(FPSMaster.i18n.get(module.name.toLowerCase(java.util.Locale.ROOT)) + ": " + FPSMaster.i18n.get(module.isEnabled() ? "command.module.enabled" : "command.module.disabled")); } } diff --git a/src/main/java/top/fpsmaster/features/impl/interfaces/ReachDisplay.java b/src/main/java/top/fpsmaster/features/impl/interfaces/ReachDisplay.java index baddca77..46d7cd7b 100644 --- a/src/main/java/top/fpsmaster/features/impl/interfaces/ReachDisplay.java +++ b/src/main/java/top/fpsmaster/features/impl/interfaces/ReachDisplay.java @@ -28,7 +28,9 @@ public void onAttack(EventAttack e) { if (entity != null && mc.theWorld != null) { if (mc.objectMouseOver == null || mc.objectMouseOver.entityHit == null) return; - reach = Double.parseDouble(String.format("%.2f", distance)); + // Rounded numerically: String.format uses the default locale, "3,45" on de/fr/ru/tr systems, + // which parseDouble rejects. + reach = Math.round(distance * 100.0) / 100.0; } } } diff --git a/src/main/java/top/fpsmaster/features/manager/Module.java b/src/main/java/top/fpsmaster/features/manager/Module.java index 818e5872..e3c2171b 100644 --- a/src/main/java/top/fpsmaster/features/manager/Module.java +++ b/src/main/java/top/fpsmaster/features/manager/Module.java @@ -70,7 +70,7 @@ public void set(boolean state) { FPSMaster.i18n.get("notification.module.enable"), String.format( FPSMaster.i18n.get("notification.module.enable.desc"), - FPSMaster.i18n.get(this.name.toLowerCase(Locale.getDefault())) + FPSMaster.i18n.get(this.name.toLowerCase(Locale.ROOT)) ), NotificationCenter.Type.SUCCESS, 2f @@ -84,7 +84,7 @@ public void set(boolean state) { FPSMaster.i18n.get("notification.module.disable"), String.format( FPSMaster.i18n.get("notification.module.disable.desc"), - FPSMaster.i18n.get(this.name.toLowerCase(Locale.getDefault())) + FPSMaster.i18n.get(this.name.toLowerCase(Locale.ROOT)) ), NotificationCenter.Type.INFO, 2f diff --git a/src/main/java/top/fpsmaster/ui/click/MainPanel.java b/src/main/java/top/fpsmaster/ui/click/MainPanel.java index bbd013e9..c8a81233 100644 --- a/src/main/java/top/fpsmaster/ui/click/MainPanel.java +++ b/src/main/java/top/fpsmaster/ui/click/MainPanel.java @@ -268,7 +268,7 @@ private ClickGuiBridge.SettingInfo toInfo(Module module, Setting setting) { List names = new ArrayList(); for (int i = 0; i < mode.getModesSize(); i++) { String raw = mode.getMode(i + 1); - String key = (module.name + "." + setting.name + "." + raw).toLowerCase(Locale.getDefault()); + String key = (module.name + "." + setting.name + "." + raw).toLowerCase(Locale.ROOT); String translated = FPSMaster.i18n.get(key); names.add(key.equals(translated) ? raw : translated); } @@ -326,13 +326,13 @@ private static String categoryId(Category category) { } private static String moduleLabel(Module module) { - String key = module.name.toLowerCase(Locale.getDefault()); + String key = module.name.toLowerCase(Locale.ROOT); String translated = FPSMaster.i18n.get(key); return key.equals(translated) ? module.name : translated; } private static String settingLabel(Module module, Setting setting) { - String key = (module.name + "." + setting.name).toLowerCase(Locale.getDefault()); + String key = (module.name + "." + setting.name).toLowerCase(Locale.ROOT); String translated = FPSMaster.i18n.get(key); return key.equals(translated) ? setting.name : translated; } @@ -340,7 +340,7 @@ private static String settingLabel(Module module, Setting setting) { private static boolean matchesSearch(Module module, String rawQuery) { String query = rawQuery.trim().toLowerCase(Locale.getDefault()); String name = moduleLabel(module).toLowerCase(Locale.getDefault()); - String desc = FPSMaster.i18n.get(module.name.toLowerCase(Locale.getDefault()) + ".desc").toLowerCase(Locale.getDefault()); + String desc = FPSMaster.i18n.get(module.name.toLowerCase(Locale.ROOT) + ".desc").toLowerCase(Locale.getDefault()); return name.contains(query) || desc.contains(query) || (ClientSettings.language.getValue() != 1 && module.name.toLowerCase(Locale.getDefault()).contains(query)); } diff --git a/src/main/java/top/fpsmaster/ui/custom/impl/ModsListComponent.java b/src/main/java/top/fpsmaster/ui/custom/impl/ModsListComponent.java index 847b1d7c..583b26c0 100644 --- a/src/main/java/top/fpsmaster/ui/custom/impl/ModsListComponent.java +++ b/src/main/java/top/fpsmaster/ui/custom/impl/ModsListComponent.java @@ -56,7 +56,7 @@ public void draw(float x, float y) { Map widths = new HashMap<>(); for (Module m : FPSMaster.moduleManager.modules) { - String label = modlist.english.getValue() ? m.name : FPSMaster.i18n.get(m.name.toLowerCase()); + String label = modlist.english.getValue() ? m.name : FPSMaster.i18n.get(m.name.toLowerCase(java.util.Locale.ROOT)); widths.put(m, getStringWidth(18, label)); } List sortedVisible = FPSMaster.moduleManager.modules.stream() @@ -117,7 +117,7 @@ public void draw(float x, float y) { for (Module module : displayList) { float p = animated ? Math.max(progress.getOrDefault(module, 1f), 0.01f) : 1f; - String name = modlist.english.getValue() ? module.name : FPSMaster.i18n.get(module.name.toLowerCase()); + String name = modlist.english.getValue() ? module.name : FPSMaster.i18n.get(module.name.toLowerCase(java.util.Locale.ROOT)); float textWidth = getStringWidth(18, name); maxWidth = Math.max(maxWidth, textWidth + 5); diff --git a/src/main/java/top/fpsmaster/ui/hud/HudEditorScreen.java b/src/main/java/top/fpsmaster/ui/hud/HudEditorScreen.java index a4286339..527faf9d 100644 --- a/src/main/java/top/fpsmaster/ui/hud/HudEditorScreen.java +++ b/src/main/java/top/fpsmaster/ui/hud/HudEditorScreen.java @@ -118,8 +118,8 @@ private Component find(String id) { } private String label(Component component) { - String translated = FPSMaster.i18n.get(component.mod.name.toLowerCase()); - return translated.equals(component.mod.name.toLowerCase()) ? component.mod.name : translated; + String translated = FPSMaster.i18n.get(component.mod.name.toLowerCase(java.util.Locale.ROOT)); + return translated.equals(component.mod.name.toLowerCase(java.util.Locale.ROOT)) ? component.mod.name : translated; } private Viewport viewport(float hudWidth, float hudHeight) { diff --git a/src/main/java/top/fpsmaster/ui/screens/replay/DirectorPanel.java b/src/main/java/top/fpsmaster/ui/screens/replay/DirectorPanel.java index ccddfb97..71dfa7f5 100644 --- a/src/main/java/top/fpsmaster/ui/screens/replay/DirectorPanel.java +++ b/src/main/java/top/fpsmaster/ui/screens/replay/DirectorPanel.java @@ -898,7 +898,7 @@ public void pick(int index) { String[] pathLabels = new String[paths.length]; int pathIndex = 0; for (int i = 0; i < paths.length; i++) { - pathLabels[i] = FPSMaster.i18n.get("director.transition." + paths[i].name().toLowerCase()); + pathLabels[i] = FPSMaster.i18n.get("director.transition." + paths[i].name().toLowerCase(java.util.Locale.ROOT)); if (selectedProp.path == paths[i]) { pathIndex = i; } diff --git a/src/main/java/top/fpsmaster/utils/system/OSUtil.java b/src/main/java/top/fpsmaster/utils/system/OSUtil.java index 67606fc8..bb550b9d 100644 --- a/src/main/java/top/fpsmaster/utils/system/OSUtil.java +++ b/src/main/java/top/fpsmaster/utils/system/OSUtil.java @@ -7,23 +7,23 @@ public class OSUtil { public static boolean supportShader = true; public static boolean isMac() { - return System.getProperty("os.name").toLowerCase().contains("mac"); + return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("mac"); } public static boolean isUnix() { - return System.getProperty("os.name").toLowerCase().contains("nix"); + return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("nix"); } public static boolean isSolaris() { - return System.getProperty("os.name").toLowerCase().contains("sunos"); + return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("sunos"); } public static boolean isLinux() { - return System.getProperty("os.name").toLowerCase().contains("linux"); + return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("linux"); } public static boolean isWindows() { - return System.getProperty("os.name").toLowerCase().contains("windows"); + return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("windows"); } public static boolean isAndroid() { From eedc14cd617e09e413076414dd467e63c29c14a9 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:42:52 -0700 Subject: [PATCH 10/15] =?UTF-8?q?fix(minimap):=20=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=8C=89=20IBlockState=20=E5=8F=96=E9=94=AE?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=AE=89=E5=85=A8=EF=BC=8C=E7=BA=B9=E7=90=86?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E5=8A=A0=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不再每个方块列拼 state.toString() 作缓存键(大量短命字符串、hash 碰撞取错色) - 颜色缓存改 ConcurrentHashMap;帧缓冲的 put/flip 与上传在同一把锁下, 加载线程不再在渲染线程上传时移动 ByteBuffer 位置 - 帧重建改为原子的 test-and-set;加载线程设为守护线程,异常走 ClientLogger --- .../java/top/fpsmaster/minimap/Minimap.java | 102 ++++++++++++------ 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/src/main/java/top/fpsmaster/minimap/Minimap.java b/src/main/java/top/fpsmaster/minimap/Minimap.java index b2c09be9..b8212b71 100644 --- a/src/main/java/top/fpsmaster/minimap/Minimap.java +++ b/src/main/java/top/fpsmaster/minimap/Minimap.java @@ -25,6 +25,7 @@ import org.lwjgl.opengl.GL14; import org.lwjgl.opengl.GLContext; import top.fpsmaster.features.impl.interfaces.MiniMap; +import top.fpsmaster.modules.logger.ClientLogger; import top.fpsmaster.forge.api.IMinecraft; import top.fpsmaster.ui.minimap.animation.MinimapAnimation; import top.fpsmaster.ui.minimap.interfaces.Interface; @@ -38,7 +39,8 @@ import java.io.InputStream; import java.nio.ByteBuffer; import java.util.ArrayList; -import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import static top.fpsmaster.utils.core.Utility.mc; @@ -69,11 +71,18 @@ public class Minimap { public MapLoader loader; public MinimapChunk[][] currentBlocks; public MinimapChunk[][] loadingBlocks; - public static boolean frameIsUpdating; - public static boolean frameUpdateNeeded; - public static int bufferSizeToUpdate; - public static float frameUpdatePartialTicks; - public static boolean updatePause; + /* + * Shared between the MapLoader thread and the render thread, hence volatile; the map texture's + * ByteBuffer itself is only touched under BUFFER_LOCK (put/flip on either thread, upload on the + * render thread) — unsynchronized, a put on one thread moved the position under the other's + * upload and threw or uploaded garbage. + */ + public static volatile boolean frameIsUpdating; + public static volatile boolean frameUpdateNeeded; + public static volatile int bufferSizeToUpdate; + public static volatile float frameUpdatePartialTicks; + public static volatile boolean updatePause; + private static final Object BUFFER_LOCK = new Object(); private Integer previousTransparentBlock; private int underRed; private int underGreen; @@ -83,8 +92,13 @@ public class Minimap { public float postBrightness; private int blockY; private int blockColor; - public static HashMap textureColours; - public static HashMap blockColours; + public static Map textureColours; + /** + * Keyed by the state instance itself (block states are canonical singletons). It used to be + * {@code state.toString().hashCode()}: a fresh property string per block column on the loader + * thread, and hash collisions that gave two blocks one colour. + */ + public static Map blockColours; public static boolean clearBlockColours; boolean isglowing; public int[] lastBlockY; @@ -134,12 +148,14 @@ public Minimap(final Interface i) { this.lastBlockY = new int[16]; this.drawYState = 0; this.screen = i; - new Thread(this.loader).start(); + Thread loaderThread = new Thread(this.loader, "FPSMaster-Minimap-Loader"); + // Daemon: it polls arch$getRunning(), but must never be what keeps a closing JVM alive. + loaderThread.setDaemon(true); + loaderThread.start(); } public static int loadBlockColourFromTexture(final IBlockState state, final Block b, final BlockPos pos, final boolean convert) { - final int stateId = state.toString().hashCode(); - Integer c = Minimap.blockColours.get(stateId); + Integer c = Minimap.blockColours.get(state); int red = 0; int green = 0; int blue = 0; @@ -202,16 +218,18 @@ public static int loadBlockColourFromTexture(final IBlockState state, final Bloc return loadBlockColourFromTexture(state, b, pos, false); } c = b.getMapColor(state).colorValue; - Minimap.textureColours.put(name, c); - System.out.println("Block file not found: " + b.getLocalizedName()); + if (name != null) { + Minimap.textureColours.put(name, c); + } + ClientLogger.debug("Minimap: block texture not found for " + b.getLocalizedName()); } catch (Exception e2) { c = b.getMapColor(state).colorValue; if (name != null) { Minimap.textureColours.put(name, c); } - System.out.println("Block " + b.getLocalizedName() + " has no texture, using material colour."); + ClientLogger.debug("Minimap: " + b.getLocalizedName() + " has no texture, using material colour"); } - Minimap.blockColours.put(stateId, c); + Minimap.blockColours.put(state, c); } final int grassColor = b.colorMultiplier(Minecraft.getMinecraft().theWorld, pos); if (grassColor != 16777215) { @@ -607,9 +625,11 @@ public void updateMapFrame(final int bufferSize, final float partial) { currentState = (byte) ((currentState != 1) ? 1 : 0); } this.drawYState = (byte) ((this.drawYState != 1) ? 1 : 0); - final ByteBuffer buffer = Minimap.mapTexture.getBuffer(bufferSize); - buffer.put(this.bytes); - buffer.flip(); + synchronized (BUFFER_LOCK) { + final ByteBuffer buffer = Minimap.mapTexture.getBuffer(bufferSize); + buffer.put(this.bytes); + buffer.flip(); + } } } @@ -696,12 +716,13 @@ public void renderFrameToFBO(final int bufferSize, final int viewW, final float final boolean zooming = (int) Minimap.zoom != Minimap.zoom; final ByteBuffer buffer = Minimap.mapTexture.getBuffer(bufferSize); if (mapX != Minimap.mapUpdateX || mapZ != Minimap.mapUpdateZ || zooming || !retryIfError) { - if (!Minimap.frameIsUpdating) { - Minimap.frameIsUpdating = true; + if (tryBeginFrameUpdate()) { this.updateMapFrame(bufferSize, partial); Minimap.frameIsUpdating = false; - buffer.put(this.bytes); - buffer.flip(); + synchronized (BUFFER_LOCK) { + buffer.put(this.bytes); + buffer.flip(); + } Minimap.bufferSizeToUpdate = -1; Minimap.frameUpdateNeeded = false; } else { @@ -714,13 +735,15 @@ public void renderFrameToFBO(final int bufferSize, final int viewW, final float GL11.glEnable(3553); RenderHelper.disableStandardItemLighting(); try { - bindTextureBuffer(buffer, bufferSize, bufferSize, Minimap.mapTexture.getGlTextureId()); + synchronized (BUFFER_LOCK) { + bindTextureBuffer(buffer, bufferSize, bufferSize, Minimap.mapTexture.getGlTextureId()); + } } catch (Exception e) { if (retryIfError) { - System.out.println("Error when binding texture buffer. Retrying..."); + ClientLogger.warn("Minimap texture upload failed, retrying: " + e); this.renderFrameToFBO(bufferSize, viewW, sizeFix, partial, false); } else { - System.out.println("Error after retrying... :( Please report to Xaero96 on MinecraftForum of PlanetMinecraft!"); + ClientLogger.warn("Minimap texture upload failed again: " + e); } } @@ -823,6 +846,15 @@ public static void bindTextureBuffer(final ByteBuffer image, final int width, fi GL11.GL_RGB, GL11.GL_UNSIGNED_BYTE, image); } + /** Test-and-set for {@link #frameIsUpdating}: both threads may try to rebuild the frame. */ + private static synchronized boolean tryBeginFrameUpdate() { + if (frameIsUpdating) { + return false; + } + frameIsUpdating = true; + return true; + } + public static boolean shouldRenderEntity(final Entity e) { return !e.isSneaking() && !e.isInvisible(); } @@ -846,8 +878,8 @@ public static boolean shouldRenderEntity(final Entity e) { Minimap.bufferSizeToUpdate = -1; Minimap.frameUpdatePartialTicks = 1.0f; Minimap.updatePause = false; - Minimap.textureColours = new HashMap<>(); - Minimap.blockColours = new HashMap<>(); + Minimap.textureColours = new ConcurrentHashMap<>(); + Minimap.blockColours = new ConcurrentHashMap<>(); Minimap.clearBlockColours = false; Minimap.toResetImage = true; Minimap.zoom = 1.0; @@ -944,22 +976,23 @@ public void run() { Minimap.loadedItems = loadingItems; Minimap.loadedEntities = loadingEntities; } - if (!Minimap.frameIsUpdating && Minimap.frameUpdateNeeded) { + if (Minimap.frameUpdateNeeded && tryBeginFrameUpdate()) { Minimap.frameUpdateNeeded = false; - Minimap.frameIsUpdating = true; Minimap.bufferSizeToUpdate = Minimap.this.getFBOBufferSize(); Minimap.this.updateMapFrame(Minimap.bufferSizeToUpdate, Minimap.frameUpdatePartialTicks); Minimap.frameIsUpdating = false; } if (!Minimap.updatePause && Minimap.bufferSizeToUpdate != -1) { - final ByteBuffer buffer = Minimap.mapTexture.getBuffer(Minimap.bufferSizeToUpdate); - buffer.put(Minimap.this.bytes); - buffer.flip(); + synchronized (BUFFER_LOCK) { + final ByteBuffer buffer = Minimap.mapTexture.getBuffer(Minimap.bufferSizeToUpdate); + buffer.put(Minimap.this.bytes); + buffer.flip(); + } Minimap.bufferSizeToUpdate = -1; } } } catch (Exception e2) { - e2.printStackTrace(); + ClientLogger.warn("Minimap loader pass failed: " + e2); Minimap.frameIsUpdating = false; } final int passed = (int) (System.currentTimeMillis() - before); @@ -970,7 +1003,8 @@ public void run() { Thread.sleep(1L); } } catch (InterruptedException e3) { - e3.printStackTrace(); + Thread.currentThread().interrupt(); + return; } } } From 5e43f7095c056ae9bb848e414f23c6c8b4572c33 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:42:52 -0700 Subject: [PATCH 11/15] =?UTF-8?q?perf(blur):=20Kawase=20=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E9=93=BE=E6=8C=89=E7=BA=A7=E9=99=8D=E9=87=87=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 i 级 FBO 改为显示尺寸 >> i,halfpixel 按每级目标尺寸计算,并用线性过滤采样; 原来每级都是全分辨率,Retina/4K 下 HUD 模糊每帧 6 次全屏着色。 --- .../utils/render/shader/KawaseBlur.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/top/fpsmaster/utils/render/shader/KawaseBlur.java b/src/main/java/top/fpsmaster/utils/render/shader/KawaseBlur.java index 2497a52e..b40def7d 100644 --- a/src/main/java/top/fpsmaster/utils/render/shader/KawaseBlur.java +++ b/src/main/java/top/fpsmaster/utils/render/shader/KawaseBlur.java @@ -3,6 +3,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GlStateManager; import net.minecraft.client.shader.Framebuffer; +import org.lwjgl.opengl.GL11; import java.util.ArrayList; import java.util.List; @@ -43,14 +44,22 @@ private static void initFramebuffers(float iterations) { framebuffer = new Framebuffer(1, 1, false); } framebufferList.add(framebuffer); + // Dual Kawase: level i is the display size >> i. Every level used to be full resolution, so + // each pass shaded the whole (on Retina, doubled) screen and the "half-size chain" was not. int i = 1; while (i <= iterations) { - Framebuffer fb = new Framebuffer(mc.displayWidth, mc.displayHeight, false); + Framebuffer fb = new Framebuffer(levelSize(mc.displayWidth, i), levelSize(mc.displayHeight, i), false); + // Levels are sampled up into larger targets; nearest filtering would show the blocks. + fb.setFramebufferFilter(GL11.GL_LINEAR); framebufferList.add(fb); i++; } } + private static int levelSize(int full, int level) { + return Math.max(1, full >> level); + } + public static void renderBlur(int iterations, int offset) { if (iterations <= 0) { return; @@ -61,8 +70,8 @@ public static void renderBlur(int iterations, int offset) { if (currentIterations != iterations || framebufferList.isEmpty() || (framebufferList.size() > 1 - && (framebufferList.get(1).framebufferWidth != mc.displayWidth - || framebufferList.get(1).framebufferHeight != mc.displayHeight))) { + && (framebufferList.get(1).framebufferWidth != levelSize(mc.displayWidth, 1) + || framebufferList.get(1).framebufferHeight != levelSize(mc.displayHeight, 1)))) { initFramebuffers(iterations); currentIterations = iterations; } @@ -112,7 +121,8 @@ private static void renderFBO(Framebuffer framebuffer, int framebufferTexture, S GlStateManager.bindTexture(framebufferTexture); shader.setUniformf("offset", offset, offset); shader.setUniformi("inTexture", 0); - shader.setUniformf("halfpixel", 0.5f / mc.displayWidth, 0.5f / mc.displayHeight); + // Half a texel of the pass's target, as in the reference dual-filter implementation. + shader.setUniformf("halfpixel", 0.5f / framebuffer.framebufferWidth, 0.5f / framebuffer.framebufferHeight); ShaderUtil.drawQuads(); shader.unload(); framebuffer.unbindFramebuffer(); From e12415873f78c2fcafc9da03900ad155bbee484d Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:42:52 -0700 Subject: [PATCH 12/15] =?UTF-8?q?fix(macos):=20=E7=BC=93=E8=A7=A3=20GL-on-?= =?UTF-8?q?Metal=20resize=20=E5=B4=A9=E6=BA=83=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=9C=9F=20AWT=20=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - macOS 上窗口尺寸变化后的两帧跳过向默认 framebuffer 的最终 blit (hs_err 显示崩在这一步的 GLRResourceList::addResource);属缓解,未能复现验证 - 登录界面不再每帧调用 Desktop.isDesktopSupported()(会初始化 AWT),改查 headless --- .../fpsmaster/forge/mixin/MixinMinecraft.java | 36 +++++++++++++++++++ .../ui/screens/signin/SignInScreen.java | 6 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinMinecraft.java b/src/main/java/top/fpsmaster/forge/mixin/MixinMinecraft.java index 6f2a1ac6..5971aaec 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinMinecraft.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinMinecraft.java @@ -366,11 +366,47 @@ public void setTitle(CallbackInfo ci) { @Redirect(method = "runGameLoop", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/shader/Framebuffer;framebufferRender(II)V")) private void edge$presentFrame(net.minecraft.client.shader.Framebuffer framebuffer, int width, int height) { + if (edge$skipPresentAfterResize(width, height)) { + return; + } if (!top.fpsmaster.replay.director.DirectorExporter.presentExportScreen(framebuffer)) { framebuffer.framebufferRender(width, height); } } + /** -1 unknown, 0 no, 1 yes; resolved on first use (no static initializer in a mixin). */ + private int edge$mac = -1; + private int edge$presentW = -1; + private int edge$presentH = -1; + private int edge$presentSkip; + + /** + * macOS mitigation, not a proven fix: hs_err logs after the panorama fix still crash in + * AppleMetalOpenGLRenderer GLRResourceList::addResource during this very blit to the default + * framebuffer, right after a window resize, while the GL-on-Metal layer is still swapping the + * drawable. Skipping the present for the first two frames after a size change keeps the client + * from drawing into it mid-reallocation; the cost is two stale frames per resize step. + */ + private boolean edge$skipPresentAfterResize(int width, int height) { + if (edge$mac < 0) { + edge$mac = top.fpsmaster.utils.system.OSUtil.isMac() ? 1 : 0; + } + if (edge$mac == 0) { + return false; + } + if (width != edge$presentW || height != edge$presentH) { + boolean first = edge$presentW < 0; + edge$presentW = width; + edge$presentH = height; + edge$presentSkip = first ? 0 : 2; + } + if (edge$presentSkip > 0) { + edge$presentSkip--; + return true; + } + return false; + } + /** Fullscreen enter: the desktop DisplayMode is in points on macOS as well. */ @Redirect(method = "toggleFullscreen", at = @At(value = "INVOKE", target = "Lorg/lwjgl/opengl/DisplayMode;getWidth()I")) diff --git a/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java b/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java index f5a93fb9..190f4aee 100644 --- a/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/signin/SignInScreen.java @@ -251,7 +251,11 @@ public void close() { @Override public boolean canOpenWebsite() { - return Desktop.isDesktopSupported(); + // Called every frame. Desktop.isDesktopSupported() initializes the AWT toolkit, which on + // macOS spins up AppKit/CoreAudio-adjacent machinery next to LWJGL's own; two of four + // macOS hs_err logs crashed shortly after AWT threads appeared. Only a headless JRE truly + // cannot browse, and that check does not touch the toolkit; the click handles the rest. + return !java.awt.GraphicsEnvironment.isHeadless(); } @Override From ae3af90cc0b2dc2bcc34a92f50404b548794abc5 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:47:58 -0700 Subject: [PATCH 13/15] =?UTF-8?q?fix(music):=20=E5=8E=BB=E6=8E=89=E4=B8=8E?= =?UTF-8?q?=20Prism=200.2.2=20=E8=B4=A6=E5=8F=B7=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=87=8D=E5=8F=A0=E7=9A=84=E3=80=8C=E6=89=93=E5=BC=80=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E3=80=8D=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地页的「导入音乐」已经会打开音乐文件夹。 --- .../ui/screens/music/MusicScreen.java | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java index 74565197..2d9e7b93 100644 --- a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java @@ -19,7 +19,6 @@ import top.fpsmaster.music.Track; import top.fpsmaster.prism.screen.MusicBridge; import top.fpsmaster.prism.screen.SharedMusic; -import top.fpsmaster.prism.widget.Chrome; import top.fpsmaster.prism.widget.UiFrame; import top.fpsmaster.ui.kit.EdgeUi; import top.fpsmaster.utils.io.FileUtils; @@ -149,31 +148,10 @@ private static String describe(Throwable error) { @Override public void render(int mouseX, int mouseY, float partialTicks) { - UiFrame ui = EdgeUi.frame(); - if (gui.draw(ui, bridge)) { + // No extra "open folder" button any more: Prism 0.2.2 puts the account chip in that spot, + // and the Local tab's "Import music" already opens the folder (importLocalMusic). + if (gui.draw(EdgeUi.frame(), bridge)) { mc.displayGuiScreen(parent); - return; - } - drawOpenFolderButton(ui); - } - - private void drawOpenFolderButton(UiFrame ui) { - float gw = ui.host().width(); - float gh = ui.host().height(); - float pw = Math.min(480f, Math.max(320f, gw - 24f)); - float ph = Math.min(290f, Math.max(200f, gh - 32f)); - float px = (gw - pw) / 2f; - float py = (gh - ph) / 2f; - float size = 16f; - float closeX = px + pw - 7f - size; - float settingsX = closeX - 21f; - float folderX = settingsX - 21f; - float folderY = py + 7f; - String label = FPSMaster.i18n.get("music.local.openfolder"); - float labelW = ui.font(12).measure(label) + 16f; - float buttonX = folderX - labelW + size; - if (Chrome.button(ui, buttonX, folderY, labelW, size, label, Chrome.ButtonStyle.GHOST)) { - openLocalMusicFolder(); } } From 7db063499f4a7917f6e13a9815dac51a3a3a9423 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:56:03 -0700 Subject: [PATCH 14/15] =?UTF-8?q?fix(account):=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=97=A7=20accounts.json=20=E6=97=B6=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E8=BF=9B=E5=B7=B2=E6=9C=89=20edge=5Faccounts.json=EF=BC=8C?= =?UTF-8?q?=E5=86=99=E5=85=A5=E6=88=90=E5=8A=9F=E5=90=8E=E6=89=8D=E5=88=A0?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧文件按游戏目录各存一份,新文件全用户共用一份。此前目标已存在就直接删除旧文件, 多实例下第二个实例的账号与 Microsoft refresh token 会被永久丢弃。现在先读目标, 按 uuid(Microsoft)/名称(离线)去重合并旧账号,保存成功后再删旧文件; 目标或旧文件读不出时保留旧文件不动。 --- .../modules/account/AccountManager.java | 113 +++++++++++++----- .../account/AccountManagerMergeTest.java | 47 ++++++++ 2 files changed, 128 insertions(+), 32 deletions(-) create mode 100644 src/test/java/top/fpsmaster/modules/account/AccountManagerMergeTest.java diff --git a/src/main/java/top/fpsmaster/modules/account/AccountManager.java b/src/main/java/top/fpsmaster/modules/account/AccountManager.java index e67719d7..8e796eb3 100644 --- a/src/main/java/top/fpsmaster/modules/account/AccountManager.java +++ b/src/main/java/top/fpsmaster/modules/account/AccountManager.java @@ -289,6 +289,10 @@ private Account findStored(Account account) { } private Account findOffline(String name) { + return findOffline(accounts, name); + } + + private static Account findOffline(List accounts, String name) { for (Account account : accounts) { if (!account.isMicrosoft() && namesEqual(account.name, name)) { return account; @@ -298,6 +302,10 @@ private Account findOffline(String name) { } private Account findMicrosoft(String uuid) { + return findMicrosoft(accounts, uuid); + } + + private static Account findMicrosoft(List accounts, String uuid) { if (uuid == null) { return null; } @@ -358,55 +366,94 @@ private File legacyFile() { return new File(FileUtils.dir, "accounts.json"); } - /** Moves accounts.json out of the shareable game folder the first time this version runs. */ + /** + * Moves accounts.json out of the shareable game folder the first time this version runs. Every + * game directory has its own legacy file but they all migrate into the one per-user file, so the + * legacy accounts are merged into whatever is already there, and the legacy file is deleted only + * after the merged list has been written. + */ private void migrateLegacyFile() { File legacy = legacyFile(); - File target = file(); if (!legacy.isFile()) { return; } + List legacyAccounts = new ArrayList(); + String legacySelected; + try { + legacySelected = parse(read(legacy), legacyAccounts); + } catch (IOException | RuntimeException exception) { + ClientLogger.warn("Failed to read legacy accounts.json, leaving it in place: " + exception); + return; + } + merge(accounts, legacyAccounts); + if (selectedName == null || selectedName.isEmpty()) { + selectedName = legacySelected; + } + if (!save()) { + return; + } try { - if (!target.exists()) { - AtomicFiles.write(target.toPath(), Files.readAllBytes(legacy.toPath())); - } Files.delete(legacy.toPath()); - ClientLogger.info("Moved accounts.json out of the game folder to " + target.getAbsolutePath()); + ClientLogger.info("Merged accounts.json from the game folder into " + file().getAbsolutePath()); } catch (IOException exception) { - ClientLogger.warn("Failed to move accounts.json out of the game folder: " + exception); + ClientLogger.warn("Merged accounts.json but failed to delete the legacy copy: " + exception); } } private void load() { - migrateLegacyFile(); File file = file(); - if (!file.isFile()) { - return; - } - try { - String json = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - JsonElement root = new JsonParser().parse(json); - if (root == null || root.isJsonNull()) { + if (file.isFile()) { + try { + selectedName = parse(read(file), accounts); + } catch (IOException | RuntimeException exception) { + // Do not merge into (and overwrite) a target we could not read. + ClientLogger.warn("Failed to load accounts.json: " + exception); return; } - if (root.isJsonArray()) { - readAccounts(root.getAsJsonArray()); - return; + } + migrateLegacyFile(); + } + + private static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + /** Appends the accounts in {@code json} to {@code out}; returns the stored selection, if any. */ + static String parse(String json, List out) { + JsonElement root = new JsonParser().parse(json); + if (root == null || root.isJsonNull()) { + return null; + } + if (root.isJsonArray()) { + readAccounts(root.getAsJsonArray(), out); + return null; + } + String selected = null; + if (root.isJsonObject()) { + JsonObject object = root.getAsJsonObject(); + if (object.has("selected") && !object.get("selected").isJsonNull()) { + selected = object.get("selected").getAsString(); } - if (root.isJsonObject()) { - JsonObject object = root.getAsJsonObject(); - if (object.has("selected") && !object.get("selected").isJsonNull()) { - selectedName = object.get("selected").getAsString(); - } - if (object.has("accounts") && object.get("accounts").isJsonArray()) { - readAccounts(object.getAsJsonArray("accounts")); - } + if (object.has("accounts") && object.get("accounts").isJsonArray()) { + readAccounts(object.getAsJsonArray("accounts"), out); } - } catch (IOException | RuntimeException exception) { - ClientLogger.warn("Failed to load accounts.json: " + exception); } + return selected; } - private void readAccounts(JsonArray array) { + /** Adds the {@code extra} accounts {@code into} lacks; Microsoft by uuid, offline by name. */ + static void merge(List into, List extra) { + for (Account account : extra) { + Account existing = account.isMicrosoft() + ? findMicrosoft(into, account.uuid) + : findOffline(into, account.name); + if (existing == null) { + into.add(account); + } + } + } + + private static void readAccounts(JsonArray array, List out) { for (JsonElement element : array) { if (element == null || !element.isJsonObject()) { continue; @@ -420,17 +467,17 @@ private void readAccounts(JsonArray array) { } if (account.isMicrosoft()) { account.uuid = MicrosoftAuth.dashedUuid(account.uuid); - accounts.add(account); + out.add(account); } else if (isValidUsername(account.name)) { if (account.uuid == null || account.uuid.isEmpty()) { account.uuid = offlineUuid(account.name); } - accounts.add(account); + out.add(account); } } } - private void save() { + private boolean save() { try { JsonObject root = new JsonObject(); if (selectedName != null) { @@ -438,8 +485,10 @@ private void save() { } root.add("accounts", GSON.toJsonTree(accounts)); AtomicFiles.write(file().toPath(), GSON.toJson(root).getBytes(StandardCharsets.UTF_8)); + return true; } catch (IOException exception) { ClientLogger.warn("Failed to save accounts.json: " + exception); + return false; } } } diff --git a/src/test/java/top/fpsmaster/modules/account/AccountManagerMergeTest.java b/src/test/java/top/fpsmaster/modules/account/AccountManagerMergeTest.java new file mode 100644 index 00000000..b71a80fd --- /dev/null +++ b/src/test/java/top/fpsmaster/modules/account/AccountManagerMergeTest.java @@ -0,0 +1,47 @@ +package top.fpsmaster.modules.account; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class AccountManagerMergeTest { + @Test + void legacyAccountsAreMergedIntoExistingTarget() { + List target = new ArrayList(); + String selected = AccountManager.parse("{\"selected\":\"Alpha\",\"accounts\":[" + + "{\"name\":\"Alpha\",\"type\":\"offline\"}," + + "{\"name\":\"MsOne\",\"uuid\":\"0123456789abcdef0123456789abcdef\",\"type\":\"microsoft\"," + + "\"refreshToken\":\"keep\"}]}", target); + assertEquals("Alpha", selected); + + List legacy = new ArrayList(); + String legacySelected = AccountManager.parse("{\"selected\":\"MsTwo\",\"accounts\":[" + + "{\"name\":\"alpha\",\"type\":\"offline\"}," + + "{\"name\":\"MsOne\",\"uuid\":\"01234567-89ab-cdef-0123-456789abcdef\",\"type\":\"microsoft\"," + + "\"refreshToken\":\"stale\"}," + + "{\"name\":\"MsTwo\",\"uuid\":\"fedcba9876543210fedcba9876543210\",\"type\":\"microsoft\"," + + "\"refreshToken\":\"other\"}," + + "{\"name\":\"Beta\"}]}", legacy); + assertEquals("MsTwo", legacySelected); + + AccountManager.merge(target, legacy); + + assertEquals(4, target.size()); + assertEquals("keep", target.get(1).refreshToken); + assertEquals("MsTwo", target.get(2).name); + assertEquals("other", target.get(2).refreshToken); + assertEquals("Beta", target.get(3).name); + assertEquals(AccountManager.TYPE_OFFLINE, target.get(3).type); + } + + @Test + void bareArrayHasNoSelection() { + List out = new ArrayList(); + assertNull(AccountManager.parse("[{\"name\":\"Gamma\"}]", out)); + assertEquals(1, out.size()); + } +} From 8c0bae54ca77f871b4d0cd7b0ca035870c92a0e5 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 04:15:35 -0700 Subject: [PATCH 15/15] ci: pin Prism 0.2.2 and Cadence v0.1.2 to their merged commits --- .github/workflows/ci-release.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index f4270ed9..365ca3f2 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -152,8 +152,7 @@ jobs: uses: actions/checkout@v4 with: repository: FPSMasterTeam/fpsmaster-prism - # TODO: Edge 依赖 prism 0.2.2,这里先指向 fix/prism-review 的提交; - # Prism 合并到主分支后改成合并后的提交(或 0.2.2 的 tag)。 + # Prism 0.2.2(fix/prism-review 已合入 main 的提交)。 ref: e3b6e244f912828bd11d777683c47efd94c79121 path: fpsmaster-prism @@ -170,7 +169,7 @@ jobs: uses: actions/checkout@v4 with: repository: FPSMasterTeam/Cadence - # TODO: 先指向 fix/cadence-review 的提交;Cadence 合并并打 0.1.2 tag 后改成合并后的提交。 + # Cadence v0.1.2。 ref: 9e60bab7834a73ed4ceead49ecec2d07af708b80 path: cadence